Gunnar

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,
    ))
}