nordisk/heimdall
crates/heimdall-plain/src/image.rs
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB
//! Pinned OCI images from a local OCI image layout
//! (`oci-layout`, `index.json`, `blobs/sha256/<hex>`).
//!
//! Heimdall is a defensive sandbox. Before EVERY job the runner re-reads the
//! manifest named by the job's pinned digest, checks its SHA-256, checks the
//! config blob and every layer blob against the digests and sizes the
//! manifest lists (while unpacking them), and unpacks the layers into a fresh
//! root for that job only. Nothing is cached between jobs, so
//! `image_verified_each_start` is true.
//!
//! Layer entries are unpacked without leaving the root: absolute paths and
//! `..` are refused, a path whose parent is a symlink is refused, device
//! nodes are skipped (a rootless runtime cannot create them), ownership is
//! not applied (every file belongs to the job's mapped root), and OCI
//! whiteouts (`.wh.<name>`, `.wh..wh..opq`) are applied.
use std::fs;
use std::io::{self, Read};
use std::path::{Component, Path, PathBuf};
use heimdall_core::{ImageRef, Sha256Digest};
use serde::Deserialize;
use sha2::{Digest, Sha256};
/// Largest manifest or config blob read into memory.
pub const MAX_JSON_BLOB: u64 = 4 * 1024 * 1024;
const MT_MANIFEST: &str = "application/vnd.oci.image.manifest.v1+json";
const MT_DOCKER_MANIFEST: &str = "application/vnd.docker.distribution.manifest.v2+json";
const MT_INDEX: &str = "application/vnd.oci.image.index.v1+json";
const MT_DOCKER_LIST: &str = "application/vnd.docker.distribution.manifest.list.v2+json";
/// Why an image is refused.
#[derive(Debug, thiserror::Error)]
pub enum ImageError {
#[error("image digest `{0}` is not sha256:<64 lower-case hex>")]
BadDigest(String),
#[error("blob {0} is not in the image layout")]
MissingBlob(String),
#[error("blob {digest}: sha256 is {got}")]
DigestMismatch { digest: String, got: String },
#[error("blob {digest}: size is {got}, the manifest says {expected}")]
SizeMismatch {
digest: String,
expected: u64,
got: u64,
},
#[error("blob {0} is larger than {MAX_JSON_BLOB} bytes")]
TooLarge(String),
#[error("manifest: {0}")]
Manifest(String),
#[error("unsupported layer media type {0}")]
LayerType(String),
#[error("layer entry `{0}` would leave the root")]
Escape(String),
#[error("i/o: {0}")]
Io(#[from] io::Error),
}
#[derive(Deserialize)]
struct Descriptor {
#[serde(rename = "mediaType", default)]
media_type: String,
digest: String,
size: u64,
#[serde(default)]
platform: Option<Platform>,
}
#[derive(Deserialize)]
struct Platform {
architecture: String,
os: String,
}
#[derive(Deserialize)]
struct Manifest {
#[serde(rename = "mediaType", default)]
media_type: String,
#[serde(default)]
config: Option<Descriptor>,
#[serde(default)]
layers: Vec<Descriptor>,
#[serde(default)]
manifests: Vec<Descriptor>,
}
#[derive(Deserialize, Default)]
struct ImageConfig {
#[serde(default)]
config: Option<ImageConfigInner>,
}
#[derive(Deserialize, Default)]
struct ImageConfigInner {
#[serde(rename = "Env", default)]
env: Option<Vec<String>>,
}
/// What the runner needs from a verified, unpacked image.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Unpacked {
/// The image's `Env` (or a default PATH).
pub env: Vec<String>,
/// How many layers were verified and unpacked.
pub layers: usize,
}
/// A local OCI image layout directory.
#[derive(Debug, Clone)]
pub struct OciLayout {
root: PathBuf,
}
fn hex_of(digest: &str) -> Result<&str, ImageError> {
let h = digest
.strip_prefix("sha256:")
.ok_or_else(|| ImageError::BadDigest(digest.into()))?;
if h.len() != 64 || !h.bytes().all(|c| matches!(c, b'0'..=b'9' | b'a'..=b'f')) {
return Err(ImageError::BadDigest(digest.into()));
}
Ok(h)
}
/// Wraps a reader, hashing and counting what passes through.
struct HashingReader<R> {
inner: R,
hasher: Sha256,
n: u64,
}
impl<R: Read> Read for HashingReader<R> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let k = self.inner.read(buf)?;
self.hasher.update(&buf[..k]);
self.n += k as u64;
Ok(k)
}
}
impl OciLayout {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
fn blob_path(&self, digest: &str) -> Result<PathBuf, ImageError> {
Ok(self.root.join("blobs/sha256").join(hex_of(digest)?))
}
fn open_blob(&self, digest: &str) -> Result<fs::File, ImageError> {
let p = self.blob_path(digest)?;
fs::File::open(&p).map_err(|e| match e.kind() {
io::ErrorKind::NotFound => ImageError::MissingBlob(digest.into()),
_ => ImageError::Io(e),
})
}
fn check(digest: &str, h: Sha256, n: u64, size: Option<u64>) -> Result<(), ImageError> {
let got = format!("sha256:{}", heimdall_core::hex::encode(&h.finalize()));
if got != digest {
return Err(ImageError::DigestMismatch {
digest: digest.into(),
got,
});
}
if let Some(expected) = size {
if expected != n {
return Err(ImageError::SizeMismatch {
digest: digest.into(),
expected,
got: n,
});
}
}
Ok(())
}
/// Read a small blob and check its digest (and size, when known).
fn read_json_blob(&self, digest: &str, size: Option<u64>) -> Result<Vec<u8>, ImageError> {
let f = self.open_blob(digest)?;
let mut buf = Vec::new();
f.take(MAX_JSON_BLOB + 1).read_to_end(&mut buf)?;
if buf.len() as u64 > MAX_JSON_BLOB {
return Err(ImageError::TooLarge(digest.into()));
}
let mut h = Sha256::new();
h.update(&buf);
Self::check(digest, h, buf.len() as u64, size)?;
Ok(buf)
}
fn manifest(&self, digest: &str, size: Option<u64>, depth: u8) -> Result<Manifest, ImageError> {
let bytes = self.read_json_blob(digest, size)?;
let m: Manifest =
serde_json::from_slice(&bytes).map_err(|e| ImageError::Manifest(e.to_string()))?;
let is_index = m.media_type == MT_INDEX
|| m.media_type == MT_DOCKER_LIST
|| (m.media_type.is_empty() && !m.manifests.is_empty());
if is_index {
if depth > 0 {
return Err(ImageError::Manifest("nested image index".into()));
}
let arch = match std::env::consts::ARCH {
"x86_64" => "amd64",
"aarch64" => "arm64",
a => a,
};
let d = m
.manifests
.iter()
.find(|d| {
d.platform
.as_ref()
.is_some_and(|p| p.os == "linux" && p.architecture == arch)
})
.ok_or_else(|| {
ImageError::Manifest(format!("image index has no linux/{arch} manifest"))
})?;
return self.manifest(&d.digest, Some(d.size), depth + 1);
}
if !(m.media_type.is_empty()
|| m.media_type == MT_MANIFEST
|| m.media_type == MT_DOCKER_MANIFEST)
{
return Err(ImageError::Manifest(format!(
"unsupported manifest media type {}",
m.media_type
)));
}
if m.config.is_none() {
return Err(ImageError::Manifest("manifest has no config".into()));
}
Ok(m)
}
/// Verify the image pinned by `image` and unpack it into `rootfs` (which
/// must exist and be empty).
pub fn unpack(&self, image: &ImageRef, rootfs: &Path) -> Result<Unpacked, ImageError> {
if image.sha256().is_none() {
return Err(ImageError::BadDigest(image.digest.clone()));
}
let m = self.manifest(&image.digest, None, 0)?;
let cfg_desc = m.config.as_ref().expect("checked above");
let cfg_bytes = self.read_json_blob(&cfg_desc.digest, Some(cfg_desc.size))?;
let cfg: ImageConfig = serde_json::from_slice(&cfg_bytes).unwrap_or_default();
for layer in &m.layers {
self.unpack_layer(layer, rootfs)?;
}
let env = cfg.config.and_then(|c| c.env).unwrap_or_else(|| {
vec!["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".into()]
});
Ok(Unpacked {
env,
layers: m.layers.len(),
})
}
fn unpack_layer(&self, d: &Descriptor, rootfs: &Path) -> Result<(), ImageError> {
let gz = match d.media_type.as_str() {
"application/vnd.oci.image.layer.v1.tar"
| "application/vnd.docker.image.rootfs.diff.tar" => false,
"application/vnd.oci.image.layer.v1.tar+gzip"
| "application/vnd.docker.image.rootfs.diff.tar.gzip" => true,
other => return Err(ImageError::LayerType(other.into())),
};
let mut hr = HashingReader {
inner: self.open_blob(&d.digest)?,
hasher: Sha256::new(),
n: 0,
};
{
let r: Box<dyn Read + '_> = if gz {
Box::new(flate2::read::GzDecoder::new(&mut hr))
} else {
Box::new(&mut hr)
};
let mut ar = tar::Archive::new(r);
ar.set_preserve_permissions(true);
ar.set_preserve_ownerships(false);
ar.set_unpack_xattrs(false);
for entry in ar.entries()? {
let mut entry = entry?;
apply_entry(&mut entry, rootfs)?;
}
// Drain what tar did not read (padding), so the digest covers the blob.
let mut rest = ar.into_inner();
io::copy(&mut rest, &mut io::sink())?;
}
let HashingReader { hasher, n, .. } = hr;
Self::check(&d.digest, hasher, n, Some(d.size))
}
}
/// The entry's path inside the root, refusing anything that is not a plain
/// relative path.
fn safe_rel(p: &Path) -> Result<PathBuf, ImageError> {
let mut out = PathBuf::new();
for c in p.components() {
match c {
Component::Normal(x) => out.push(x),
Component::CurDir => {}
_ => return Err(ImageError::Escape(p.display().to_string())),
}
}
Ok(out)
}
/// Refuse when any existing ancestor of `rel` under `root` is a symlink.
fn no_symlink_parents(root: &Path, rel: &Path) -> Result<(), ImageError> {
let mut cur = root.to_path_buf();
if let Some(parent) = rel.parent() {
for c in parent.components() {
cur.push(c);
match fs::symlink_metadata(&cur) {
Ok(m) if m.file_type().is_symlink() => {
return Err(ImageError::Escape(rel.display().to_string()))
}
Ok(_) => {}
Err(e) if e.kind() == io::ErrorKind::NotFound => break,
Err(e) => return Err(e.into()),
}
}
}
Ok(())
}
fn remove_any(p: &Path) -> io::Result<()> {
match fs::symlink_metadata(p) {
Ok(m) if m.is_dir() => fs::remove_dir_all(p),
Ok(_) => fs::remove_file(p),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
fn apply_entry<R: Read>(entry: &mut tar::Entry<'_, R>, rootfs: &Path) -> Result<(), ImageError> {
let path = entry.path()?.into_owned();
let rel = safe_rel(&path)?;
if rel.as_os_str().is_empty() {
return Ok(());
}
no_symlink_parents(rootfs, &rel)?;
let name = rel
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let parent = rootfs.join(rel.parent().unwrap_or(Path::new("")));
if name == ".wh..wh..opq" {
if parent.is_dir() {
for e in fs::read_dir(&parent)? {
remove_any(&e?.path())?;
}
}
return Ok(());
}
if let Some(target) = name.strip_prefix(".wh.") {
remove_any(&parent.join(target))?;
return Ok(());
}
use tar::EntryType as T;
match entry.header().entry_type() {
T::Block | T::Char | T::Fifo => return Ok(()),
T::Link => {
// A hard link must point at a path inside the root too.
if let Some(t) = entry.link_name()? {
safe_rel(&t)?;
}
}
_ => {}
}
let dst = rootfs.join(&rel);
if !entry.header().entry_type().is_dir() {
remove_any(&dst)?;
}
if !entry.unpack_in(rootfs)? {
return Err(ImageError::Escape(rel.display().to_string()));
}
Ok(())
}
/// SHA-256 of a file as `sha256:<hex>` (for building test layouts).
pub fn file_digest(p: &Path) -> io::Result<(String, u64)> {
let b = fs::read(p)?;
Ok((
format!("sha256:{}", Sha256Digest::of(&b).to_hex()),
b.len() as u64,
))
}