//! Unpacks OCI image layers into a rootfs directory. //! //! Layers come from untrusted registries, so every rule here is about never writing outside the //! rootfs. Paths are normalized to plain components first. Each parent component is then walked //! one level at a time with `symlink_metadata`, and an entry is rejected when a parent is a //! symlink or any other non-directory. Nothing is ever written through a link. Files are created //! with `create_new`, after the old leaf has been removed, so an existing symlink is replaced //! rather than followed. //! //! Ownership depends on [`Ownership`]. `Flatten` gives every file to the unpacking user, which is //! what the single-uid user namespace expects. `Preserve` gives every entry the owner from its tar //! header, with ids mapped into the user namespace. Either way setuid and setgid bits are stripped. //! Extended attributes are never written, so file capabilities from the image do not apply. Device //! nodes and fifos are skipped. Directories are kept owner-writable (`| 0o700`) while layers are //! applied. `Preserve` sets their exact modes at the end, deepest first. use std::collections::{HashMap, HashSet}; use std::ffi::{OsStr, OsString}; use std::fs::{self, File, OpenOptions, Permissions}; use std::io::{BufReader, Read, Seek, SeekFrom}; use std::os::unix::ffi::OsStrExt; use std::os::unix::fs::{PermissionsExt, lchown, symlink}; use std::path::{Path, PathBuf}; use flate2::read::GzDecoder; use serde::{Deserialize, Serialize}; use tar::{Archive, EntryType}; const GZIP_MAGIC: &[u8] = &[0x1f, 0x8b]; const ZSTD_MAGIC: &[u8] = &[0x28, 0xb5, 0x2f, 0xfd]; const WHITEOUT_PREFIX: &[u8] = b".wh."; /// The opaque marker, after the `.wh.` prefix has been stripped. const OPAQUE_MARKER: &[u8] = b".wh..opq"; const COPY_BUF: usize = 64 * 1024; /// Errors from unpacking. Entries that fail the path rules are not errors: they are counted in /// `UnpackStats::rejected` and skipped. #[derive(Debug, thiserror::Error)] pub enum UnpackError { #[error("unpack I/O error: {0}")] Io(#[from] std::io::Error), #[error("unpacked size exceeds the {limit} byte limit")] TooLarge { limit: u64 }, /// The `__sandbox-unpack` helper failed. The message is its stderr, or a parse error. #[error("unpack helper failed: {0}")] Helper(String), } /// What unpacking did, for logging. #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct UnpackStats { pub files: u64, pub dirs: u64, pub symlinks: u64, pub hardlinks: u64, pub whiteouts: u64, /// Device nodes, fifos and entry types that are not supported. pub skipped: u64, /// Entries whose path or link target failed the containment rules. pub rejected: u64, /// Total bytes of regular file content written. pub bytes: u64, /// Owner ids (uid or gid) at or above the map size, which became 0. Only set by `Preserve`. pub remapped_ids: u64, } /// How entries get their owner. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Ownership { /// Every entry belongs to the unpacking user. This is the single-uid behaviour. Flatten, /// Every entry gets its tar header owner, with ids mapped into a user namespace of `size` ids. /// Ids `>= size` become 0 and are counted in `UnpackStats::remapped_ids`. Preserve { size: u32 }, } /// Maps a tar owner id into a user namespace of `size` ids. Ids below `size` are kept, and the /// rest become 0. The flag is true when the id was changed. pub fn map_id(id: u64, size: u32) -> (u32, bool) { match u32::try_from(id) { Ok(id) if id < size => (id, false), _ => (0, true), } } /// Unpacks `layers` in order into `rootfs`, which must already exist and be empty. Each layer may /// be a plain, gzip or zstd tar; the compression is detected from the first bytes. `max_bytes` caps /// the total regular file content across all layers. `ownership` decides who owns the entries. pub fn unpack_layers( rootfs: &Path, layers: &[PathBuf], max_bytes: u64, ownership: Ownership, ) -> Result { let mut unpacker = Unpacker { root: rootfs.to_path_buf(), limit: max_bytes, remaining: max_bytes, created: HashSet::new(), ownership, owner: None, dir_modes: HashMap::new(), stats: UnpackStats::default(), }; for layer in layers { unpacker.created.clear(); unpacker.unpack_layer(layer)?; } unpacker.finish_dir_modes()?; unpacker.stats.bytes = max_bytes - unpacker.remaining; Ok(unpacker.stats) } struct Unpacker { root: PathBuf, limit: u64, remaining: u64, /// Relative paths written by the current layer. Opaque markers keep these and drop the rest. created: HashSet, ownership: Ownership, /// The owner of the entry being applied, with ids mapped. None when flattening. owner: Option<(u32, u32)>, /// Exact directory modes for `Preserve`, keyed by path. Set by `finish_dir_modes`. dir_modes: HashMap, stats: UnpackStats, } impl Unpacker { fn unpack_layer(&mut self, layer: &Path) -> Result<(), UnpackError> { let mut file = File::open(layer)?; let mut magic = Vec::with_capacity(4); (&mut file).take(4).read_to_end(&mut magic)?; file.seek(SeekFrom::Start(0))?; if magic.starts_with(GZIP_MAGIC) { self.unpack_stream(GzDecoder::new(BufReader::new(file))) } else if magic.starts_with(ZSTD_MAGIC) { let decoder = zstd::stream::read::Decoder::new(BufReader::new(file))?; self.unpack_stream(decoder) } else { self.unpack_stream(BufReader::new(file)) } } fn unpack_stream(&mut self, reader: R) -> Result<(), UnpackError> { let mut archive = Archive::new(reader); for entry in archive.entries()? { let mut entry = entry?; let kind = entry.header().entry_type(); // Keep the permission bits but never setuid, setgid. Sticky bit is kept. let mode = entry.header().mode()? & 0o1777; let size = entry.header().size()?; let raw_path = entry.path_bytes().into_owned(); let raw_link = entry.link_name_bytes().map(|link| link.into_owned()); let Some(comps) = normalize(&raw_path) else { self.stats.rejected += 1; continue; }; if comps.is_empty() { // "." or "./" is the rootfs itself. continue; } self.owner = self.owner_of(entry.header())?; self.apply(&comps, kind, mode, size, raw_link.as_deref(), &mut entry)?; } Ok(()) } /// The owner for an entry, with ids mapped. None when flattening. fn owner_of(&mut self, header: &tar::Header) -> Result, UnpackError> { let Ownership::Preserve { size } = self.ownership else { return Ok(None); }; let (uid, uid_remapped) = map_id(header.uid()?, size); let (gid, gid_remapped) = map_id(header.gid()?, size); self.stats.remapped_ids += u64::from(uid_remapped) + u64::from(gid_remapped); Ok(Some((uid, gid))) } /// Gives `path` the owner of the current entry, without following a symlink. Does nothing when /// flattening. fn own(&self, path: &Path) -> Result<(), UnpackError> { if let Some((uid, gid)) = self.owner { lchown(path, Some(uid), Some(gid))?; } Ok(()) } /// Sets each directory to the exact mode it was last given, deepest first. A path that is no /// longer a directory, because a later layer removed or replaced it, is skipped. fn finish_dir_modes(&mut self) -> Result<(), UnpackError> { let mut dirs: Vec<(PathBuf, u32)> = self.dir_modes.drain().collect(); dirs.sort_by_key(|(path, _)| std::cmp::Reverse(path.components().count())); for (path, mode) in dirs { match fs::symlink_metadata(&path) { Ok(meta) if meta.is_dir() => { fs::set_permissions(&path, Permissions::from_mode(mode))?; } Ok(_) => {} Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} Err(err) => return Err(err.into()), } } Ok(()) } fn apply( &mut self, comps: &[OsString], kind: EntryType, mode: u32, size: u64, link: Option<&[u8]>, data: &mut R, ) -> Result<(), UnpackError> { let Some((leaf, parents)) = comps.split_last() else { return Ok(()); }; let rel: PathBuf = comps.iter().collect(); if let Some(name) = leaf.as_bytes().strip_prefix(WHITEOUT_PREFIX) { return self.whiteout(parents, name); } match kind { EntryType::Directory => { let Some(parent) = self.resolve_dir(parents, true)? else { return self.reject(); }; let path = parent.join(leaf); match fs::symlink_metadata(&path) { Ok(meta) if meta.is_dir() => {} Ok(_) => { fs::remove_file(&path)?; fs::create_dir(&path)?; } Err(err) if err.kind() == std::io::ErrorKind::NotFound => { fs::create_dir(&path)?; } Err(err) => return Err(err.into()), } self.own(&path)?; fs::set_permissions(&path, Permissions::from_mode(mode | 0o700))?; if matches!(self.ownership, Ownership::Preserve { .. }) { self.dir_modes.insert(path, mode); } self.created.insert(rel); self.stats.dirs += 1; } EntryType::Regular | EntryType::Continuous => { if size > self.remaining { return Err(UnpackError::TooLarge { limit: self.limit }); } let Some(parent) = self.resolve_dir(parents, true)? else { return self.reject(); }; let path = parent.join(leaf); remove_path(&path)?; let mut file = OpenOptions::new() .write(true) .create_new(true) .open(&path)?; self.copy_capped(data, &mut file)?; self.own(&path)?; file.set_permissions(Permissions::from_mode(mode))?; self.created.insert(rel); self.stats.files += 1; } EntryType::Symlink => { // The target is stored as is. It is never followed while unpacking. let Some(target) = link.filter(|target| !target.contains(&0)) else { return self.reject(); }; let Some(parent) = self.resolve_dir(parents, true)? else { return self.reject(); }; let path = parent.join(leaf); remove_path(&path)?; symlink(OsStr::from_bytes(target), &path)?; self.own(&path)?; self.created.insert(rel); self.stats.symlinks += 1; } EntryType::Link => { // A hardlink must name a regular file inside the rootfs. let Some(target) = link.and_then(normalize) else { return self.reject(); }; let Some((target_leaf, target_parents)) = target.split_last() else { return self.reject(); }; let Some(target_dir) = self.resolve_dir(target_parents, false)? else { return self.reject(); }; let source = target_dir.join(target_leaf); let is_file = matches!(fs::symlink_metadata(&source), Ok(meta) if meta.is_file()); if !is_file { return self.reject(); } let Some(parent) = self.resolve_dir(parents, true)? else { return self.reject(); }; let path = parent.join(leaf); if path == source { return self.reject(); } remove_path(&path)?; fs::hard_link(&source, &path)?; // A hardlink shares its inode, so this also sets the owner of the source. self.own(&path)?; self.created.insert(rel); self.stats.hardlinks += 1; } // Devices, fifos, sparse files and anything else. Skipped and counted. _ => self.stats.skipped += 1, } Ok(()) } /// Handles `.wh.` (delete ``) and `.wh..wh..opq` (clear the directory's contents /// from earlier layers). fn whiteout(&mut self, parents: &[OsString], name: &[u8]) -> Result<(), UnpackError> { // A whiteout under a missing or linked parent has nothing to delete. let Some(dir) = self.resolve_dir(parents, false)? else { return Ok(()); }; if name == OPAQUE_MARKER { let rel_dir: PathBuf = parents.iter().collect(); self.clear_dir(&dir, &rel_dir)?; } else { if name.is_empty() || name == b"." || name == b".." || name.contains(&0) { return self.reject(); } remove_path(&dir.join(OsStr::from_bytes(name)))?; } self.stats.whiteouts += 1; Ok(()) } /// Removes everything in `dir` that this layer did not write. fn clear_dir(&mut self, dir: &Path, rel_dir: &Path) -> Result<(), UnpackError> { for child in fs::read_dir(dir)? { let child = child?; let child_rel = rel_dir.join(child.file_name()); if self.created.contains(&child_rel) { continue; } remove_path(&child.path())?; } Ok(()) } /// Walks `parents` from the root, one component at a time. Missing directories are created /// when `create` is set, and are otherwise treated as absent. Returns `None` if any component /// is a symlink or any other non-directory, or is missing with `create` unset. fn resolve_dir( &mut self, parents: &[OsString], create: bool, ) -> Result, UnpackError> { let mut cur = self.root.clone(); let mut rel = PathBuf::new(); for comp in parents { cur.push(comp); rel.push(comp); match fs::symlink_metadata(&cur) { Ok(meta) if meta.is_dir() => {} Ok(_) => return Ok(None), Err(err) if err.kind() == std::io::ErrorKind::NotFound => { if !create { return Ok(None); } fs::create_dir(&cur)?; fs::set_permissions(&cur, Permissions::from_mode(0o755))?; self.created.insert(rel.clone()); } Err(err) => return Err(err.into()), } } Ok(Some(cur)) } /// Copies `src` to `dst`, failing as soon as the budget would be exceeded. fn copy_capped(&mut self, src: &mut R, dst: &mut File) -> Result<(), UnpackError> { use std::io::Write; let mut buf = vec![0u8; COPY_BUF]; loop { let n = src.read(&mut buf)?; if n == 0 { return Ok(()); } let n = n as u64; if n > self.remaining { return Err(UnpackError::TooLarge { limit: self.limit }); } self.remaining -= n; dst.write_all(&buf[..n as usize])?; } } fn reject(&mut self) -> Result<(), UnpackError> { self.stats.rejected += 1; Ok(()) } } /// Turns an archive path into plain components. Returns `None` for absolute paths, paths with /// `..`, and paths with NUL bytes. `.` components are dropped, so `./a` and `a` are the same. fn normalize(raw: &[u8]) -> Option> { if raw.first() == Some(&b'/') || raw.contains(&0) { return None; } let mut out = Vec::new(); for comp in Path::new(OsStr::from_bytes(raw)).components() { match comp { std::path::Component::Normal(name) => out.push(name.to_owned()), std::path::Component::CurDir => {} _ => return None, } } Some(out) } /// Removes whatever is at `path` without following a symlink. Missing paths are fine. pub(super) fn remove_path(path: &Path) -> std::io::Result<()> { match fs::symlink_metadata(path) { Ok(meta) if meta.is_dir() => fs::remove_dir_all(path), Ok(_) => fs::remove_file(path), Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(err) => Err(err), } } #[cfg(test)] mod tests { use super::*; use std::io::Write; use std::os::unix::fs::MetadataExt; use tar::{Builder, Header}; fn write_field(field: &mut [u8], value: &str) { field[..value.len()].copy_from_slice(value.as_bytes()); } /// Appends one entry with a raw name, so tests can write names that `tar` would refuse. fn put( builder: &mut Builder>, kind: EntryType, name: &str, link: Option<&str>, mode: u32, data: &[u8], ) { let mut header = Header::new_old(); header.set_entry_type(kind); header.set_mode(mode); header.set_size(data.len() as u64); write_field(&mut header.as_old_mut().name, name); if let Some(link) = link { write_field(&mut header.as_old_mut().linkname, link); } header.set_cksum(); builder.append(&header, data).expect("append"); } fn file(builder: &mut Builder>, name: &str, data: &[u8]) { put(builder, EntryType::Regular, name, None, 0o644, data); } fn dir(builder: &mut Builder>, name: &str) { put(builder, EntryType::Directory, name, None, 0o755, b""); } fn tar_of(build: impl FnOnce(&mut Builder>)) -> Vec { let mut builder = Builder::new(Vec::new()); build(&mut builder); builder.into_inner().expect("finish tar") } fn gzip(bytes: &[u8]) -> Vec { let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); encoder.write_all(bytes).expect("gzip"); encoder.finish().expect("gzip finish") } fn zstd_of(bytes: &[u8]) -> Vec { zstd::encode_all(bytes, 0).expect("zstd") } /// A temp dir with an empty `rootfs` and an `outside` dir that escapes must never reach. struct Case { dir: tempfile::TempDir, rootfs: PathBuf, outside: PathBuf, } fn case() -> Case { let dir = tempfile::tempdir().expect("tempdir"); let rootfs = dir.path().join("rootfs"); let outside = dir.path().join("outside"); fs::create_dir(&rootfs).expect("rootfs"); fs::create_dir(&outside).expect("outside"); Case { dir, rootfs, outside, } } fn run(case: &Case, layers: &[Vec]) -> Result { let paths: Vec = layers .iter() .enumerate() .map(|(i, bytes)| { let path = case.dir.path().join(format!("layer{i}.tar")); fs::write(&path, bytes).expect("write layer"); path }) .collect(); unpack_layers(&case.rootfs, &paths, 1 << 30, Ownership::Flatten) } fn mode_of(path: &Path) -> u32 { fs::symlink_metadata(path) .expect("metadata") .permissions() .mode() & 0o7777 } #[test] fn normalize_accepts_relative_and_rejects_escapes() { assert_eq!( normalize(b"./etc/x"), Some(vec![OsString::from("etc"), OsString::from("x")]) ); assert_eq!( normalize(b"a/./b"), Some(vec![OsString::from("a"), OsString::from("b")]) ); assert!(normalize(b"../x").is_none()); assert!(normalize(b"a/../../x").is_none()); assert!(normalize(b"/etc/passwd").is_none()); assert!(normalize(b"a\0b").is_none()); } #[test] fn plain_gzip_and_zstd_layers_unpack() { let bytes = tar_of(|b| { dir(b, "etc/"); file(b, "etc/motd", b"hello\n"); }); for layer in [bytes.clone(), gzip(&bytes), zstd_of(&bytes)] { let c = case(); let stats = run(&c, &[layer]).expect("unpack"); assert_eq!( fs::read(c.rootfs.join("etc/motd")).expect("read"), b"hello\n" ); assert_eq!(stats.files, 1); assert_eq!(stats.bytes, 6); } } #[test] fn leading_dot_slash_names_are_accepted() { let c = case(); let layer = tar_of(|b| file(b, "./bin/tool", b"x")); run(&c, &[layer]).expect("unpack"); assert!(c.rootfs.join("bin/tool").is_file()); } #[test] fn traversal_and_absolute_names_are_rejected() { let c = case(); let layer = tar_of(|b| { file(b, "../escape.txt", b"nope"); file(b, "a/../../escape2.txt", b"nope"); file(b, "/abs-escape.txt", b"nope"); file(b, "ok.txt", b"yes"); }); let stats = run(&c, &[layer]).expect("unpack"); assert_eq!(stats.rejected, 3); assert!(!c.dir.path().join("escape.txt").exists()); assert!(!c.dir.path().join("escape2.txt").exists()); assert!(c.rootfs.join("ok.txt").is_file()); } #[test] fn symlink_parent_is_never_followed() { let c = case(); let target = c.outside.to_str().expect("utf8").to_owned(); let first = tar_of(|b| put(b, EntryType::Symlink, "a", Some(&target), 0o777, b"")); let second = tar_of(|b| file(b, "a/passwd", b"owned")); let stats = run(&c, &[first, second]).expect("unpack"); assert!(!c.outside.join("passwd").exists()); assert_eq!(stats.rejected, 1); // The link itself is still there, and is not replaced by a directory. assert!( fs::symlink_metadata(c.rootfs.join("a")) .expect("meta") .file_type() .is_symlink() ); } #[test] fn symlink_leaf_is_replaced_not_followed() { let c = case(); let target = c.outside.join("victim"); fs::write(&target, "original").expect("write victim"); let first = tar_of(|b| { put( b, EntryType::Symlink, "f", Some(target.to_str().expect("utf8")), 0o777, b"", ) }); let second = tar_of(|b| file(b, "f", b"new")); run(&c, &[first, second]).expect("unpack"); assert_eq!(fs::read_to_string(&target).expect("read"), "original"); assert_eq!(fs::read_to_string(c.rootfs.join("f")).expect("read"), "new"); } #[test] fn hardlink_inside_rootfs_works() { let c = case(); let layer = tar_of(|b| { file(b, "a", b"shared"); put(b, EntryType::Link, "b", Some("a"), 0o644, b""); }); let stats = run(&c, &[layer]).expect("unpack"); assert_eq!(stats.hardlinks, 1); assert_eq!(fs::read(c.rootfs.join("b")).expect("read"), b"shared"); } #[test] fn hardlink_escape_is_rejected() { let c = case(); let secret = c.outside.join("secret"); fs::write(&secret, "secret").expect("write"); let outside_abs = secret.to_str().expect("utf8").to_owned(); let layer = tar_of(|b| { put(b, EntryType::Link, "abs", Some(&outside_abs), 0o644, b""); put( b, EntryType::Link, "rel", Some("../outside/secret"), 0o644, b"", ); put( b, EntryType::Link, "missing", Some("nothing-here"), 0o644, b"", ); }); let stats = run(&c, &[layer]).expect("unpack"); assert_eq!(stats.rejected, 3); assert!(!c.rootfs.join("abs").exists()); assert!(!c.rootfs.join("rel").exists()); } #[test] fn hardlink_to_symlink_is_rejected() { let c = case(); let layer = tar_of(|b| { put( b, EntryType::Symlink, "link", Some("/etc/passwd"), 0o777, b"", ); put(b, EntryType::Link, "copy", Some("link"), 0o644, b""); }); let stats = run(&c, &[layer]).expect("unpack"); assert_eq!(stats.rejected, 1); assert!(!c.rootfs.join("copy").exists()); } #[test] fn whiteout_deletes_earlier_file() { let c = case(); let first = tar_of(|b| { dir(b, "etc/"); file(b, "etc/old", b"1"); file(b, "etc/keep", b"2"); }); let second = tar_of(|b| file(b, "etc/.wh.old", b"")); let stats = run(&c, &[first, second]).expect("unpack"); assert!(!c.rootfs.join("etc/old").exists()); assert!(c.rootfs.join("etc/keep").is_file()); assert_eq!(stats.whiteouts, 1); // The whiteout marker itself is never written. assert!(!c.rootfs.join("etc/.wh.old").exists()); } #[test] fn whiteout_deletes_directory_tree() { let c = case(); let first = tar_of(|b| { dir(b, "opt/"); dir(b, "opt/app/"); file(b, "opt/app/bin", b"x"); }); let second = tar_of(|b| file(b, "opt/.wh.app", b"")); run(&c, &[first, second]).expect("unpack"); assert!(!c.rootfs.join("opt/app").exists()); } #[test] fn opaque_dir_clears_lower_layers_and_keeps_this_layer() { let c = case(); let first = tar_of(|b| { dir(b, "etc/"); file(b, "etc/old1", b"1"); dir(b, "etc/sub/"); file(b, "etc/sub/old2", b"2"); file(b, "keep-outside-etc", b"3"); }); let second = tar_of(|b| { file(b, "etc/.wh..wh..opq", b""); file(b, "etc/new", b"4"); }); run(&c, &[first, second]).expect("unpack"); assert!(!c.rootfs.join("etc/old1").exists()); assert!(!c.rootfs.join("etc/sub").exists()); assert!(c.rootfs.join("etc/new").is_file()); assert!(c.rootfs.join("keep-outside-etc").is_file()); } #[test] fn setuid_and_setgid_are_stripped() { let c = case(); let layer = tar_of(|b| { put(b, EntryType::Regular, "su", None, 0o4755, b"x"); put(b, EntryType::Regular, "sg", None, 0o2750, b"x"); put(b, EntryType::Directory, "d/", None, 0o6777, b""); }); run(&c, &[layer]).expect("unpack"); assert_eq!(mode_of(&c.rootfs.join("su")), 0o755); assert_eq!(mode_of(&c.rootfs.join("sg")), 0o750); assert_eq!(mode_of(&c.rootfs.join("d")) & 0o6000, 0); } #[test] fn devices_and_fifos_are_skipped() { let c = case(); let layer = tar_of(|b| { put(b, EntryType::Char, "dev/null", None, 0o666, b""); put(b, EntryType::Block, "dev/sda", None, 0o660, b""); put(b, EntryType::Fifo, "dev/pipe", None, 0o644, b""); file(b, "dev/ok", b"x"); }); let stats = run(&c, &[layer]).expect("unpack"); assert_eq!(stats.skipped, 3); assert!(!c.rootfs.join("dev/null").exists()); assert!(!c.rootfs.join("dev/pipe").exists()); assert!(c.rootfs.join("dev/ok").is_file()); } #[test] fn size_cap_stops_unpacking() { let c = case(); let layer = tar_of(|b| { file(b, "a", &[1u8; 100]); file(b, "b", &[2u8; 100]); }); let paths = { let path = c.dir.path().join("big.tar"); fs::write(&path, &layer).expect("write"); vec![path] }; let err = unpack_layers(&c.rootfs, &paths, 150, Ownership::Flatten).expect_err("over the cap"); assert!(matches!(err, UnpackError::TooLarge { limit: 150 })); } #[test] fn map_id_keeps_ids_inside_the_map_and_zeroes_the_rest() { assert_eq!(map_id(0, 65), (0, false)); assert_eq!(map_id(1, 65), (1, false)); assert_eq!(map_id(64, 65), (64, false)); assert_eq!(map_id(65, 65), (0, true)); assert_eq!(map_id(1000, 1), (0, true)); assert_eq!(map_id(u64::MAX, u32::MAX), (0, true)); assert_eq!(map_id(u64::from(u32::MAX), u32::MAX), (0, true)); assert_eq!( map_id(u64::from(u32::MAX) - 1, u32::MAX), (u32::MAX - 1, false) ); } /// Writes one tar entry with an explicit owner and mode, and no data. fn put_owned( builder: &mut Builder>, kind: EntryType, name: &str, link: Option<&str>, mode: u32, owner: (u64, u64), ) { let mut header = Header::new_old(); header.set_entry_type(kind); header.set_mode(mode); header.set_size(0); header.set_uid(owner.0); header.set_gid(owner.1); write_field(&mut header.as_old_mut().name, name); if let Some(link) = link { write_field(&mut header.as_old_mut().linkname, link); } header.set_cksum(); builder.append(&header, &b""[..]).expect("append"); } #[test] fn preserve_sets_owner_and_exact_directory_modes() { // Owned by the test user, so the test can chown to it without privileges. let c = case(); let probe = c.dir.path().join("probe"); fs::write(&probe, b"").expect("probe"); let meta = fs::metadata(&probe).expect("probe metadata"); let owner = (u64::from(meta.uid()), u64::from(meta.gid())); let layer = tar_of(|b| { put_owned(b, EntryType::Directory, "d/", None, 0o710, owner); put_owned(b, EntryType::Directory, "d/sub/", None, 0o751, owner); put_owned(b, EntryType::Regular, "d/sub/f", None, 0o640, owner); put_owned(b, EntryType::Symlink, "d/link", Some("sub/f"), 0o777, owner); }); let paths = { let path = c.dir.path().join("preserve.tar"); fs::write(&path, &layer).expect("write"); vec![path] }; let stats = unpack_layers( &c.rootfs, &paths, 1 << 30, Ownership::Preserve { size: u32::MAX }, ) .expect("unpack"); assert_eq!(stats.remapped_ids, 0); assert_eq!(stats.dirs, 2); assert_eq!(mode_of(&c.rootfs.join("d")), 0o710); assert_eq!(mode_of(&c.rootfs.join("d/sub")), 0o751); assert_eq!(mode_of(&c.rootfs.join("d/sub/f")), 0o640); let file_meta = fs::symlink_metadata(c.rootfs.join("d/sub/f")).expect("file meta"); assert_eq!((file_meta.uid(), file_meta.gid()), (meta.uid(), meta.gid())); assert!( fs::symlink_metadata(c.rootfs.join("d/link")) .expect("link") .file_type() .is_symlink() ); } #[test] fn flatten_keeps_directories_owner_writable() { let c = case(); let layer = tar_of(|b| { put(b, EntryType::Directory, "d/", None, 0o550, b""); }); run(&c, &[layer]).expect("unpack"); assert_eq!(mode_of(&c.rootfs.join("d")), 0o750); } #[test] fn later_layer_replaces_file_with_directory() { let c = case(); let first = tar_of(|b| file(b, "x", b"file")); let second = tar_of(|b| { dir(b, "x/"); file(b, "x/inner", b"inner"); }); run(&c, &[first, second]).expect("unpack"); assert_eq!(fs::read(c.rootfs.join("x/inner")).expect("read"), b"inner"); } }