delonix-rust tutorial

Projecto: minicontainer · 20 min de leitura

2 · Imagem OCI#

mc unpack <layout> <bundle> transforma uma imagem OCI numa pasta que o runtime sabe correr. Já viste o formato no capítulo OCI; aqui está o código.

Ter uma imagem para desempacotar#

Sem acesso a um registo (o pull é matéria do delonix, não do mini-projecto), scripts/make-oci-layout.sh gera um image layout válido a partir de um rootfs: duas layers — a segunda apaga um ficheiro da primeira com um whiteout e acrescenta outro — com config, manifest e index, tudo com os digests certos.

scripts/make-rootfs.sh /tmp/b
scripts/make-oci-layout.sh /tmp/b /tmp/img
mc unpack /tmp/img /tmp/b2 && mc run img -b /tmp/b2
saída real · medida neste host
$ find oci-layout -type f   # (digests abreviados)
./blobs/sha256/a184019c3c14…
./blobs/sha256/c47a79988f17…
./blobs/sha256/d16cfcdae88e…
./blobs/sha256/e935c9788314…
./index.json
./oci-layout
$ mc unpack layout bundle2
[exit 0]
$ ls bundle2/rootfs/etc   # 'removido-pela-layer-2' foi apagado pelo whiteout
motd
passwd
$ jq -c .process bundle2/config.json
{"terminal":false,"args":["/bin/sh","-c","cat /etc/motd; ls /etc; echo cwd=$(pwd) MSG=$MSG"],"env":["PATH=/bin","MSG=ola-da-imagem"],"cwd":"/tmp"}
$ mc run img -b bundle2
vem da layer 2
motd
passwd
cwd=/tmp MSG=ola-da-imagem
[exit 0]

O que esta saída prova, ponto por ponto:

  • Entrypoint + Cmd da imagem tornaram-se os process.args do bundle;
  • Env e WorkingDir foram aplicados (MSG=ola-da-imagem, cwd=/tmp);
  • whiteout: removido-pela-layer-2 estava na layer 1 e não está no rootfs;
  • e o motd que só existe na layer 2 lê-se vem da layer 2.

O algoritmo, em dez linhas#

minicontainer/src/image.rs · unpackver no GitHub ↗
/// Desempacota `layout` em `bundle/rootfs` e gera `bundle/config.json`.
pub fn unpack(layout: &Path, bundle: &Path) -> Result<()> {
    let index: Index = read_json(&layout.join("index.json"))?;
    let entry = index
        .manifests
        .iter()
        .find(|d| d.media_type == "application/vnd.oci.image.manifest.v1+json")
        .ok_or_else(|| Error::Unsupported("index.json without an OCI image manifest".into()))?;

    let manifest: Manifest = read_json(&verified_blob(layout, &entry.digest)?)?;
    let config: ImageConfig = read_json(&verified_blob(layout, &manifest.config.digest)?)?;

    // Verifica TODAS as layers antes de escrever qualquer byte no bundle.
    let layers: Vec<(PathBuf, &str)> = manifest
        .layers
        .iter()
        .map(|d| Ok((verified_blob(layout, &d.digest)?, d.media_type.as_str())))
        .collect::<Result<_>>()?;

    let rootfs = bundle.join("rootfs");
    fs::create_dir_all(&rootfs).ctx(|| format!("creating {}", rootfs.display()))?;
    for (path, media_type) in &layers {
        apply_layer(path, media_type, &rootfs)?;
    }

    let cfg = &config.config;
    let mut args = cfg.entrypoint.clone();
    args.extend(cfg.cmd.iter().cloned());
    if args.is_empty() {
        return Err(Error::Spec("image has neither Entrypoint nor Cmd".into()));
    }
    let mut spec = Spec::new_default(args);
    if !cfg.env.is_empty() {
        spec.process.env = cfg.env.clone();
    }
    if !cfg.working_dir.is_empty() {
        spec.process.cwd = cfg.working_dir.clone();
    }
    let json = serde_json::to_vec_pretty(&spec)
        .map_err(|source| Error::Json { path: bundle.join("config.json"), source })?;
    write_atomic(&bundle.join("config.json"), &json)
}

A ordem é deliberada:

  1. Verifica tudo primeiro. O manifest, a config e cada layer são verificados contra os seus digests antes de se escrever um byte no bundle. Um erro a meio da extracção deixaria um rootfs meio-feito (e potencialmente adulterado) para trás.
  2. Aplica as layers pela ordem do manifest — a ordem é o significado.
  3. Só no fim gera o config.json, e com escrita atómica (write_atomic): um bundle ou tem config completa ou não a tem.

Repara no .collect::<Result<_>>()? — um iterador de Result recolhido para um Result<Vec> para à primeira falha. É o idioma para «faz isto a todos e aborta se algum falhar».

Aplicar uma layer#

minicontainer/src/image.rs · layerver no GitHub ↗
fn apply_layer(blob: &Path, media_type: &str, rootfs: &Path) -> Result<()> {
    let file = fs::File::open(blob).ctx(|| format!("opening {}", blob.display()))?;
    let reader: Box<dyn Read> = match media_type {
        "application/vnd.oci.image.layer.v1.tar+gzip" => Box::new(flate2::read::GzDecoder::new(file)),
        "application/vnd.oci.image.layer.v1.tar" => Box::new(file),
        other => return Err(Error::Unsupported(format!("layer media type {other}"))),
    };
    let mut archive = tar::Archive::new(reader);
    archive.set_preserve_ownerships(false); // sem root não há chown; e não devia haver.
    archive.set_overwrite(true);

    for entry in archive.entries().ctx(|| format!("reading {}", blob.display()))? {
        let mut entry = entry.ctx(|| "reading tar entry".to_string())?;
        let path = entry.path().ctx(|| "tar entry path".to_string())?.into_owned();
        let kind = entry.header().entry_type();

        // Whiteouts: `.wh.<nome>` apaga o que as layers de baixo puseram; `.wh..wh..opq` esvazia o dir.
        if let Some(name) = path.file_name().and_then(|n| n.to_str())
            && let Some(target) = name.strip_prefix(".wh.")
        {
            let parent =
                crate::fsutil::resolve_in_root(rootfs, path.parent().unwrap_or_else(|| Path::new("")), false);
            if let Ok(parent) = parent {
                if target == ".wh..opq" {
                    empty_dir(&parent)?;
                } else {
                    remove_any(&parent.join(target))?;
                }
            }
            continue;
        }
        // Sem root não se criam device nodes — e um device dentro de uma imagem é suspeito.
        if kind.is_block_special() || kind.is_character_special() {
            eprintln!("warning: skipping device node {}", path.display());
            continue;
        }
        // `unpack_in` recusa `..` e symlinks que escapem do destino.
        if !entry.unpack_in(rootfs).ctx(|| format!("unpacking {}", path.display()))? {
            return Err(Error::UnsafePath(path));
        }
    }
    make_dirs_writable(rootfs)
}

Cada linha faz uma escolha de segurança:

  • set_preserve_ownerships(false) — sem root não há chown; e mesmo com root, não devíamos deixar uma imagem escolher os donos dos ficheiros do host.
  • Whiteouts tratados antes de extrair, e o directório-pai resolvido com resolve_in_root (recusa .. e symlinks): um whiteout hostil não pode apagar fora do rootfs.
  • Nodes de dispositivo são saltados (com aviso). Uma imagem legítima não precisa de mknod — e uma que traga /dev/sda está a pedir problemas.
  • unpack_in do crate tar recusa entradas com .. e symlinks que escapem do destino; se devolver false, o mc falha com UnsafePath.
  • make_dirs_writable — uma layer pode trazer directórios 0555, e a seguinte precisa de lá escrever (somos o dono, mas sem o bit de escrita nem o dono escreve).

Tar-slip é a vulnerabilidade clássica de extractores

Um tar com uma entrada ../../home/user/.ssh/authorized_keys escreve fora do destino se o extractor fizer destino.join(entrada). O delonix já teve um path traversal em whiteouts OCI (safe_rel + confinamento canonicalizado), e outro no COPY do build (safe_join). Nunca junto caminhos vindos de um arquivo sem os validar.

A config da imagem vira o process#

O config.json gerado usa os defaults de um bundle nosso (Spec::new_default) e sobrepõe o que a imagem declara:

{"terminal":false,"args":["/bin/sh","-c","cat /etc/motd; ls /etc; echo cwd=$(pwd) MSG=$MSG"],
 "env":["PATH=/bin","MSG=ola-da-imagem"],"cwd":"/tmp"}

Uma imagem sem Entrypoint nem Cmd é recusada com uma mensagem (image has neither Entrypoint nor Cmd) — não há o que correr.

O que este unpack não faz#

Limitações honestas

  • Só image layout local. Não fala com registos (autenticação, Accept de media types, fallback de manifest lists multi-arquitectura, retoma de downloads — o capítulo 6 mostra a retoma).
  • Só sha256 e só layers tar / tar+gzip (não zstd).
  • Não escolhe a plataforma de um image index multi-arch: usa o primeiro manifest de imagem.
  • Uma cópia do rootfs por bundle. O delonix partilha as layers entre containers com overlayfs montado dentro do user namespace — medido: 6 containers da mesma imagem em 1 MiB cada contra 17 MiB de layers.

Verifica#

cargo test -p minicontainer image::                 # digest malformado, blob adulterado
scripts/demo.sh && cat content/outputs/12-digest-adulterado.txt
saída real · medida neste host
$ echo x >> blobs/sha256/<layer1>   # adulterar 1 byte
$ mc unpack layout-adulterado bundle3
mc: digest mismatch for sha256:<sha256>: expected <sha256>, got <sha256>
[exit 1]

Agora que há um rootfs no disco, falta o difícil: isolar e arrancar.