diff --git a/crates/ecr/src/extract.rs b/crates/ecr/src/extract.rs index 612dc21..644e27b 100644 --- a/crates/ecr/src/extract.rs +++ b/crates/ecr/src/extract.rs @@ -3,7 +3,8 @@ use anyhow::{Context, Result}; use indicatif::{ProgressBar, ProgressStyle}; use std::fs::File; use std::io::{BufReader, Read}; -use std::path::Path; +use std::os::unix::fs::PermissionsExt; +use std::path::{Component, Path, PathBuf}; /// Extract a tarball to the specified directory pub fn extract_tarball(tarball: &Path, dest: &Path) -> Result<()> { @@ -30,16 +31,17 @@ fn extract_single_archive(tarball: &Path, dest: &Path) -> Result<()> { .with_context(|| format!("Failed to open tarball: {}", tarball.display()))?; let reader = BufReader::new(file); + let mut fixups = DeferredFixups::default(); // Detect compression format from filename if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") { - extract_gz(reader, dest)?; + extract_gz(reader, dest, &mut fixups)?; } else if filename.ends_with(".tar.xz") || filename.ends_with(".txz") { - extract_xz(reader, dest)?; + extract_xz(reader, dest, &mut fixups)?; } else if filename.ends_with(".tar.zst") || filename.ends_with(".tar.zstd") { - extract_zst(reader, dest)?; + extract_zst(reader, dest, &mut fixups)?; } else if filename.ends_with(".tar") { - extract_tar(reader, dest)?; + extract_tar(reader, dest, &mut fixups)?; } else { // Try to detect from magic bytes let mut magic = [0u8; 6]; @@ -51,30 +53,30 @@ fn extract_single_archive(tarball: &Path, dest: &Path) -> Result<()> { // gzip magic drop(peek_reader); let file = File::open(tarball)?; - extract_gz(BufReader::new(file), dest)?; + extract_gz(BufReader::new(file), dest, &mut fixups)?; } [0xfd, b'7', b'z', b'X', b'Z', 0x00] => { // xz magic drop(peek_reader); let file = File::open(tarball)?; - extract_xz(BufReader::new(file), dest)?; + extract_xz(BufReader::new(file), dest, &mut fixups)?; } [0x28, 0xb5, 0x2f, 0xfd, ..] => { // zstd magic drop(peek_reader); let file = File::open(tarball)?; - extract_zst(BufReader::new(file), dest)?; + extract_zst(BufReader::new(file), dest, &mut fixups)?; } _ => { // Assume uncompressed tar drop(peek_reader); let file = File::open(tarball)?; - extract_tar(BufReader::new(file), dest)?; + extract_tar(BufReader::new(file), dest, &mut fixups)?; } } } - Ok(()) + apply_fixups(dest, &mut fixups) } /// Extract a multi-layer OCI image @@ -121,6 +123,11 @@ fn extract_multi_layer_oci(tarball: &Path, dest: &Path) -> Result<()> { let manifest = std::fs::read_to_string(&manifest_path).context("Failed to read layers.manifest")?; + // One fixup set across all layers: later layers routinely write into + // directories the base layer ships read-only, and hard-link targets + // routinely live in an earlier layer. + let mut fixups = DeferredFixups::default(); + // Apply each layer in order with full whiteout handling. for layer_name in manifest.lines() { let layer_path = temp_dir.path().join(layer_name); @@ -128,16 +135,16 @@ fn extract_multi_layer_oci(tarball: &Path, dest: &Path) -> Result<()> { continue; } veprintln!("Extracting layer: {}", layer_name); - extract_oci_layer(&layer_path, dest)?; + extract_oci_layer(&layer_path, dest, &mut fixups)?; } - Ok(()) + apply_fixups(dest, &mut fixups) } /// Decompress and extract one OCI layer tarball into `dest`, honouring /// whiteout markers. Compression is inferred from the filename then from /// magic bytes. -fn extract_oci_layer(layer_path: &Path, dest: &Path) -> Result<()> { +fn extract_oci_layer(layer_path: &Path, dest: &Path, fixups: &mut DeferredFixups) -> Result<()> { use std::io::Read; let layer_name = layer_path .file_name() @@ -152,18 +159,21 @@ fn extract_oci_layer(layer_path: &Path, dest: &Path) -> Result<()> { tar::Archive::new(flate2::read::GzDecoder::new(reader)), dest, "Extracting OCI layer", + fixups, ) } else if layer_name.ends_with(".tar.xz") || layer_name.ends_with(".txz") { extract_with_progress( tar::Archive::new(xz2::read::XzDecoder::new(reader)), dest, "Extracting OCI layer", + fixups, ) } else if layer_name.ends_with(".tar.zst") || layer_name.ends_with(".tar.zstd") { extract_with_progress( tar::Archive::new(zstd::stream::read::Decoder::new(reader)?), dest, "Extracting OCI layer", + fixups, ) } else { // Fall back to magic-byte detection @@ -178,6 +188,7 @@ fn extract_oci_layer(layer_path: &Path, dest: &Path) -> Result<()> { )?))), dest, "Extracting OCI layer", + fixups, ), [0xfd, b'7', b'z', b'X', b'Z', 0x00] => extract_with_progress( tar::Archive::new(xz2::read::XzDecoder::new(BufReader::new(File::open( @@ -185,6 +196,7 @@ fn extract_oci_layer(layer_path: &Path, dest: &Path) -> Result<()> { )?))), dest, "Extracting OCI layer", + fixups, ), [0x28, 0xb5, 0x2f, 0xfd, ..] => extract_with_progress( tar::Archive::new(zstd::stream::read::Decoder::new(BufReader::new( @@ -192,56 +204,152 @@ fn extract_oci_layer(layer_path: &Path, dest: &Path) -> Result<()> { ))?), dest, "Extracting OCI layer", + fixups, ), _ => extract_with_progress( tar::Archive::new(BufReader::new(File::open(layer_path)?)), dest, "Extracting OCI layer", + fixups, ), } } } -fn extract_gz(reader: R, dest: &Path) -> Result<()> { +fn extract_gz(reader: R, dest: &Path, fixups: &mut DeferredFixups) -> Result<()> { let gz_decoder = flate2::read::GzDecoder::new(reader); let archive = tar::Archive::new(gz_decoder); - extract_with_progress(archive, dest, "Extracting gzip archive")?; + extract_with_progress(archive, dest, "Extracting gzip archive", fixups)?; Ok(()) } -fn extract_xz(reader: R, dest: &Path) -> Result<()> { +fn extract_xz(reader: R, dest: &Path, fixups: &mut DeferredFixups) -> Result<()> { let xz_decoder = xz2::read::XzDecoder::new(reader); let archive = tar::Archive::new(xz_decoder); - extract_with_progress(archive, dest, "Extracting xz archive")?; + extract_with_progress(archive, dest, "Extracting xz archive", fixups)?; Ok(()) } -fn extract_zst(reader: R, dest: &Path) -> Result<()> { +fn extract_zst( + reader: R, + dest: &Path, + fixups: &mut DeferredFixups, +) -> Result<()> { let zst_decoder = zstd::Decoder::new(reader)?; let archive = tar::Archive::new(zst_decoder); - extract_with_progress(archive, dest, "Extracting zstd archive")?; + extract_with_progress(archive, dest, "Extracting zstd archive", fixups)?; Ok(()) } -fn extract_tar(reader: R, dest: &Path) -> Result<()> { +fn extract_tar( + reader: R, + dest: &Path, + fixups: &mut DeferredFixups, +) -> Result<()> { let archive = tar::Archive::new(reader); - extract_with_progress(archive, dest, "Extracting tar archive")?; + extract_with_progress(archive, dest, "Extracting tar archive", fixups)?; Ok(()) } +/// Archive entries that must not be materialized inline because the tar +/// order does not guarantee their prerequisites exist yet. +#[derive(Default)] +struct DeferredFixups { + /// Directory entries with the mode they ship; applied last. Images + /// routinely ship read-only directories (Fedora's ca-trust tree is + /// dr-xr-xr-x) whose contents would be rejected by an eagerly-applied + /// mode. + dir_modes: Vec<(PathBuf, u32)>, + /// Hard link entries as (destination, raw link target); applied after + /// the file pass so targets appearing later in the tar (or in an + /// earlier layer) resolve. + hard_links: Vec<(PathBuf, PathBuf)>, +} + +/// Join an archive-relative entry path onto `dest`, refusing paths that +/// traverse above the root (mirrors tar's own unpack component walk). +fn safe_join(dest: &Path, rel: &Path) -> Option { + let mut out = dest.to_path_buf(); + for part in rel.components() { + match part { + Component::Prefix(_) | Component::RootDir | Component::CurDir => {} + Component::ParentDir => return None, + Component::Normal(p) => out.push(p), + } + } + Some(out) +} + +/// Resolve a raw link target against `dest`, normalizing `..` lexically. +/// None when the target climbs out of the rootfs. +fn normalize_within(dest: &Path, target: &Path) -> Option { + let mut out = dest.to_path_buf(); + for part in target.components() { + match part { + Component::Prefix(_) | Component::RootDir | Component::CurDir => {} + Component::ParentDir => { + if out == dest { + return None; + } + out.pop(); + } + Component::Normal(p) => out.push(p), + } + } + Some(out) +} + +/// Apply hard links and directory modes collected during extraction. +fn apply_fixups(dest: &Path, fixups: &mut DeferredFixups) -> Result<()> { + for (dst, target) in fixups.hard_links.drain(..) { + apply_hard_link(dest, &dst, &target)?; + } + for (path, mode) in fixups.dir_modes.drain(..) { + // Errors are ignored: a later layer's whiteout may have removed the + // directory, and the mode is cosmetic for our chroot use. + let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)); + } + Ok(()) +} + +/// Materialize one hard link. A real hard link is used when the target +/// exists on disk; otherwise the entry is preserved verbatim as a symlink — +/// some images (Fedora's ca-trust directory-hash) ship links whose targets +/// are generated after linking and never exist anywhere in the archive. +fn apply_hard_link(dest: &Path, dst: &Path, target: &Path) -> Result<()> { + if dst.symlink_metadata().is_ok() { + std::fs::remove_file(dst) + .with_context(|| format!("Failed to replace {}", dst.display()))?; + } + if let Some(src) = normalize_within(dest, target) { + if std::fs::hard_link(&src, dst).is_ok() { + return Ok(()); + } + } + std::os::unix::fs::symlink(target, dst) + .with_context(|| format!("Failed to link {} -> {}", dst.display(), target.display()))?; + veprintln!( + "Warning: hard link target {} absent; extracted {} as a symlink", + target.display(), + dst.display() + ); + Ok(()) +} + /// Extract tar archive with progress bar, handling whiteout files for OCI layers fn extract_with_progress( mut archive: tar::Archive, dest: &Path, msg: &str, + fixups: &mut DeferredFixups, ) -> Result<()> { let pb = ProgressBar::new_spinner(); pb.set_style( @@ -296,6 +404,27 @@ fn extract_with_progress( .with_context(|| format!("Whiteout: failed to remove {}", target.display()))?; } // Do not extract the .wh.* marker itself + } else if entry.header().entry_type().is_dir() { + // Create the directory but defer its mode: the mode in the tar + // may be read-only, and this directory's own contents (and other + // layers') still need to be written into it. + if let Some(dest_dir) = safe_join(dest, &path) { + std::fs::create_dir_all(&dest_dir) + .with_context(|| format!("Failed to create {}", dest_dir.display()))?; + if let Ok(mode) = entry.header().mode() { + fixups.dir_modes.push((dest_dir, mode)); + } + } + } else if entry.header().entry_type().is_hard_link() { + // Defer: the target may be a later entry in this tar, live in an + // earlier layer, or not exist at all (see apply_hard_link). + if let Some(dest_path) = safe_join(dest, &path) { + if let Some(link) = entry.link_name().context("Invalid hard link target")? { + if !link.as_os_str().is_empty() { + fixups.hard_links.push((dest_path, link.into_owned())); + } + } + } } else { entry .unpack_in(dest) @@ -322,3 +451,184 @@ fn remove_path(path: &Path) -> std::io::Result<()> { std::fs::remove_file(path) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + use tar::{Builder, EntryType, Header}; + + /// Build a plain tar archive from (path, entry-type, mode, payload) + /// tuples in the given order; link payloads are the raw link targets. + fn build_tar(path: &Path, entries: &[(&str, EntryType, u32, &[u8])]) { + let file = File::create(path).unwrap(); + let mut ar = Builder::new(file); + for (name, kind, mode, payload) in entries { + let mut header = Header::new_gnu(); + header.set_size(if *kind == EntryType::Regular { + payload.len() as u64 + } else { + 0 + }); + header.set_entry_type(*kind); + header.set_mode(*mode); + if *kind == EntryType::Symlink || *kind == EntryType::Link { + header + .set_link_name(std::path::PathBuf::from( + std::str::from_utf8(payload).unwrap(), + )) + .unwrap(); + } + header.set_cksum(); + if *kind == EntryType::Regular { + ar.append_data(&mut header, name, *payload).unwrap(); + } else { + ar.append_data(&mut header, name, std::io::empty()).unwrap(); + } + } + ar.finish().unwrap(); + } + + /// Fedora's ca-certificates ships etc/pki/ca-trust/extracted/pem/ + /// directory-hash/ as a read-only (0555) directory full of per-cert hash + /// symlinks (002c0b4f.0 -> GlobalSign_Root_R46.pem) whose targets come + /// later in the tar or not at all, plus hard links elsewhere in the + /// image. Extracting must never open or follow a link payload, and must + /// not choke on the read-only directory. + #[test] + fn fedora_directory_hash_links_extract_without_following() { + let src = tempfile::tempdir().unwrap(); + let out = tempfile::tempdir().unwrap(); + let tarball = src.path().join("rootfs.tar"); + build_tar( + &tarball, + &[ + ( + "etc/pki/ca-trust/extracted/pem/directory-hash/", + EntryType::Directory, + 0o555, + b"", + ), + // symlink whose target appears later in the archive + ( + "etc/pki/ca-trust/extracted/pem/directory-hash/002c0b4f.0", + EntryType::Symlink, + 0o777, + b"GlobalSign_Root_R46.pem", + ), + // symlink whose target never appears (dangling) + ( + "etc/pki/ca-trust/extracted/pem/directory-hash/01419da9.0", + EntryType::Symlink, + 0o777, + b"Missing_Root.pem", + ), + // the real cert file, written after the read-only dir exists + ( + "etc/pki/ca-trust/extracted/pem/directory-hash/GlobalSign_Root_R46.pem", + EntryType::Regular, + 0o644, + b"-----BEGIN CERTIFICATE-----", + ), + ], + ); + + extract_tarball(&tarball, out.path()).unwrap(); + + let base = out + .path() + .join("etc/pki/ca-trust/extracted/pem/directory-hash"); + assert_eq!( + std::fs::read_link(base.join("002c0b4f.0")).unwrap(), + Path::new("GlobalSign_Root_R46.pem") + ); + assert_eq!( + std::fs::read_link(base.join("01419da9.0")).unwrap(), + Path::new("Missing_Root.pem"), + "dangling symlinks must be created verbatim, not followed" + ); + assert_eq!( + std::fs::read(base.join("GlobalSign_Root_R46.pem")).unwrap(), + b"-----BEGIN CERTIFICATE-----" + ); + let dir_mode = base.symlink_metadata().unwrap().permissions().mode() & 0o7777; + assert_eq!(dir_mode, 0o555, "shipped directory mode must be restored"); + } + + #[test] + fn hard_link_to_later_target_extracts() { + let src = tempfile::tempdir().unwrap(); + let out = tempfile::tempdir().unwrap(); + let tarball = src.path().join("rootfs.tar"); + build_tar( + &tarball, + &[ + ("etc/dir/", EntryType::Directory, 0o755, b""), + // hard link recorded before its target + ( + "etc/dir/link.0", + EntryType::Link, + 0o644, + b"etc/dir/real.pem", + ), + ("etc/dir/real.pem", EntryType::Regular, 0o644, b"payload"), + ], + ); + + extract_tarball(&tarball, out.path()).unwrap(); + assert_eq!( + std::fs::read(out.path().join("etc/dir/link.0")).unwrap(), + b"payload" + ); + let link = out + .path() + .join("etc/dir/link.0") + .symlink_metadata() + .unwrap(); + assert!( + !link.file_type().is_symlink(), + "target exists later in the tar, so a real hard link is expected" + ); + let link_ino = std::fs::metadata(out.path().join("etc/dir/link.0")) + .unwrap() + .ino(); + let real_ino = std::fs::metadata(out.path().join("etc/dir/real.pem")) + .unwrap() + .ino(); + assert_eq!(link_ino, real_ino); + } + + #[test] + fn dangling_hard_link_does_not_abort_extraction() { + let src = tempfile::tempdir().unwrap(); + let out = tempfile::tempdir().unwrap(); + let tarball = src.path().join("rootfs.tar"); + build_tar( + &tarball, + &[ + ("etc/dir/", EntryType::Directory, 0o755, b""), + // target is not in the archive at all + ( + "etc/dir/dangling.0", + EntryType::Link, + 0o644, + b"etc/dir/absent.pem", + ), + ("etc/dir/keep.txt", EntryType::Regular, 0o644, b"kept"), + ], + ); + + extract_tarball(&tarball, out.path()).unwrap(); + assert_eq!( + std::fs::read(out.path().join("etc/dir/keep.txt")).unwrap(), + b"kept", + "extraction must not abort on an unresolvable hard link" + ); + let meta = out.path().join("etc/dir/dangling.0").symlink_metadata(); + assert!(meta.map(|m| m.file_type().is_symlink()).unwrap_or(false)); + assert_eq!( + std::fs::read_link(out.path().join("etc/dir/dangling.0")).unwrap(), + Path::new("etc/dir/absent.pem") + ); + } +}