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
$ 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+Cmdda imagem tornaram-se osprocess.argsdo bundle;EnveWorkingDirforam aplicados (MSG=ola-da-imagem,cwd=/tmp);- whiteout:
removido-pela-layer-2estava na layer 1 e não está no rootfs; - e o
motdque só existe na layer 2 lê-sevem da layer 2.
O algoritmo, em dez linhas#
/// 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:
- Verifica tudo primeiro. O
manifest, aconfige 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. - Aplica as layers pela ordem do manifest — a ordem é o significado.
- 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#
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/sdaestá a pedir problemas. unpack_indo cratetarrecusa entradas com..e symlinks que escapem do destino; se devolverfalse, omcfalha comUnsafePath.make_dirs_writable— uma layer pode trazer directórios0555, 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,
Acceptde media types, fallback de manifest lists multi-arquitectura, retoma de downloads — o capítulo 6 mostra a retoma). - Só
sha256e só layerstar/tar+gzip(nãozstd). - 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
overlayfsmontado 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
$ 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.