use std::collections::{HashMap, HashSet}; use std::ffi::{OsStr, OsString}; use std::fs::File; use std::io::{self, Read, Seek, SeekFrom, Write}; use std::os::fd::{AsFd, OwnedFd}; use std::os::unix::ffi::OsStrExt; use std::path::{Component, Path, PathBuf}; use anyhow::{Context, Result, bail}; use rustix::fs::{ AtFlags, FileType, Gid, Mode, OFlags, ResolveFlags, Timestamps, Uid, chownat, fchmod, fchown, futimens, linkat, mkdirat, openat2, symlinkat, unlinkat, utimensat, }; use rustix::io::{Errno, dup}; use rustix::time::Timespec; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use tar::{EntryType, Header}; pub const POLICY_NAME: &str = "lazaret-supported-image-v1"; #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct IdMapRange { pub container_id: u32, pub namespace_id: u32, pub size: u32, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct IdMap { pub uid: Vec, pub gid: Vec, } #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] pub enum PreparedDestinationKind { Directory, RegularFile, } #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] pub struct PreparedDestination { pub path: PathBuf, pub kind: PreparedDestinationKind, } #[derive(Clone, Copy, Debug)] pub struct PreparationLimits { pub max_layers: usize, pub max_entries: u64, pub max_hardlinks: usize, pub max_file_bytes: u64, pub max_total_bytes: u64, pub max_path_bytes: usize, pub max_pax_records: usize, pub max_pax_bytes: usize, pub max_json_bytes: u64, pub max_layer_blob_bytes: u64, pub max_uncompressed_layer_bytes: u64, } impl Default for PreparationLimits { fn default() -> Self { Self { max_layers: 256, max_entries: 500_000, max_hardlinks: 250_000, max_file_bytes: 8 * 1024 * 1024 * 1024, max_total_bytes: 128 * 1024 * 1024 * 1024, max_path_bytes: 4096, max_pax_records: 64, max_pax_bytes: 4 * 1024 * 1024, max_json_bytes: 16 * 1024 * 1024, max_layer_blob_bytes: 32 * 1024 * 1024 * 1024, max_uncompressed_layer_bytes: 128 * 1024 * 1024 * 1024, } } } #[derive(Deserialize)] struct LayoutIndex { manifests: Vec, } #[derive(Deserialize)] struct Manifest { config: Descriptor, layers: Vec, } #[derive(Deserialize)] struct Config { rootfs: Rootfs, } #[derive(Deserialize)] struct Rootfs { diff_ids: Vec, } #[derive(Clone, Deserialize)] struct Descriptor { digest: String, size: u64, #[serde(rename = "mediaType", default)] media_type: String, } pub(crate) struct VerifiedLayout { pub(crate) manifest_digest: [u8; 32], layers: Vec, diff_ids: Vec, } pub(crate) enum LayoutSource<'a> { Ambient(&'a Path), Retained(&'a File), } impl LayoutSource<'_> { fn open(&self, relative: &Path) -> Result { match self { Self::Ambient(root) => Ok(File::open(root.join(relative))?), Self::Retained(root) => Ok(File::from(openat2( root, relative, OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC, Mode::empty(), secure_resolve(), )?)), } } } #[derive(Default)] struct Usage { entries: u64, hardlinks: usize, total_bytes: u64, } pub(crate) fn verify_layout( layout: &LayoutSource<'_>, limits: PreparationLimits, ) -> Result { let index: LayoutIndex = read_json(layout, Path::new("index.json"), limits.max_json_bytes)?; if index.manifests.len() != 1 { bail!("selected OCI layout must contain exactly one manifest"); } let descriptor = &index.manifests[0]; let manifest: Manifest = serde_json::from_slice(&verified_blob(layout, descriptor, limits.max_json_bytes)?)?; if manifest.layers.len() > limits.max_layers { bail!("policy {POLICY_NAME} rejects excessive layer count"); } let config: Config = serde_json::from_slice(&verified_blob( layout, &manifest.config, limits.max_json_bytes, )?)?; if manifest.layers.len() != config.rootfs.diff_ids.len() { bail!("OCI config diff-ID count does not match layer count"); } for layer in &manifest.layers { validate_media_type(&layer.media_type)?; verify_blob_digest(layout, layer, limits.max_layer_blob_bytes)?; } Ok(VerifiedLayout { manifest_digest: parse_sha256(&descriptor.digest)?, layers: manifest.layers, diff_ids: config.rootfs.diff_ids, }) } pub(crate) fn apply_layout( layout: &LayoutSource<'_>, rootfs: &File, verified: &VerifiedLayout, id_map: &IdMap, limits: PreparationLimits, ) -> Result<()> { validate_id_map(id_map)?; let mut state = LayerState::default(); let mut usage = Usage::default(); for (index, (descriptor, diff_id)) in verified.layers.iter().zip(&verified.diff_ids).enumerate() { let spool = File::from(openat2( rootfs, ".", OFlags::RDWR | OFlags::TMPFILE | OFlags::CLOEXEC, Mode::from_raw_mode(0o600), secure_resolve(), )?); let observed = decompress_layer( layout.open(&blob_path(&descriptor.digest)?)?, &descriptor.media_type, spool.try_clone()?, limits.max_uncompressed_layer_bytes, )?; if &observed != diff_id { bail!("layer {index} diff-ID mismatch"); } validate_tar(&spool, id_map, limits, &mut usage)?; state.apply(&spool, rootfs, id_map)?; } state.finish(rootfs) } pub(crate) fn materialize_destinations( root: &File, destinations: &[PreparedDestination], id_map: &IdMap, ) -> Result<()> { if destinations.is_empty() { return Ok(()); } let uid = map_owner(0, &id_map.uid, "uid")?; let gid = map_owner(0, &id_map.gid, "gid")?; for destination in destinations { let relative = destination .path .strip_prefix("/") .context("prepared destination is not absolute")?; let relative = normalize_relative(relative)?; let (parent, leaf) = materialize_parent(root, &relative, uid, gid)?; let expected = match destination.kind { PreparedDestinationKind::Directory => FileType::Directory, PreparedDestinationKind::RegularFile => FileType::RegularFile, }; match path_type(&parent, Path::new(&leaf))? { Some(actual) if actual == expected => {} Some(_) => bail!("prepared destination conflicts with the image layout"), None => match destination.kind { PreparedDestinationKind::Directory => { mkdirat(&parent, &leaf, Mode::from_raw_mode(0o755))?; set_materialized_metadata(open_component(&parent, &leaf)?, uid, gid)?; } PreparedDestinationKind::RegularFile => { let file = openat2( &parent, &leaf, OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC | OFlags::NOFOLLOW, Mode::from_raw_mode(0o755), secure_resolve(), )?; set_materialized_metadata(file, uid, gid)?; } }, } } Ok(()) } fn materialize_parent(root: &File, path: &Path, uid: u32, gid: u32) -> Result<(OwnedFd, OsString)> { let leaf = path .file_name() .context("prepared destination has no final component")? .to_os_string(); let mut current = dup(root)?; for component in path.parent().into_iter().flat_map(Path::components) { let Component::Normal(name) = component else { bail!("prepared destination is not normalized"); }; match mkdirat(¤t, name, Mode::from_raw_mode(0o755)) { Ok(()) => set_materialized_metadata(open_component(¤t, name)?, uid, gid)?, Err(Errno::EXIST) => {} Err(error) => return Err(error.into()), } current = open_component(¤t, name)?; } Ok((current, leaf)) } fn set_materialized_metadata(descriptor: impl AsFd, uid: u32, gid: u32) -> Result<()> { fchown( &descriptor, Some(Uid::from_raw(uid)), Some(Gid::from_raw(gid)), )?; fchmod(&descriptor, Mode::from_raw_mode(0o755))?; futimens(&descriptor, ×tamps(0))?; Ok(()) } fn read_json( layout: &LayoutSource<'_>, path: &Path, limit: u64, ) -> Result { let mut file = layout.open(path)?; let metadata = file.metadata()?; if metadata.len() > limit { bail!("OCI JSON exceeds {limit} bytes"); } let mut bytes = Vec::new(); file.read_to_end(&mut bytes)?; Ok(serde_json::from_slice(&bytes)?) } fn verified_blob( layout: &LayoutSource<'_>, descriptor: &Descriptor, limit: u64, ) -> Result> { let mut file = layout.open(&blob_path(&descriptor.digest)?)?; let metadata = file.metadata()?; if metadata.len() != descriptor.size || metadata.len() > limit { bail!("OCI descriptor size or resource limit mismatch"); } let mut bytes = Vec::new(); file.read_to_end(&mut bytes)?; if Sha256::digest(&bytes).as_slice() != parse_sha256(&descriptor.digest)? { bail!("OCI descriptor digest mismatch"); } Ok(bytes) } fn verify_blob_digest( layout: &LayoutSource<'_>, descriptor: &Descriptor, limit: u64, ) -> Result<()> { let file = layout.open(&blob_path(&descriptor.digest)?)?; let metadata = file.metadata()?; if metadata.len() != descriptor.size || metadata.len() > limit { bail!("OCI layer size or resource limit mismatch"); } let observed = digest_reader(file, limit, None)?; if observed != descriptor.digest { bail!("OCI layer digest mismatch"); } Ok(()) } fn blob_path(digest: &str) -> Result { let component = digest .strip_prefix("sha256:") .context("only sha256 OCI descriptors are supported")?; if component.len() != 64 || !component.bytes().all(|byte| byte.is_ascii_hexdigit()) { bail!("invalid sha256 OCI descriptor"); } Ok(Path::new("blobs/sha256").join(component)) } fn parse_sha256(digest: &str) -> Result<[u8; 32]> { let component = digest .strip_prefix("sha256:") .context("only sha256 OCI descriptors are supported")?; if component.len() != 64 { bail!("invalid sha256 OCI descriptor"); } let mut bytes = [0; 32]; for (index, pair) in component.as_bytes().chunks_exact(2).enumerate() { bytes[index] = u8::from_str_radix(std::str::from_utf8(pair)?, 16)?; } Ok(bytes) } fn decompress_layer(input: File, media_type: &str, output: File, limit: u64) -> Result { let reader: Box = match media_type { "application/vnd.oci.image.layer.v1.tar" => Box::new(input), "application/vnd.oci.image.layer.v1.tar+gzip" => { Box::new(flate2::read::GzDecoder::new(input)) } "application/vnd.oci.image.layer.v1.tar+zstd" => { Box::new(zstd::stream::read::Decoder::new(input)?) } _ => bail!("policy {POLICY_NAME} rejects layer media type {media_type}"), }; digest_reader(reader, limit, Some(output)) } fn digest_reader(mut reader: impl Read, limit: u64, mut output: Option) -> Result { let mut hasher = Sha256::new(); let mut buffer = vec![0; 64 * 1024]; let mut total = 0u64; loop { let read = reader.read(&mut buffer)?; if read == 0 { break; } total = total .checked_add(read as u64) .context("content size overflow")?; if total > limit { bail!("content exceeds {limit} bytes"); } hasher.update(&buffer[..read]); if let Some(output) = &mut output { output.write_all(&buffer[..read])?; } } Ok(format!("sha256:{}", hex(&hasher.finalize()))) } fn validate_tar( source: &File, id_map: &IdMap, limits: PreparationLimits, usage: &mut Usage, ) -> Result<()> { let mut archive = tar::Archive::new(rewound(source)?); let mut paths = HashSet::new(); for entry in archive.entries()? { let mut entry = entry?; let entry_type = entry.header().entry_type(); let path = normalize_layer_path(&entry.path()?, entry_type)?; if !paths.insert(path.clone()) { bail!("policy {POLICY_NAME} rejects duplicate layer path"); } usage.entries += 1; if usage.entries > limits.max_entries || path.as_os_str().as_bytes().len() > limits.max_path_bytes { bail!("policy {POLICY_NAME} rejects entry or path resource limit"); } let size = entry.header().size()?; usage.total_bytes = usage .total_bytes .checked_add(size) .context("size overflow")?; if size > limits.max_file_bytes || usage.total_bytes > limits.max_total_bytes { bail!("policy {POLICY_NAME} rejects content resource limit"); } let pax: Vec<_> = entry .pax_extensions()? .into_iter() .flatten() .collect::>()?; let pax_bytes = pax.iter().try_fold(0usize, |total, item| { total .checked_add(item.key_bytes().len())? .checked_add(item.value_bytes().len()) }); if pax.len() > limits.max_pax_records || pax_bytes.is_none_or(|bytes| bytes > limits.max_pax_bytes) { bail!("policy {POLICY_NAME} rejects PAX resource limit"); } for item in pax { if !matches!(item.key()?, "path" | "linkpath" | "mtime") { bail!("policy {POLICY_NAME} rejects PAX metadata"); } } map_owner(entry.header().uid()?, &id_map.uid, "uid")?; map_owner(entry.header().gid()?, &id_map.gid, "gid")?; if !matches!( entry_type, EntryType::Regular | EntryType::Continuous | EntryType::Directory | EntryType::Symlink | EntryType::Link ) { bail!("policy {POLICY_NAME} rejects entry type {entry_type:?}"); } let name = path .file_name() .and_then(|value| value.to_str()) .unwrap_or(""); if name == ".wh." { bail!("whiteout has no target name"); } if (name == ".wh..wh..opq" || name.starts_with(".wh.")) && (!matches!(entry_type, EntryType::Regular | EntryType::Continuous) || entry.header().size()? != 0) { bail!("whiteout is not an empty regular file"); } if entry_type == EntryType::Link { let target = entry.link_name()?.context("hardlink has no target")?; normalize_relative(&target)?; usage.hardlinks += 1; if usage.hardlinks > limits.max_hardlinks { bail!("policy {POLICY_NAME} rejects hardlink resource limit"); } } } Ok(()) } fn normalize_relative(path: &Path) -> Result { let mut normalized = PathBuf::new(); for component in path.components() { match component { Component::Normal(value) => normalized.push(value), Component::CurDir => {} Component::ParentDir => bail!("parent traversal is not allowed"), Component::RootDir | Component::Prefix(_) => bail!("absolute path is not allowed"), } } if normalized.as_os_str().is_empty() { bail!("empty path is not allowed"); } Ok(normalized) } fn normalize_layer_path(path: &Path, entry_type: EntryType) -> Result { match normalize_relative(path) { Ok(path) => Ok(path), Err(_) if entry_type == EntryType::Directory && path.components().next().is_some() && path .components() .all(|component| component == Component::CurDir) => { Ok(PathBuf::from(".")) } Err(error) => Err(error), } } fn map_owner(value: u64, ranges: &[IdMapRange], name: &str) -> Result { let Ok(value) = u32::try_from(value) else { bail!("{name} {value} is outside the target ID map"); }; for range in ranges { if value >= range.container_id && value - range.container_id < range.size { return range .namespace_id .checked_add(value - range.container_id) .context("mapped owner overflows"); } } bail!("{name} {value} is outside the target ID map") } fn validate_id_map(id_map: &IdMap) -> Result<()> { for (name, ranges) in [("uid", &id_map.uid), ("gid", &id_map.gid)] { if ranges.is_empty() || ranges.iter().any(|range| range.size == 0) { bail!("{name} map is empty or invalid"); } for range in ranges { range .container_id .checked_add(range.size - 1) .context("ID map overflows")?; range .namespace_id .checked_add(range.size - 1) .context("ID map overflows")?; } for (index, left) in ranges.iter().enumerate() { for right in &ranges[index + 1..] { if overlaps(left.container_id, left.size, right.container_id, right.size) || overlaps(left.namespace_id, left.size, right.namespace_id, right.size) { bail!("{name} map contains overlapping ranges"); } } } } Ok(()) } fn overlaps(left: u32, left_size: u32, right: u32, right_size: u32) -> bool { left <= right.saturating_add(right_size - 1) && right <= left.saturating_add(left_size - 1) } fn validate_media_type(media_type: &str) -> Result<()> { if matches!( media_type, "application/vnd.oci.image.layer.v1.tar" | "application/vnd.oci.image.layer.v1.tar+gzip" | "application/vnd.oci.image.layer.v1.tar+zstd" ) { Ok(()) } else { bail!("policy {POLICY_NAME} rejects layer media type {media_type}") } } fn hex(bytes: &[u8]) -> String { use std::fmt::Write as _; bytes.iter().fold( String::with_capacity(bytes.len() * 2), |mut output, byte| { write!(output, "{byte:02x}").expect("writing to String cannot fail"); output }, ) } #[derive(Default)] struct LayerState { directories: HashMap, } impl LayerState { fn apply(&mut self, tar_source: &File, root: &File, id_map: &IdMap) -> Result<()> { apply_whiteouts(tar_source, root, &mut self.directories)?; let mut archive = tar::Archive::new(rewound(tar_source)?); let mut hardlinks = Vec::new(); for entry in archive.entries()? { let mut entry = entry?; let path = entry.path()?.into_owned(); let name = path .file_name() .and_then(|value| value.to_str()) .unwrap_or(""); if name == ".wh..wh..opq" || name.starts_with(".wh.") { continue; } let metadata = EntryMetadata::from_header(path.clone(), entry.header(), id_map)?; match entry.header().entry_type() { EntryType::Regular | EntryType::Continuous => { forget_directory_tree(&mut self.directories, &path); remove_path(root.as_fd(), &path)?; create_regular(root.as_fd(), &path, &mut entry, &metadata)?; } EntryType::Directory => { ensure_directory(root.as_fd(), &path)?; self.directories.insert(path, metadata); } EntryType::Symlink => { let target = entry.link_name()?.context("symlink has no target")?; forget_directory_tree(&mut self.directories, &path); remove_path(root.as_fd(), &path)?; let (parent, leaf) = ensure_parent(root.as_fd(), &path)?; symlinkat(target.as_ref(), &parent, &leaf)?; set_symlink_metadata(&parent, &leaf, &metadata)?; } EntryType::Link => { let target = entry .link_name()? .context("hardlink has no target")? .into_owned(); forget_directory_tree(&mut self.directories, &path); remove_path(root.as_fd(), &path)?; hardlinks.push((path, target)); } entry_type => bail!("validated layer contains unsupported type {entry_type:?}"), } } resolve_hardlinks(root.as_fd(), hardlinks) } fn finish(mut self, root: &File) -> Result<()> { let mut entries: Vec<_> = self.directories.drain().map(|(_, value)| value).collect(); entries.sort_by_key(|metadata| std::cmp::Reverse(metadata.path.components().count())); for metadata in entries { let fd = openat2( root.as_fd(), &metadata.path, OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW, Mode::empty(), secure_resolve(), )?; set_fd_metadata(&fd, &metadata)?; } Ok(()) } } struct EntryMetadata { path: PathBuf, uid: u32, gid: u32, mode: u32, mtime: u64, } impl EntryMetadata { fn from_header(path: PathBuf, header: &Header, id_map: &IdMap) -> Result { Ok(Self { path, uid: map_owner(header.uid()?, &id_map.uid, "uid")?, gid: map_owner(header.gid()?, &id_map.gid, "gid")?, mode: header.mode()?, mtime: header.mtime()?, }) } } fn apply_whiteouts( tar_source: &File, root: &File, directories: &mut HashMap, ) -> Result<()> { let mut archive = tar::Archive::new(rewound(tar_source)?); for entry in archive.entries()? { let entry = entry?; let path = entry.path()?.into_owned(); let name = path .file_name() .and_then(|value| value.to_str()) .unwrap_or(""); if name == ".wh..wh..opq" { if let Some(parent) = path.parent() { remove_children_at(root.as_fd(), parent)?; directories .retain(|candidate, _| candidate == parent || !candidate.starts_with(parent)); } } else if let Some(target_name) = name.strip_prefix(".wh.") { let target = path.parent().unwrap_or(Path::new("")).join(target_name); forget_directory_tree(directories, &target); remove_path(root.as_fd(), &target)?; } } Ok(()) } fn forget_directory_tree(directories: &mut HashMap, path: &Path) { directories.retain(|candidate, _| !candidate.starts_with(path)); } fn create_regular( root: impl AsFd, path: &Path, entry: &mut tar::Entry<'_, R>, metadata: &EntryMetadata, ) -> Result<()> { let (parent, leaf) = ensure_parent(root, path)?; let fd = openat2( &parent, &leaf, OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC | OFlags::NOFOLLOW, Mode::from_raw_mode(0o600), secure_resolve(), )?; let mut file = File::from(fd); io::copy(entry, &mut file)?; set_fd_metadata(&file, metadata) } fn ensure_directory(root: impl AsFd, path: &Path) -> Result<()> { if let Some(file_type) = path_type(root.as_fd(), path)? { if file_type == FileType::Directory { return Ok(()); } remove_path(root.as_fd(), path)?; } let (parent, leaf) = ensure_parent(root, path)?; match mkdirat(&parent, &leaf, Mode::from_raw_mode(0o700)) { Ok(()) | Err(Errno::EXIST) => Ok(()), Err(error) => Err(error.into()), } } fn resolve_hardlinks(root: impl AsFd, mut pending: Vec<(PathBuf, PathBuf)>) -> Result<()> { while !pending.is_empty() { let mut remaining = Vec::new(); let mut progress = false; for (path, target) in pending { match create_hardlink(root.as_fd(), &path, &target) { Ok(()) => progress = true, Err(error) if error.downcast_ref::() == Some(&Errno::NOENT) => { remaining.push((path, target)); } Err(error) => return Err(error), } } if !progress { bail!("unresolved layer hardlinks"); } pending = remaining; } Ok(()) } fn create_hardlink(root: impl AsFd, path: &Path, target: &Path) -> Result<()> { let target_fd = openat2( root.as_fd(), target, OFlags::PATH | OFlags::CLOEXEC | OFlags::NOFOLLOW, Mode::empty(), secure_resolve(), )?; if FileType::from_raw_mode(rustix::fs::fstat(&target_fd)?.st_mode) != FileType::RegularFile { bail!("hardlink target is not a regular file"); } let (parent, leaf) = ensure_parent(root, path)?; linkat(&target_fd, "", &parent, &leaf, AtFlags::EMPTY_PATH)?; Ok(()) } fn path_type(root: impl AsFd, path: &Path) -> Result> { match openat2( root, path, OFlags::PATH | OFlags::CLOEXEC | OFlags::NOFOLLOW, Mode::empty(), secure_resolve(), ) { Ok(fd) => Ok(Some(FileType::from_raw_mode( rustix::fs::fstat(fd)?.st_mode, ))), Err(Errno::NOENT) => Ok(None), Err(error) => Err(error.into()), } } fn remove_path(root: impl AsFd, path: &Path) -> Result<()> { let Some((parent, leaf)) = existing_parent(root, path)? else { return Ok(()); }; let file_type = match openat2( &parent, &leaf, OFlags::PATH | OFlags::CLOEXEC | OFlags::NOFOLLOW, Mode::empty(), secure_resolve(), ) { Ok(fd) => FileType::from_raw_mode(rustix::fs::fstat(fd)?.st_mode), Err(Errno::NOENT) => return Ok(()), Err(error) => return Err(error.into()), }; if file_type == FileType::Directory { let directory = open_component(&parent, &leaf)?; remove_children(&directory)?; unlinkat(&parent, &leaf, AtFlags::REMOVEDIR)?; } else { unlinkat(&parent, &leaf, AtFlags::empty())?; } Ok(()) } fn remove_children_at(root: impl AsFd, path: &Path) -> Result<()> { if path.as_os_str().is_empty() { return remove_children(root); } let directory = match openat2( root, path, OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW, Mode::empty(), secure_resolve(), ) { Ok(fd) => fd, Err(Errno::NOENT) => return Ok(()), Err(error) => return Err(error.into()), }; remove_children(&directory) } pub(crate) fn remove_children(directory: impl AsFd) -> Result<()> { let mut buffer = [std::mem::MaybeUninit::uninit(); 8192]; let names = { let mut names = Vec::new(); let mut entries = rustix::fs::RawDir::new(directory.as_fd(), &mut buffer); while let Some(entry) = entries.next() { let entry = entry?; let name = entry.file_name(); if name.to_bytes() != b"." && name.to_bytes() != b".." { names.push(name.to_owned()); } } names }; for name in names { let file_type = match openat2( directory.as_fd(), &name, OFlags::PATH | OFlags::CLOEXEC | OFlags::NOFOLLOW, Mode::empty(), secure_resolve(), ) { Ok(fd) => FileType::from_raw_mode(rustix::fs::fstat(fd)?.st_mode), Err(Errno::NOENT) => continue, Err(error) => return Err(error.into()), }; if file_type == FileType::Directory { let child = open_component(directory.as_fd(), OsStr::from_bytes(name.to_bytes()))?; remove_children(&child)?; unlinkat(directory.as_fd(), name.as_c_str(), AtFlags::REMOVEDIR)?; } else { unlinkat(directory.as_fd(), name.as_c_str(), AtFlags::empty())?; } } Ok(()) } fn ensure_parent(root: impl AsFd, path: &Path) -> Result<(OwnedFd, OsString)> { let leaf = path .file_name() .context("path has no final component")? .to_os_string(); let mut current = dup(root)?; if let Some(parent) = path.parent() { for component in parent.components() { let Component::Normal(name) = component else { bail!("path is not relative"); }; match mkdirat(¤t, name, Mode::from_raw_mode(0o700)) { Ok(()) | Err(Errno::EXIST) => {} Err(error) => return Err(error.into()), } current = open_component(¤t, name)?; } } Ok((current, leaf)) } fn existing_parent(root: impl AsFd, path: &Path) -> Result> { let leaf = path .file_name() .context("path has no final component")? .to_os_string(); let mut current = dup(root)?; if let Some(parent) = path.parent() { for component in parent.components() { let Component::Normal(name) = component else { bail!("path is not relative"); }; current = match open_component(¤t, name) { Ok(fd) => fd, Err(Errno::NOENT) => return Ok(None), Err(error) => return Err(error.into()), }; } } Ok(Some((current, leaf))) } fn open_component(parent: impl AsFd, name: &OsStr) -> rustix::io::Result { openat2( parent, name, OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW, Mode::empty(), secure_resolve(), ) } fn set_fd_metadata(fd: impl AsFd, metadata: &EntryMetadata) -> Result<()> { fchown( &fd, Some(Uid::from_raw(metadata.uid)), Some(Gid::from_raw(metadata.gid)), )?; fchmod(&fd, Mode::from_raw_mode(metadata.mode))?; futimens(&fd, ×tamps(metadata.mtime))?; Ok(()) } fn set_symlink_metadata(parent: impl AsFd, leaf: &OsStr, metadata: &EntryMetadata) -> Result<()> { chownat( &parent, leaf, Some(Uid::from_raw(metadata.uid)), Some(Gid::from_raw(metadata.gid)), AtFlags::SYMLINK_NOFOLLOW, )?; utimensat( parent, leaf, ×tamps(metadata.mtime), AtFlags::SYMLINK_NOFOLLOW, )?; Ok(()) } fn timestamps(seconds: u64) -> Timestamps { let seconds = i64::try_from(seconds).unwrap_or(i64::MAX); let time = Timespec { tv_sec: seconds, tv_nsec: 0, }; Timestamps { last_access: time, last_modification: time, } } fn secure_resolve() -> ResolveFlags { ResolveFlags::BENEATH | ResolveFlags::NO_SYMLINKS | ResolveFlags::NO_MAGICLINKS | ResolveFlags::NO_XDEV } fn rewound(source: &File) -> Result { let mut file = source.try_clone()?; file.seek(SeekFrom::Start(0))?; Ok(file) } #[cfg(test)] mod tests { use super::*; #[test] fn retained_layer_resolution_refuses_mount_crossing() { assert!(secure_resolve().contains(ResolveFlags::NO_XDEV)); } }