Implement verified OCI prepared-lower lifecycle
This commit is contained in:
parent
54f3a08b91
commit
5458460717
1 changed files with 4973 additions and 273 deletions
846
crates/lazaret-prepared-lower/src/layer.rs
Normal file
846
crates/lazaret-prepared-lower/src/layer.rs
Normal file
|
|
@ -0,0 +1,846 @@
|
|||
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<IdMapRange>,
|
||||
pub gid: Vec<IdMapRange>,
|
||||
}
|
||||
|
||||
#[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<Descriptor>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Manifest {
|
||||
config: Descriptor,
|
||||
layers: Vec<Descriptor>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Config {
|
||||
rootfs: Rootfs,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Rootfs {
|
||||
diff_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
struct Descriptor {
|
||||
digest: String,
|
||||
size: u64,
|
||||
#[serde(rename = "mediaType", default)]
|
||||
media_type: String,
|
||||
}
|
||||
|
||||
pub(crate) struct VerifiedLayout {
|
||||
layers: Vec<Descriptor>,
|
||||
diff_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Usage {
|
||||
entries: u64,
|
||||
hardlinks: usize,
|
||||
total_bytes: u64,
|
||||
}
|
||||
|
||||
pub(crate) fn verify_layout(layout: &Path, limits: PreparationLimits) -> Result<VerifiedLayout> {
|
||||
let index: LayoutIndex = read_json(&layout.join("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 {
|
||||
layers: manifest.layers,
|
||||
diff_ids: config.rootfs.diff_ids,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn apply_layout(
|
||||
layout: &Path,
|
||||
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_name = format!(".layer-{index}.tar");
|
||||
let spool_output = File::from(openat2(
|
||||
rootfs,
|
||||
&spool_name,
|
||||
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||
Mode::from_raw_mode(0o600),
|
||||
secure_resolve(),
|
||||
)?);
|
||||
let observed = decompress_layer(
|
||||
&blob_path(layout, &descriptor.digest)?,
|
||||
&descriptor.media_type,
|
||||
spool_output,
|
||||
limits.max_uncompressed_layer_bytes,
|
||||
)?;
|
||||
if &observed != diff_id {
|
||||
let _ = unlinkat(rootfs, &spool_name, AtFlags::empty());
|
||||
bail!("layer {index} diff-ID mismatch");
|
||||
}
|
||||
let spool = File::from(openat2(
|
||||
rootfs,
|
||||
&spool_name,
|
||||
OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||
Mode::empty(),
|
||||
secure_resolve(),
|
||||
)?);
|
||||
let result = validate_tar(&spool, id_map, limits, &mut usage)
|
||||
.and_then(|()| state.apply(&spool, rootfs));
|
||||
if let Err(error) = result {
|
||||
let _ = unlinkat(rootfs, &spool_name, AtFlags::empty());
|
||||
return Err(error);
|
||||
}
|
||||
match unlinkat(rootfs, &spool_name, AtFlags::empty()) {
|
||||
Ok(()) | Err(rustix::io::Errno::NOENT) => {}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
}
|
||||
state.finish(rootfs)
|
||||
}
|
||||
|
||||
fn read_json<T: serde::de::DeserializeOwned>(path: &Path, limit: u64) -> Result<T> {
|
||||
let metadata = std::fs::metadata(path)?;
|
||||
if metadata.len() > limit {
|
||||
bail!("OCI JSON exceeds {limit} bytes");
|
||||
}
|
||||
Ok(serde_json::from_slice(&std::fs::read(path)?)?)
|
||||
}
|
||||
|
||||
fn verified_blob(layout: &Path, descriptor: &Descriptor, limit: u64) -> Result<Vec<u8>> {
|
||||
let path = blob_path(layout, &descriptor.digest)?;
|
||||
let metadata = std::fs::metadata(&path)?;
|
||||
if metadata.len() != descriptor.size || metadata.len() > limit {
|
||||
bail!("OCI descriptor size or resource limit mismatch");
|
||||
}
|
||||
let bytes = std::fs::read(path)?;
|
||||
if Sha256::digest(&bytes).as_slice() != parse_sha256(&descriptor.digest)? {
|
||||
bail!("OCI descriptor digest mismatch");
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn verify_blob_digest(layout: &Path, descriptor: &Descriptor, limit: u64) -> Result<()> {
|
||||
let path = blob_path(layout, &descriptor.digest)?;
|
||||
let metadata = std::fs::metadata(&path)?;
|
||||
if metadata.len() != descriptor.size || metadata.len() > limit {
|
||||
bail!("OCI layer size or resource limit mismatch");
|
||||
}
|
||||
let observed = digest_reader(File::open(path)?, limit, None)?;
|
||||
if observed != descriptor.digest {
|
||||
bail!("OCI layer digest mismatch");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn blob_path(layout: &Path, digest: &str) -> Result<PathBuf> {
|
||||
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(layout.join("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(source: &Path, media_type: &str, output: File, limit: u64) -> Result<String> {
|
||||
let input = File::open(source)?;
|
||||
let reader: Box<dyn Read> = 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<File>) -> Result<String> {
|
||||
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 path = normalize_relative(&entry.path()?)?;
|
||||
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::<Result<_, _>>()?;
|
||||
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");
|
||||
}
|
||||
}
|
||||
validate_owner(entry.header().uid()?, &id_map.uid, "uid")?;
|
||||
validate_owner(entry.header().gid()?, &id_map.gid, "gid")?;
|
||||
let entry_type = entry.header().entry_type();
|
||||
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<PathBuf> {
|
||||
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 validate_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");
|
||||
};
|
||||
if !ranges
|
||||
.iter()
|
||||
.any(|range| value >= range.container_id && value - range.container_id < range.size)
|
||||
{
|
||||
bail!("{name} {value} is outside the target ID map");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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<PathBuf, EntryMetadata>,
|
||||
}
|
||||
|
||||
impl LayerState {
|
||||
fn apply(&mut self, tar_source: &File, root: &File) -> 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())?;
|
||||
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) -> Result<Self> {
|
||||
Ok(Self {
|
||||
path,
|
||||
uid: u32::try_from(header.uid()?).context("entry uid exceeds u32")?,
|
||||
gid: u32::try_from(header.gid()?).context("entry gid exceeds u32")?,
|
||||
mode: header.mode()?,
|
||||
mtime: header.mtime()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_whiteouts(
|
||||
tar_source: &File,
|
||||
root: &File,
|
||||
directories: &mut HashMap<PathBuf, EntryMetadata>,
|
||||
) -> 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<PathBuf, EntryMetadata>, path: &Path) {
|
||||
directories.retain(|candidate, _| !candidate.starts_with(path));
|
||||
}
|
||||
|
||||
fn create_regular<R: Read>(
|
||||
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::<Errno>() == 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<Option<FileType>> {
|
||||
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<Option<(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");
|
||||
};
|
||||
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<OwnedFd> {
|
||||
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
|
||||
}
|
||||
|
||||
fn rewound(source: &File) -> Result<File> {
|
||||
let mut file = source.try_clone()?;
|
||||
file.seek(SeekFrom::Start(0))?;
|
||||
Ok(file)
|
||||
}
|
||||
Loading…
Reference in a new issue