Implement verified OCI prepared-lower lifecycle
This commit is contained in:
parent
54f3a08b91
commit
5458460717
6 changed files with 4973 additions and 273 deletions
28
crates/lazaret-prepared-lower/Cargo.toml
Normal file
28
crates/lazaret-prepared-lower/Cargo.toml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
[package]
|
||||
name = "lazaret-prepared-lower"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
flate2.workspace = true
|
||||
lazaret-core = { version = "=0.0.0", path = "../lazaret-core" }
|
||||
lazaret-linux = { version = "=0.0.0", path = "../lazaret-linux" }
|
||||
lazaret-storage-catalog = { version = "=0.0.0", path = "../lazaret-storage-catalog" }
|
||||
oci-client.workspace = true
|
||||
postcard.workspace = true
|
||||
rustix.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
tar.workspace = true
|
||||
tokio.workspace = true
|
||||
zstd.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "=3.27.0"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
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)
|
||||
}
|
||||
9
crates/lazaret-prepared-lower/src/lib.rs
Normal file
9
crates/lazaret-prepared-lower/src/lib.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
#![doc = "OCI transport, preparation, publication, and exact-owned reclaim for prepared lowers."]
|
||||
|
||||
mod layer;
|
||||
mod store;
|
||||
mod transport;
|
||||
|
||||
pub use layer::{IdMap, IdMapRange, PreparationLimits};
|
||||
pub use store::{PrepareRecovery, PrepareRequest, PreparedLowerStore, ReclaimOutcome};
|
||||
pub use transport::{OciRegistryTransport, Platform, RegistryCredentials, TransportedImage};
|
||||
608
crates/lazaret-prepared-lower/src/store.rs
Normal file
608
crates/lazaret-prepared-lower/src/store.rs
Normal file
|
|
@ -0,0 +1,608 @@
|
|||
use std::fs::File;
|
||||
use std::io;
|
||||
use std::os::fd::AsFd;
|
||||
use std::os::unix::ffi::{OsStrExt, OsStringExt};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use lazaret_core::identity::{
|
||||
DeviceId, HostPath, IdMapDigest, InodeId, PolicyId, PrepareOperationId, PreparedArtifactId,
|
||||
SelectedManifestDigest, SourceIdentity,
|
||||
};
|
||||
use lazaret_core::records::{ArtifactState, PrepareOperation, PrepareStageState, PreparedArtifact};
|
||||
use lazaret_linux::filesystem::{ExclusiveFileLock, try_exclusive_flock};
|
||||
use lazaret_storage_catalog::{Catalog, CatalogError, CatalogRow};
|
||||
use rustix::fs::{AtFlags, Mode, OFlags, ResolveFlags, mkdirat, openat2, renameat, unlinkat};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::layer::{
|
||||
IdMap, POLICY_NAME, PreparationLimits, apply_layout, remove_children, verify_layout,
|
||||
};
|
||||
|
||||
const PHYSICAL_LOCK_WAIT: Duration = Duration::from_secs(5);
|
||||
const LOCK_RETRY: Duration = Duration::from_millis(2);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PrepareRequest {
|
||||
pub operation_id: PrepareOperationId,
|
||||
pub layout_path: PathBuf,
|
||||
pub source_identity: Vec<u8>,
|
||||
pub selected_manifest_digest: [u8; 32],
|
||||
pub id_map: IdMap,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum PrepareRecovery {
|
||||
ClearedUnmaterializedIntent,
|
||||
RemovedOwnedStage,
|
||||
PublishedCompletedStage,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ReclaimOutcome {
|
||||
Removed,
|
||||
ReplacementPreserved,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PreparedLowerStore {
|
||||
root: PathBuf,
|
||||
catalog: Catalog,
|
||||
limits: PreparationLimits,
|
||||
}
|
||||
|
||||
impl PreparedLowerStore {
|
||||
/// Creates the fixed prepared-lower directories beneath an existing Storage Root.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the root cannot be opened or any fixed path is not a real directory.
|
||||
pub fn initialize(root: impl Into<PathBuf>, catalog: Catalog) -> Result<Self> {
|
||||
let store = Self {
|
||||
root: root.into(),
|
||||
catalog,
|
||||
limits: PreparationLimits::default(),
|
||||
};
|
||||
let root = File::open(&store.root).context("opening Storage Root")?;
|
||||
for name in ["artifacts", "stages", "quarantine"] {
|
||||
match mkdirat(&root, name, Mode::from_raw_mode(0o700)) {
|
||||
Ok(()) | Err(rustix::io::Errno::EXIST) => {}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
open_directory_at(&root, Path::new(name))?;
|
||||
}
|
||||
open_lock_at(&root)?;
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_limits(mut self, limits: PreparationLimits) -> Self {
|
||||
self.limits = limits;
|
||||
self
|
||||
}
|
||||
|
||||
/// Materializes and atomically publishes one verified immutable prepared lower.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Refuses corrupt or unsupported OCI input, forged physical ownership, catalog conflicts,
|
||||
/// resource limits, and publication failures.
|
||||
pub fn prepare(&self, request: &PrepareRequest) -> Result<PreparedArtifact> {
|
||||
if request.source_identity.is_empty() || request.source_identity.len() > 4096 {
|
||||
bail!("source identity is empty or exceeds 4096 bytes");
|
||||
}
|
||||
let verified = verify_layout(&request.layout_path, self.limits)?;
|
||||
let identities = Identities::new(request)?;
|
||||
if let Some(existing) = self.available_artifact(identities.artifact_id)? {
|
||||
identities.require_equivalent(&existing)?;
|
||||
self.require_artifact_identity(&existing)?;
|
||||
return Ok(existing);
|
||||
}
|
||||
|
||||
let stage_relative = stage_path(request.operation_id);
|
||||
let intent = identities.intent(request.operation_id, &stage_relative);
|
||||
self.catalog.record_prepare(&intent)?;
|
||||
let stage = match self.create_stage(&stage_relative) {
|
||||
Ok(stage) => stage,
|
||||
Err(error) => {
|
||||
let _ = self.catalog.remove_prepare(&intent);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let (device, inode) = file_identity(&stage)?;
|
||||
let owned = PrepareOperation {
|
||||
stage_state: PrepareStageState::Owned,
|
||||
device: Some(device),
|
||||
inode: Some(inode),
|
||||
..intent.clone()
|
||||
};
|
||||
if let Err(error) = self.catalog.bind_prepare_stage(&intent, &owned) {
|
||||
if self
|
||||
.remove_exact_path(&stage_relative, device, inode)
|
||||
.is_ok()
|
||||
{
|
||||
let _ = self.catalog.remove_prepare(&intent);
|
||||
}
|
||||
return Err(error.into());
|
||||
}
|
||||
if let Err(error) = apply_layout(
|
||||
&request.layout_path,
|
||||
&stage,
|
||||
&verified,
|
||||
&request.id_map,
|
||||
self.limits,
|
||||
)
|
||||
.and_then(|()| sync_tree(&stage))
|
||||
{
|
||||
if self
|
||||
.remove_exact_path(&stage_relative, device, inode)
|
||||
.is_ok()
|
||||
{
|
||||
let _ = self.catalog.remove_prepare(&owned);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
self.publish_owned(&owned, &identities)
|
||||
}
|
||||
|
||||
/// Recovers one exact durable prepare operation without adopting foreign paths.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Refuses unexpected paths, missing ownership evidence, identity mismatch, and catalog or
|
||||
/// filesystem failures.
|
||||
pub fn recover_prepare(&self, operation: &PrepareOperation) -> Result<PrepareRecovery> {
|
||||
let expected_stage = stage_path(operation.operation_id);
|
||||
if operation.stage_path.as_bytes() != path_bytes(&expected_stage) {
|
||||
bail!("prepare operation stage path is not canonical");
|
||||
}
|
||||
match operation.stage_state {
|
||||
PrepareStageState::Intent => {
|
||||
if self.open_relative(&expected_stage)?.is_some() {
|
||||
bail!("unowned stage path is present");
|
||||
}
|
||||
self.catalog.remove_prepare(operation)?;
|
||||
Ok(PrepareRecovery::ClearedUnmaterializedIntent)
|
||||
}
|
||||
PrepareStageState::Owned => {
|
||||
let device = operation.device.context("owned prepare lacks device")?;
|
||||
let inode = operation.inode.context("owned prepare lacks inode")?;
|
||||
if self.open_relative(&expected_stage)?.is_some() {
|
||||
self.remove_exact_path(&expected_stage, device, inode)?;
|
||||
self.catalog.remove_prepare(operation)?;
|
||||
return Ok(PrepareRecovery::RemovedOwnedStage);
|
||||
}
|
||||
let artifact_relative = artifact_path(operation.expected_artifact_id);
|
||||
let Some(artifact_fd) = self.open_relative(&artifact_relative)? else {
|
||||
bail!("owned preparation path is absent without exact recovery evidence");
|
||||
};
|
||||
if file_identity(&artifact_fd)? != (device, inode) {
|
||||
bail!("published path does not match preparation ownership");
|
||||
}
|
||||
let artifact =
|
||||
artifact_from_operation(operation, &artifact_relative, device, inode);
|
||||
self.publish_catalog_row(operation, &artifact)?;
|
||||
Ok(PrepareRecovery::PublishedCompletedStage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves an exact zero-lease artifact to quarantine, deletes it, and closes reclaim intent.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Refuses leases, forged catalog rows, physical identity mismatch, foreign paths, and
|
||||
/// filesystem or catalog failures.
|
||||
pub fn reclaim(&self, expected: &PreparedArtifact) -> Result<ReclaimOutcome> {
|
||||
let reclaiming = self.catalog.begin_reclaim(expected)?;
|
||||
self.resume_reclaim(&reclaiming)
|
||||
}
|
||||
|
||||
/// Resumes an exact durable reclaim after interruption.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Refuses non-reclaiming state, path/identity mismatch, missing physical evidence, and
|
||||
/// catalog or filesystem failures.
|
||||
pub fn resume_reclaim(&self, reclaiming: &PreparedArtifact) -> Result<ReclaimOutcome> {
|
||||
if reclaiming.state != ArtifactState::Reclaiming {
|
||||
bail!("artifact is not reclaiming");
|
||||
}
|
||||
let expected_artifact = artifact_path(reclaiming.prepared_artifact_id);
|
||||
if reclaiming.path.as_bytes() != path_bytes(&expected_artifact) {
|
||||
bail!("artifact path is not canonical");
|
||||
}
|
||||
let quarantine = quarantine_path(reclaiming);
|
||||
{
|
||||
let _lock = self.physical_lock()?;
|
||||
if let Some(canonical) = self.open_relative(&expected_artifact)? {
|
||||
require_identity(&canonical, reclaiming.device, reclaiming.inode)?;
|
||||
self.rename_relative(&expected_artifact, &quarantine)?;
|
||||
self.sync_fixed_directory("artifacts")?;
|
||||
self.sync_fixed_directory("quarantine")?;
|
||||
} else {
|
||||
let existing = self
|
||||
.open_relative(&quarantine)?
|
||||
.context("reclaiming artifact and quarantine are both absent")?;
|
||||
require_identity(&existing, reclaiming.device, reclaiming.inode)?;
|
||||
}
|
||||
}
|
||||
self.remove_exact_path(&quarantine, reclaiming.device, reclaiming.inode)?;
|
||||
match self.catalog.finish_reclaim(reclaiming) {
|
||||
Ok(()) => Ok(ReclaimOutcome::Removed),
|
||||
Err(CatalogError::OwnershipMismatch {
|
||||
row: CatalogRow::PreparedArtifact,
|
||||
}) => {
|
||||
let replacement = self
|
||||
.available_artifact(reclaiming.prepared_artifact_id)?
|
||||
.context("reclaim row changed without an available replacement")?;
|
||||
self.require_artifact_identity(&replacement)?;
|
||||
Ok(ReclaimOutcome::ReplacementPreserved)
|
||||
}
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn publish_owned(
|
||||
&self,
|
||||
operation: &PrepareOperation,
|
||||
identities: &Identities,
|
||||
) -> Result<PreparedArtifact> {
|
||||
let stage = stage_path(operation.operation_id);
|
||||
let artifact_path = artifact_path(operation.expected_artifact_id);
|
||||
let _lock = self.physical_lock()?;
|
||||
if let Some(existing) = self.open_relative(&artifact_path)? {
|
||||
if let Some(row) = self.available_artifact(operation.expected_artifact_id)? {
|
||||
identities.require_equivalent(&row)?;
|
||||
require_identity(&existing, row.device, row.inode)?;
|
||||
let device = operation.device.context("owned prepare lacks device")?;
|
||||
let inode = operation.inode.context("owned prepare lacks inode")?;
|
||||
self.remove_exact_path(&stage, device, inode)?;
|
||||
self.catalog.remove_prepare(operation)?;
|
||||
return Ok(row);
|
||||
}
|
||||
let reclaiming = self
|
||||
.catalog
|
||||
.snapshot()?
|
||||
.prepared_artifacts
|
||||
.into_iter()
|
||||
.find(|candidate| {
|
||||
candidate.prepared_artifact_id == operation.expected_artifact_id
|
||||
&& candidate.state == ArtifactState::Reclaiming
|
||||
})
|
||||
.context("canonical artifact path exists without matching catalog ownership")?;
|
||||
require_identity(&existing, reclaiming.device, reclaiming.inode)?;
|
||||
let quarantine = quarantine_path(&reclaiming);
|
||||
self.rename_relative(&artifact_path, &quarantine)?;
|
||||
self.sync_fixed_directory("quarantine")?;
|
||||
}
|
||||
self.rename_relative(&stage, &artifact_path)?;
|
||||
self.sync_fixed_directory("artifacts")?;
|
||||
let artifact_fd = self
|
||||
.open_relative(&artifact_path)?
|
||||
.context("published artifact disappeared")?;
|
||||
let (device, inode) = file_identity(&artifact_fd)?;
|
||||
let artifact = identities.artifact(&artifact_path, device, inode);
|
||||
self.publish_catalog_row(operation, &artifact)?;
|
||||
Ok(artifact)
|
||||
}
|
||||
|
||||
fn publish_catalog_row(
|
||||
&self,
|
||||
operation: &PrepareOperation,
|
||||
artifact: &PreparedArtifact,
|
||||
) -> Result<()> {
|
||||
let snapshot = self.catalog.snapshot()?;
|
||||
if let Some(reclaiming) = snapshot.prepared_artifacts.into_iter().find(|candidate| {
|
||||
candidate.prepared_artifact_id == artifact.prepared_artifact_id
|
||||
&& candidate.state == ArtifactState::Reclaiming
|
||||
}) {
|
||||
self.catalog
|
||||
.publish_replacement(operation, &reclaiming, artifact)?;
|
||||
} else {
|
||||
self.catalog.publish(operation, artifact)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn available_artifact(&self, id: PreparedArtifactId) -> Result<Option<PreparedArtifact>> {
|
||||
Ok(self
|
||||
.catalog
|
||||
.snapshot()?
|
||||
.prepared_artifacts
|
||||
.into_iter()
|
||||
.find(|artifact| {
|
||||
artifact.prepared_artifact_id == id && artifact.state == ArtifactState::Available
|
||||
}))
|
||||
}
|
||||
|
||||
fn require_artifact_identity(&self, artifact: &PreparedArtifact) -> Result<()> {
|
||||
let path = PathBuf::from(std::ffi::OsString::from_vec(
|
||||
artifact.path.as_bytes().to_vec(),
|
||||
));
|
||||
let expected = artifact_path(artifact.prepared_artifact_id);
|
||||
if path != expected {
|
||||
bail!("artifact path is not canonical");
|
||||
}
|
||||
let file = self
|
||||
.open_relative(&path)?
|
||||
.context("artifact path is absent")?;
|
||||
require_identity(&file, artifact.device, artifact.inode)
|
||||
}
|
||||
|
||||
fn create_stage(&self, relative: &Path) -> Result<File> {
|
||||
let stages = open_directory_at(&File::open(&self.root)?, Path::new("stages"))?;
|
||||
let name = relative.file_name().context("stage has no name")?;
|
||||
mkdirat(&stages, name, Mode::from_raw_mode(0o700))?;
|
||||
open_directory_at(&stages, Path::new(name))
|
||||
}
|
||||
|
||||
fn open_relative(&self, relative: &Path) -> Result<Option<File>> {
|
||||
let root = File::open(&self.root)?;
|
||||
match openat2(
|
||||
&root,
|
||||
relative,
|
||||
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||
Mode::empty(),
|
||||
secure_resolve(),
|
||||
) {
|
||||
Ok(fd) => Ok(Some(File::from(fd))),
|
||||
Err(rustix::io::Errno::NOENT) => Ok(None),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn rename_relative(&self, from: &Path, to: &Path) -> Result<()> {
|
||||
let root = File::open(&self.root)?;
|
||||
renameat(&root, from, &root, to)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_exact_path(&self, relative: &Path, device: DeviceId, inode: InodeId) -> Result<()> {
|
||||
let root = File::open(&self.root)?;
|
||||
let file = open_directory_at(&root, relative)?;
|
||||
require_identity(&file, device, inode)?;
|
||||
remove_children(&file)?;
|
||||
unlinkat(&root, relative, AtFlags::REMOVEDIR)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn physical_lock(&self) -> Result<ExclusiveFileLock> {
|
||||
let deadline = Instant::now() + PHYSICAL_LOCK_WAIT;
|
||||
loop {
|
||||
let file = open_lock_at(&File::open(&self.root)?)?;
|
||||
match try_exclusive_flock(file) {
|
||||
Ok(lock) => return Ok(lock),
|
||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
|
||||
let now = Instant::now();
|
||||
if now >= deadline {
|
||||
bail!("prepared-lower physical lock wait expired");
|
||||
}
|
||||
thread::sleep(LOCK_RETRY.min(deadline.saturating_duration_since(now)));
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_fixed_directory(&self, name: &str) -> Result<()> {
|
||||
open_directory_at(&File::open(&self.root)?, Path::new(name))?.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct Identities {
|
||||
artifact_id: PreparedArtifactId,
|
||||
source: SourceIdentity,
|
||||
manifest: SelectedManifestDigest,
|
||||
policy: PolicyId,
|
||||
id_map: IdMapDigest,
|
||||
}
|
||||
|
||||
impl Identities {
|
||||
fn new(request: &PrepareRequest) -> Result<Self> {
|
||||
let source = SourceIdentity::new(request.source_identity.clone());
|
||||
let manifest = SelectedManifestDigest::new(request.selected_manifest_digest);
|
||||
let policy = PolicyId::new(hash_parts(&[b"lazaret-policy-v1", POLICY_NAME.as_bytes()]));
|
||||
let id_map_bytes = postcard::to_stdvec(&request.id_map)?;
|
||||
let id_map = IdMapDigest::new(hash_parts(&[b"lazaret-id-map-v1", &id_map_bytes]));
|
||||
let artifact_id = PreparedArtifactId::new(hash_parts(&[
|
||||
b"lazaret-prepared-artifact-v1",
|
||||
source.as_bytes(),
|
||||
manifest.as_bytes(),
|
||||
policy.as_bytes(),
|
||||
id_map.as_bytes(),
|
||||
]));
|
||||
Ok(Self {
|
||||
artifact_id,
|
||||
source,
|
||||
manifest,
|
||||
policy,
|
||||
id_map,
|
||||
})
|
||||
}
|
||||
|
||||
fn intent(&self, operation_id: PrepareOperationId, stage: &Path) -> PrepareOperation {
|
||||
PrepareOperation {
|
||||
operation_id,
|
||||
expected_artifact_id: self.artifact_id,
|
||||
source_identity: self.source.clone(),
|
||||
selected_manifest_digest: self.manifest,
|
||||
policy_id: self.policy,
|
||||
id_map_digest: self.id_map,
|
||||
stage_path: HostPath::new(path_bytes(stage).to_vec()),
|
||||
stage_state: PrepareStageState::Intent,
|
||||
device: None,
|
||||
inode: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn artifact(&self, path: &Path, device: DeviceId, inode: InodeId) -> PreparedArtifact {
|
||||
PreparedArtifact {
|
||||
prepared_artifact_id: self.artifact_id,
|
||||
source_identity: self.source.clone(),
|
||||
selected_manifest_digest: self.manifest,
|
||||
policy_id: self.policy,
|
||||
id_map_digest: self.id_map,
|
||||
path: HostPath::new(path_bytes(path).to_vec()),
|
||||
device,
|
||||
inode,
|
||||
state: ArtifactState::Available,
|
||||
}
|
||||
}
|
||||
|
||||
fn require_equivalent(&self, artifact: &PreparedArtifact) -> Result<()> {
|
||||
if artifact.prepared_artifact_id != self.artifact_id
|
||||
|| artifact.source_identity != self.source
|
||||
|| artifact.selected_manifest_digest != self.manifest
|
||||
|| artifact.policy_id != self.policy
|
||||
|| artifact.id_map_digest != self.id_map
|
||||
{
|
||||
bail!("prepared artifact identity inputs differ");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn artifact_from_operation(
|
||||
operation: &PrepareOperation,
|
||||
path: &Path,
|
||||
device: DeviceId,
|
||||
inode: InodeId,
|
||||
) -> PreparedArtifact {
|
||||
PreparedArtifact {
|
||||
prepared_artifact_id: operation.expected_artifact_id,
|
||||
source_identity: operation.source_identity.clone(),
|
||||
selected_manifest_digest: operation.selected_manifest_digest,
|
||||
policy_id: operation.policy_id,
|
||||
id_map_digest: operation.id_map_digest,
|
||||
path: HostPath::new(path_bytes(path).to_vec()),
|
||||
device,
|
||||
inode,
|
||||
state: ArtifactState::Available,
|
||||
}
|
||||
}
|
||||
|
||||
fn file_identity(file: &File) -> Result<(DeviceId, InodeId)> {
|
||||
let stat = rustix::fs::fstat(file)?;
|
||||
Ok((DeviceId::new(stat.st_dev), InodeId::new(stat.st_ino)))
|
||||
}
|
||||
|
||||
fn require_identity(file: &File, device: DeviceId, inode: InodeId) -> Result<()> {
|
||||
if file_identity(file)? == (device, inode) {
|
||||
Ok(())
|
||||
} else {
|
||||
bail!("physical path identity mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
fn open_directory_at(parent: impl AsFd, path: &Path) -> Result<File> {
|
||||
Ok(File::from(openat2(
|
||||
parent,
|
||||
path,
|
||||
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||
Mode::empty(),
|
||||
secure_resolve(),
|
||||
)?))
|
||||
}
|
||||
|
||||
fn open_lock_at(root: impl AsFd) -> Result<File> {
|
||||
Ok(File::from(openat2(
|
||||
root,
|
||||
"prepared-lower.lock",
|
||||
OFlags::RDWR | OFlags::CREATE | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||
Mode::from_raw_mode(0o600),
|
||||
secure_resolve(),
|
||||
)?))
|
||||
}
|
||||
|
||||
fn sync_tree(directory: &File) -> 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?;
|
||||
if entry.file_name().to_bytes() != b"." && entry.file_name().to_bytes() != b".." {
|
||||
names.push(entry.file_name().to_owned());
|
||||
}
|
||||
}
|
||||
names
|
||||
};
|
||||
for name in names {
|
||||
let path_fd = openat2(
|
||||
directory,
|
||||
&name,
|
||||
OFlags::PATH | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||
Mode::empty(),
|
||||
secure_resolve(),
|
||||
)?;
|
||||
let kind = rustix::fs::FileType::from_raw_mode(rustix::fs::fstat(&path_fd)?.st_mode);
|
||||
if kind == rustix::fs::FileType::Directory {
|
||||
let child = open_directory_at(
|
||||
directory,
|
||||
Path::new(std::ffi::OsStr::from_bytes(name.to_bytes())),
|
||||
)?;
|
||||
sync_tree(&child)?;
|
||||
} else if kind == rustix::fs::FileType::RegularFile {
|
||||
File::from(openat2(
|
||||
directory,
|
||||
&name,
|
||||
OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||
Mode::empty(),
|
||||
secure_resolve(),
|
||||
)?)
|
||||
.sync_all()?;
|
||||
}
|
||||
}
|
||||
directory.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hash_parts(parts: &[&[u8]]) -> [u8; 32] {
|
||||
let mut hasher = Sha256::new();
|
||||
for part in parts {
|
||||
hasher.update((part.len() as u64).to_be_bytes());
|
||||
hasher.update(part);
|
||||
}
|
||||
hasher.finalize().into()
|
||||
}
|
||||
|
||||
fn stage_path(id: PrepareOperationId) -> PathBuf {
|
||||
Path::new("stages").join(hex(id.as_bytes()))
|
||||
}
|
||||
|
||||
fn artifact_path(id: PreparedArtifactId) -> PathBuf {
|
||||
Path::new("artifacts").join(hex(id.as_bytes()))
|
||||
}
|
||||
|
||||
fn quarantine_path(artifact: &PreparedArtifact) -> PathBuf {
|
||||
Path::new("quarantine").join(format!(
|
||||
"{}-{}-{}",
|
||||
hex(artifact.prepared_artifact_id.as_bytes()),
|
||||
artifact.device.get(),
|
||||
artifact.inode.get()
|
||||
))
|
||||
}
|
||||
|
||||
fn path_bytes(path: &Path) -> &[u8] {
|
||||
std::os::unix::ffi::OsStrExt::as_bytes(path.as_os_str())
|
||||
}
|
||||
|
||||
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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn secure_resolve() -> ResolveFlags {
|
||||
ResolveFlags::BENEATH | ResolveFlags::NO_SYMLINKS | ResolveFlags::NO_MAGICLINKS
|
||||
}
|
||||
442
crates/lazaret-prepared-lower/src/transport.rs
Normal file
442
crates/lazaret-prepared-lower/src/transport.rs
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
use std::{fs::OpenOptions, io::Write, os::unix::fs::PermissionsExt};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use oci_client::Reference;
|
||||
use oci_client::client::{Client, ClientConfig};
|
||||
use oci_client::manifest::{IMAGE_LAYER_GZIP_MEDIA_TYPE, IMAGE_LAYER_MEDIA_TYPE, OciImageManifest};
|
||||
use oci_client::secrets::RegistryAuth;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
const OCI_LAYER_ZSTD: &str = "application/vnd.oci.image.layer.v1.tar+zstd";
|
||||
const MAX_CONFIG_BYTES: usize = 16 * 1024 * 1024;
|
||||
const MAX_LAYER_BYTES: u64 = 32 * 1024 * 1024 * 1024;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Platform {
|
||||
pub os: String,
|
||||
pub architecture: String,
|
||||
pub variant: Option<String>,
|
||||
}
|
||||
|
||||
impl Platform {
|
||||
#[must_use]
|
||||
pub fn linux(architecture: impl Into<String>) -> Self {
|
||||
Self {
|
||||
os: "linux".to_owned(),
|
||||
architecture: architecture.into(),
|
||||
variant: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub enum RegistryCredentials {
|
||||
Anonymous,
|
||||
Basic { username: String, password: String },
|
||||
Bearer(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for RegistryCredentials {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Anonymous => formatter.write_str("Anonymous"),
|
||||
Self::Basic { username, .. } => formatter
|
||||
.debug_struct("Basic")
|
||||
.field("username", username)
|
||||
.field("password", &"[redacted]")
|
||||
.finish(),
|
||||
Self::Bearer(_) => formatter
|
||||
.debug_tuple("Bearer")
|
||||
.field(&"[redacted]")
|
||||
.finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TransportedImage {
|
||||
pub layout_path: PathBuf,
|
||||
pub source_identity: Vec<u8>,
|
||||
pub selected_manifest_digest: [u8; 32],
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct OciRegistryTransport;
|
||||
|
||||
impl OciRegistryTransport {
|
||||
/// Pulls one explicitly selected platform into a digest-verified private OCI layout.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Refuses invalid references, authentication failures, unsupported platforms, malformed
|
||||
/// descriptors, resource limits, and transport or filesystem errors.
|
||||
pub async fn pull(
|
||||
&self,
|
||||
reference: &str,
|
||||
credentials: &RegistryCredentials,
|
||||
platform: &Platform,
|
||||
layout_path: &Path,
|
||||
) -> Result<TransportedImage> {
|
||||
self.pull_with_config(
|
||||
reference,
|
||||
credentials,
|
||||
platform,
|
||||
layout_path,
|
||||
ClientConfig::default(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn pull_with_config(
|
||||
&self,
|
||||
reference: &str,
|
||||
credentials: &RegistryCredentials,
|
||||
platform: &Platform,
|
||||
layout_path: &Path,
|
||||
mut config: ClientConfig,
|
||||
) -> Result<TransportedImage> {
|
||||
validate_credentials(credentials)?;
|
||||
let reference = Reference::from_str(reference).context("invalid OCI image reference")?;
|
||||
let selected_platform = platform.clone();
|
||||
config.max_concurrent_download = 1;
|
||||
config.platform_resolver = Some(Box::new(move |entries| {
|
||||
entries
|
||||
.iter()
|
||||
.find(|entry| {
|
||||
entry.platform.as_ref().is_some_and(|candidate| {
|
||||
candidate.os.to_string() == selected_platform.os
|
||||
&& candidate.architecture.to_string() == selected_platform.architecture
|
||||
&& candidate.variant == selected_platform.variant
|
||||
})
|
||||
})
|
||||
.map(|entry| entry.digest.clone())
|
||||
}));
|
||||
let client = Client::new(config);
|
||||
let auth = match credentials {
|
||||
RegistryCredentials::Anonymous => RegistryAuth::Anonymous,
|
||||
RegistryCredentials::Basic { username, password } => {
|
||||
RegistryAuth::Basic(username.clone(), password.clone())
|
||||
}
|
||||
RegistryCredentials::Bearer(token) => RegistryAuth::Bearer(token.clone()),
|
||||
};
|
||||
let (manifest, manifest_digest, config_json) = client
|
||||
.pull_manifest_and_config(&reference, &auth)
|
||||
.await
|
||||
.context("pulling selected OCI manifest and config")?;
|
||||
if config_json.len() > MAX_CONFIG_BYTES {
|
||||
bail!("OCI config exceeds {MAX_CONFIG_BYTES} bytes");
|
||||
}
|
||||
verify_platform(&config_json, platform)?;
|
||||
validate_manifest(&manifest)?;
|
||||
create_layout(layout_path)?;
|
||||
let blobs = layout_path.join("blobs/sha256");
|
||||
write_verified_blob(&blobs, &manifest.config.digest, config_json.as_bytes())?;
|
||||
for descriptor in &manifest.layers {
|
||||
let size = u64::try_from(descriptor.size).context("negative OCI layer size")?;
|
||||
if size > MAX_LAYER_BYTES {
|
||||
bail!("OCI layer exceeds {MAX_LAYER_BYTES} bytes");
|
||||
}
|
||||
let digest_component = digest_component(&descriptor.digest)?;
|
||||
let path = blobs.join(digest_component);
|
||||
if path.exists() {
|
||||
require_existing_blob(&path, &descriptor.digest, size)?;
|
||||
continue;
|
||||
}
|
||||
let file = tokio::fs::OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.await?;
|
||||
if let Err(error) = client.pull_blob(&reference, descriptor, file).await {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
return Err(error)
|
||||
.with_context(|| format!("pulling OCI layer {}", descriptor.digest));
|
||||
}
|
||||
let observed = std::fs::metadata(&path)?.len();
|
||||
if observed != size {
|
||||
bail!("OCI layer {} size mismatch", descriptor.digest);
|
||||
}
|
||||
}
|
||||
let local_manifest = serde_json::to_vec(&manifest)?;
|
||||
let local_digest = format!("sha256:{}", hex(&Sha256::digest(&local_manifest)));
|
||||
write_verified_blob(&blobs, &local_digest, &local_manifest)?;
|
||||
write_layout_index(layout_path, &local_digest, local_manifest.len())?;
|
||||
let selected_manifest_digest = parse_sha256(&manifest_digest)?;
|
||||
let source_identity = format!(
|
||||
"oci-v1\0{}\0{}\0{manifest_digest}",
|
||||
reference.registry(),
|
||||
reference.repository()
|
||||
)
|
||||
.into_bytes();
|
||||
Ok(TransportedImage {
|
||||
layout_path: layout_path.to_owned(),
|
||||
source_identity,
|
||||
selected_manifest_digest,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ImageConfigPlatform {
|
||||
os: String,
|
||||
architecture: String,
|
||||
#[serde(default)]
|
||||
variant: Option<String>,
|
||||
}
|
||||
|
||||
fn verify_platform(config: &str, expected: &Platform) -> Result<()> {
|
||||
let observed: ImageConfigPlatform = serde_json::from_str(config)?;
|
||||
if observed.os != expected.os
|
||||
|| observed.architecture != expected.architecture
|
||||
|| observed.variant != expected.variant
|
||||
{
|
||||
bail!(
|
||||
"selected image platform {}/{} does not match requested {}/{}",
|
||||
observed.os,
|
||||
observed.architecture,
|
||||
expected.os,
|
||||
expected.architecture
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_credentials(credentials: &RegistryCredentials) -> Result<()> {
|
||||
match credentials {
|
||||
RegistryCredentials::Anonymous => Ok(()),
|
||||
RegistryCredentials::Basic { username, password }
|
||||
if !username.is_empty() && !password.is_empty() =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
RegistryCredentials::Bearer(token) if !token.is_empty() => Ok(()),
|
||||
RegistryCredentials::Basic { .. } | RegistryCredentials::Bearer(_) => {
|
||||
bail!("registry credentials are incomplete")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_manifest(manifest: &OciImageManifest) -> Result<()> {
|
||||
if manifest.schema_version != 2 || manifest.layers.len() > 256 {
|
||||
bail!("unsupported OCI manifest schema or layer count");
|
||||
}
|
||||
for descriptor in &manifest.layers {
|
||||
if !matches!(
|
||||
descriptor.media_type.as_str(),
|
||||
IMAGE_LAYER_MEDIA_TYPE | IMAGE_LAYER_GZIP_MEDIA_TYPE | OCI_LAYER_ZSTD
|
||||
) {
|
||||
bail!("unsupported OCI layer media type {}", descriptor.media_type);
|
||||
}
|
||||
digest_component(&descriptor.digest)?;
|
||||
u64::try_from(descriptor.size).context("negative OCI layer size")?;
|
||||
}
|
||||
digest_component(&manifest.config.digest)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_layout(path: &Path) -> Result<()> {
|
||||
std::fs::create_dir(path).context("creating private OCI layout")?;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?;
|
||||
std::fs::create_dir_all(path.join("blobs/sha256"))?;
|
||||
std::fs::set_permissions(path.join("blobs"), std::fs::Permissions::from_mode(0o700))?;
|
||||
std::fs::set_permissions(
|
||||
path.join("blobs/sha256"),
|
||||
std::fs::Permissions::from_mode(0o700),
|
||||
)?;
|
||||
std::fs::write(
|
||||
path.join("oci-layout"),
|
||||
b"{\"imageLayoutVersion\":\"1.0.0\"}\n",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_verified_blob(blobs: &Path, digest: &str, bytes: &[u8]) -> Result<()> {
|
||||
if parse_sha256(digest)? != Sha256::digest(bytes).as_slice() {
|
||||
bail!("OCI descriptor digest mismatch for {digest}");
|
||||
}
|
||||
let path = blobs.join(digest_component(digest)?);
|
||||
match OpenOptions::new().create_new(true).write(true).open(&path) {
|
||||
Ok(mut file) => file.write_all(bytes)?,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
require_existing_blob(&path, digest, bytes.len() as u64)?;
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_existing_blob(path: &Path, digest: &str, size: u64) -> Result<()> {
|
||||
let bytes = std::fs::read(path)?;
|
||||
if bytes.len() as u64 != size || parse_sha256(digest)? != Sha256::digest(&bytes).as_slice() {
|
||||
bail!("existing OCI blob does not match its descriptor");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LayoutIndex<'a> {
|
||||
schema_version: u8,
|
||||
manifests: [LayoutDescriptor<'a>; 1],
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LayoutDescriptor<'a> {
|
||||
media_type: &'a str,
|
||||
digest: &'a str,
|
||||
size: usize,
|
||||
}
|
||||
|
||||
fn write_layout_index(path: &Path, digest: &str, size: usize) -> Result<()> {
|
||||
let index = LayoutIndex {
|
||||
schema_version: 2,
|
||||
manifests: [LayoutDescriptor {
|
||||
media_type: "application/vnd.oci.image.manifest.v1+json",
|
||||
digest,
|
||||
size,
|
||||
}],
|
||||
};
|
||||
std::fs::write(path.join("index.json"), serde_json::to_vec(&index)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn digest_component(digest: &str) -> Result<&str> {
|
||||
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(component)
|
||||
}
|
||||
|
||||
fn parse_sha256(digest: &str) -> Result<[u8; 32]> {
|
||||
let component = digest_component(digest)?;
|
||||
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 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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
|
||||
use oci_client::client::ClientProtocol;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn refuses_mismatched_and_incomplete_platforms() {
|
||||
assert!(
|
||||
verify_platform(
|
||||
r#"{"os":"linux","architecture":"amd64"}"#,
|
||||
&Platform::linux("amd64")
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
verify_platform(
|
||||
r#"{"os":"linux","architecture":"arm64"}"#,
|
||||
&Platform::linux("amd64")
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
verify_platform(
|
||||
r#"{"os":"linux","architecture":"amd64","variant":"v2"}"#,
|
||||
&Platform::linux("amd64")
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(verify_platform(r#"{"architecture":"amd64"}"#, &Platform::linux("amd64")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_authentication_refuses_without_layout_or_secret_disclosure() {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.build()
|
||||
.unwrap();
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let result = runtime.block_on(OciRegistryTransport.pull_with_config(
|
||||
"registry.invalid/private/image:latest",
|
||||
&RegistryCredentials::Basic {
|
||||
username: "developer".to_owned(),
|
||||
password: String::new(),
|
||||
},
|
||||
&Platform::linux("amd64"),
|
||||
&temp.path().join("layout"),
|
||||
ClientConfig::default(),
|
||||
));
|
||||
let error = result.unwrap_err().to_string();
|
||||
assert!(error.contains("credentials are incomplete"));
|
||||
assert!(!error.contains("developer"));
|
||||
assert!(!temp.path().join("layout").exists());
|
||||
assert_eq!(
|
||||
format!(
|
||||
"{:?}",
|
||||
RegistryCredentials::Bearer("not-a-real-token".to_owned())
|
||||
),
|
||||
"Bearer(\"[redacted]\")"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_authentication_failure_refuses_before_layout_creation() {
|
||||
let listener = match TcpListener::bind("127.0.0.1:0") {
|
||||
Ok(listener) => listener,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => return,
|
||||
Err(error) => panic!("binding test registry: {error}"),
|
||||
};
|
||||
let address = listener.local_addr().unwrap();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut request = [0; 4096];
|
||||
assert!(stream.read(&mut request).unwrap() > 0);
|
||||
stream
|
||||
.write_all(
|
||||
b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let result = runtime.block_on(OciRegistryTransport.pull_with_config(
|
||||
&format!("{address}/private/image:latest"),
|
||||
&RegistryCredentials::Basic {
|
||||
username: "wrong".to_owned(),
|
||||
password: "wrong".to_owned(),
|
||||
},
|
||||
&Platform::linux("amd64"),
|
||||
&temp.path().join("layout"),
|
||||
ClientConfig {
|
||||
protocol: ClientProtocol::Http,
|
||||
..ClientConfig::default()
|
||||
},
|
||||
));
|
||||
assert!(result.is_err());
|
||||
assert!(!temp.path().join("layout").exists());
|
||||
server.join().unwrap();
|
||||
}
|
||||
}
|
||||
742
crates/lazaret-prepared-lower/tests/prepared_lower.rs
Normal file
742
crates/lazaret-prepared-lower/tests/prepared_lower.rs
Normal file
|
|
@ -0,0 +1,742 @@
|
|||
use std::fs::File;
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Barrier};
|
||||
use std::thread;
|
||||
|
||||
use flate2::Compression;
|
||||
use flate2::write::GzEncoder;
|
||||
use lazaret_core::identity::{
|
||||
DeviceId, HostPath, IdMapDigest, InodeId, PolicyId, PrepareOperationId, PreparedArtifactId,
|
||||
SelectedManifestDigest, SourceIdentity,
|
||||
};
|
||||
use lazaret_core::records::{PrepareOperation, PrepareStageState};
|
||||
use lazaret_prepared_lower::{
|
||||
IdMap, IdMapRange, PrepareRecovery, PrepareRequest, PreparedLowerStore, ReclaimOutcome,
|
||||
};
|
||||
use lazaret_storage_catalog::{Catalog, CatalogError};
|
||||
use serde_json::json;
|
||||
use sha2::{Digest, Sha256};
|
||||
use tar::{Builder, EntryType, Header};
|
||||
use tempfile::TempDir;
|
||||
|
||||
const OCI_TAR: &str = "application/vnd.oci.image.layer.v1.tar";
|
||||
const OCI_GZIP: &str = "application/vnd.oci.image.layer.v1.tar+gzip";
|
||||
const OCI_ZSTD: &str = "application/vnd.oci.image.layer.v1.tar+zstd";
|
||||
|
||||
struct Fixture {
|
||||
directory: TempDir,
|
||||
root: PathBuf,
|
||||
catalog: Catalog,
|
||||
store: PreparedLowerStore,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let directory = TempDir::new().unwrap();
|
||||
let root = directory.path().join("storage");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let catalog = Catalog::new(root.join("catalog.redb"), root.join("storage-catalog.lock"));
|
||||
catalog.initialize().unwrap();
|
||||
let store = PreparedLowerStore::initialize(&root, catalog.clone()).unwrap();
|
||||
Self {
|
||||
directory,
|
||||
root,
|
||||
catalog,
|
||||
store,
|
||||
}
|
||||
}
|
||||
|
||||
fn request(&self, operation: u8, layers: &[(Vec<u8>, &str)]) -> PrepareRequest {
|
||||
let (layout_path, manifest) = layout(self.directory.path(), operation, layers);
|
||||
PrepareRequest {
|
||||
operation_id: PrepareOperationId::new([operation; 32]),
|
||||
layout_path,
|
||||
source_identity: b"oci-v1\0registry.invalid\0fixture\0sha256:source".to_vec(),
|
||||
selected_manifest_digest: manifest,
|
||||
id_map: current_id_map(),
|
||||
}
|
||||
}
|
||||
|
||||
fn artifact_root(&self, artifact: &lazaret_core::records::PreparedArtifact) -> PathBuf {
|
||||
self.root.join(PathBuf::from(std::ffi::OsString::from_vec(
|
||||
artifact.path.as_bytes().to_vec(),
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Entry<'a> {
|
||||
path: &'a str,
|
||||
kind: EntryType,
|
||||
data: &'a [u8],
|
||||
target: Option<&'a str>,
|
||||
mode: u32,
|
||||
}
|
||||
|
||||
fn layer(entries: &[Entry<'_>]) -> Vec<u8> {
|
||||
let uid = rustix::process::geteuid().as_raw();
|
||||
let gid = rustix::process::getegid().as_raw();
|
||||
let mut bytes = Vec::new();
|
||||
{
|
||||
let mut builder = Builder::new(&mut bytes);
|
||||
for entry in entries {
|
||||
let mut header = Header::new_gnu();
|
||||
header.set_path(entry.path).unwrap();
|
||||
header.set_entry_type(entry.kind);
|
||||
header.set_size(entry.data.len() as u64);
|
||||
header.set_uid(u64::from(uid));
|
||||
header.set_gid(u64::from(gid));
|
||||
header.set_mode(entry.mode);
|
||||
header.set_mtime(1_600_000_123);
|
||||
if let Some(target) = entry.target {
|
||||
header.set_link_name(target).unwrap();
|
||||
}
|
||||
header.set_cksum();
|
||||
builder.append(&header, Cursor::new(entry.data)).unwrap();
|
||||
}
|
||||
builder.finish().unwrap();
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
fn xattr_layer() -> Vec<u8> {
|
||||
let mut bytes = Vec::new();
|
||||
{
|
||||
let mut builder = Builder::new(&mut bytes);
|
||||
builder
|
||||
.append_pax_extensions([("SCHILY.xattr.user.fixture", b"value".as_slice())])
|
||||
.unwrap();
|
||||
let uid = rustix::process::geteuid().as_raw();
|
||||
let gid = rustix::process::getegid().as_raw();
|
||||
let mut header = Header::new_gnu();
|
||||
header.set_path("payload").unwrap();
|
||||
header.set_entry_type(EntryType::Regular);
|
||||
header.set_size(1);
|
||||
header.set_uid(u64::from(uid));
|
||||
header.set_gid(u64::from(gid));
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
builder.append(&header, Cursor::new(b"x")).unwrap();
|
||||
builder.finish().unwrap();
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
fn compress(bytes: &[u8], media_type: &str) -> Vec<u8> {
|
||||
match media_type {
|
||||
OCI_TAR => bytes.to_vec(),
|
||||
OCI_GZIP => {
|
||||
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
|
||||
encoder.write_all(bytes).unwrap();
|
||||
encoder.finish().unwrap()
|
||||
}
|
||||
OCI_ZSTD => zstd::encode_all(bytes, 1).unwrap(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn layout(root: &Path, seed: u8, layers: &[(Vec<u8>, &str)]) -> (PathBuf, [u8; 32]) {
|
||||
let path = root.join(format!("layout-{seed}"));
|
||||
let blobs = path.join("blobs/sha256");
|
||||
std::fs::create_dir_all(&blobs).unwrap();
|
||||
let mut diff_ids = Vec::new();
|
||||
let descriptors: Vec<_> = layers
|
||||
.iter()
|
||||
.map(|(bytes, media_type)| {
|
||||
let uncompressed = match *media_type {
|
||||
OCI_GZIP => {
|
||||
let mut output = Vec::new();
|
||||
flate2::read::GzDecoder::new(bytes.as_slice())
|
||||
.read_to_end(&mut output)
|
||||
.unwrap();
|
||||
output
|
||||
}
|
||||
OCI_ZSTD => zstd::decode_all(bytes.as_slice()).unwrap(),
|
||||
_ => bytes.clone(),
|
||||
};
|
||||
diff_ids.push(format!("sha256:{}", hex(&Sha256::digest(&uncompressed))));
|
||||
let digest = format!("sha256:{}", hex(&Sha256::digest(bytes)));
|
||||
std::fs::write(blobs.join(&digest[7..]), bytes).unwrap();
|
||||
json!({"mediaType": media_type, "digest": digest, "size": bytes.len()})
|
||||
})
|
||||
.collect();
|
||||
let config = serde_json::to_vec(&json!({
|
||||
"architecture": "amd64",
|
||||
"os": "linux",
|
||||
"rootfs": {"type": "layers", "diff_ids": diff_ids}
|
||||
}))
|
||||
.unwrap();
|
||||
let config_digest = format!("sha256:{}", hex(&Sha256::digest(&config)));
|
||||
std::fs::write(blobs.join(&config_digest[7..]), &config).unwrap();
|
||||
let manifest = serde_json::to_vec(&json!({
|
||||
"schemaVersion": 2,
|
||||
"config": {"mediaType": "application/vnd.oci.image.config.v1+json", "digest": config_digest, "size": config.len()},
|
||||
"layers": descriptors
|
||||
}))
|
||||
.unwrap();
|
||||
let manifest_hash: [u8; 32] = Sha256::digest(&manifest).into();
|
||||
let manifest_digest = format!("sha256:{}", hex(&manifest_hash));
|
||||
std::fs::write(blobs.join(&manifest_digest[7..]), &manifest).unwrap();
|
||||
std::fs::write(
|
||||
path.join("index.json"),
|
||||
serde_json::to_vec(&json!({
|
||||
"schemaVersion": 2,
|
||||
"manifests": [{"mediaType": "application/vnd.oci.image.manifest.v1+json", "digest": manifest_digest, "size": manifest.len()}]
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
path.join("oci-layout"),
|
||||
b"{\"imageLayoutVersion\":\"1.0.0\"}\n",
|
||||
)
|
||||
.unwrap();
|
||||
(path, manifest_hash)
|
||||
}
|
||||
|
||||
fn current_id_map() -> IdMap {
|
||||
IdMap {
|
||||
uid: vec![IdMapRange {
|
||||
container_id: rustix::process::geteuid().as_raw(),
|
||||
namespace_id: rustix::process::geteuid().as_raw(),
|
||||
size: 1,
|
||||
}],
|
||||
gid: vec![IdMapRange {
|
||||
container_id: rustix::process::getegid().as_raw(),
|
||||
namespace_id: rustix::process::getegid().as_raw(),
|
||||
size: 1,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn lower_entries() -> Vec<Entry<'static>> {
|
||||
vec![
|
||||
Entry {
|
||||
path: "gone",
|
||||
kind: EntryType::Regular,
|
||||
data: b"gone",
|
||||
target: None,
|
||||
mode: 0o644,
|
||||
},
|
||||
Entry {
|
||||
path: "opaque",
|
||||
kind: EntryType::Directory,
|
||||
data: b"",
|
||||
target: None,
|
||||
mode: 0o755,
|
||||
},
|
||||
Entry {
|
||||
path: "opaque/old",
|
||||
kind: EntryType::Regular,
|
||||
data: b"old",
|
||||
target: None,
|
||||
mode: 0o644,
|
||||
},
|
||||
Entry {
|
||||
path: "directory-to-file",
|
||||
kind: EntryType::Directory,
|
||||
data: b"",
|
||||
target: None,
|
||||
mode: 0o755,
|
||||
},
|
||||
Entry {
|
||||
path: "directory-to-file/child",
|
||||
kind: EntryType::Regular,
|
||||
data: b"child",
|
||||
target: None,
|
||||
mode: 0o644,
|
||||
},
|
||||
Entry {
|
||||
path: "file-to-directory",
|
||||
kind: EntryType::Regular,
|
||||
data: b"file",
|
||||
target: None,
|
||||
mode: 0o644,
|
||||
},
|
||||
Entry {
|
||||
path: "target",
|
||||
kind: EntryType::Regular,
|
||||
data: b"linked",
|
||||
target: None,
|
||||
mode: 0o2755,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn upper_entries() -> Vec<Entry<'static>> {
|
||||
vec![
|
||||
Entry {
|
||||
path: ".wh.gone",
|
||||
kind: EntryType::Regular,
|
||||
data: b"",
|
||||
target: None,
|
||||
mode: 0o000,
|
||||
},
|
||||
Entry {
|
||||
path: "opaque/new",
|
||||
kind: EntryType::Regular,
|
||||
data: b"new",
|
||||
target: None,
|
||||
mode: 0o644,
|
||||
},
|
||||
Entry {
|
||||
path: "opaque/.wh..wh..opq",
|
||||
kind: EntryType::Regular,
|
||||
data: b"",
|
||||
target: None,
|
||||
mode: 0o000,
|
||||
},
|
||||
Entry {
|
||||
path: "directory-to-file",
|
||||
kind: EntryType::Regular,
|
||||
data: b"replacement",
|
||||
target: None,
|
||||
mode: 0o600,
|
||||
},
|
||||
Entry {
|
||||
path: "file-to-directory",
|
||||
kind: EntryType::Directory,
|
||||
data: b"",
|
||||
target: None,
|
||||
mode: 0o755,
|
||||
},
|
||||
Entry {
|
||||
path: "file-to-directory/new",
|
||||
kind: EntryType::Regular,
|
||||
data: b"new",
|
||||
target: None,
|
||||
mode: 0o644,
|
||||
},
|
||||
Entry {
|
||||
path: "symbolic",
|
||||
kind: EntryType::Symlink,
|
||||
data: b"",
|
||||
target: Some("target"),
|
||||
mode: 0o777,
|
||||
},
|
||||
Entry {
|
||||
path: "hard",
|
||||
kind: EntryType::Link,
|
||||
data: b"",
|
||||
target: Some("target"),
|
||||
mode: 0o644,
|
||||
},
|
||||
Entry {
|
||||
path: "forward",
|
||||
kind: EntryType::Link,
|
||||
data: b"",
|
||||
target: Some("later"),
|
||||
mode: 0o644,
|
||||
},
|
||||
Entry {
|
||||
path: "later",
|
||||
kind: EntryType::Regular,
|
||||
data: b"forward",
|
||||
target: None,
|
||||
mode: 0o4755,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_corpus_preserves_supported_changesets_and_read_only_lower_use() {
|
||||
let fixture = Fixture::new();
|
||||
let first = layer(&lower_entries());
|
||||
let second = layer(&upper_entries());
|
||||
let request = fixture.request(
|
||||
1,
|
||||
&[
|
||||
(compress(&first, OCI_GZIP), OCI_GZIP),
|
||||
(compress(&second, OCI_ZSTD), OCI_ZSTD),
|
||||
],
|
||||
);
|
||||
let artifact = fixture.store.prepare(&request).unwrap();
|
||||
let root = fixture.artifact_root(&artifact);
|
||||
assert!(!root.join("gone").exists());
|
||||
assert!(!root.join("opaque/old").exists());
|
||||
assert_eq!(std::fs::read(root.join("opaque/new")).unwrap(), b"new");
|
||||
assert_eq!(
|
||||
std::fs::read(root.join("directory-to-file")).unwrap(),
|
||||
b"replacement"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(root.join("file-to-directory/new")).unwrap(),
|
||||
b"new"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_link(root.join("symbolic")).unwrap(),
|
||||
Path::new("target")
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::metadata(root.join("hard")).unwrap().ino(),
|
||||
std::fs::metadata(root.join("target")).unwrap().ino()
|
||||
);
|
||||
assert_eq!(std::fs::read(root.join("forward")).unwrap(), b"forward");
|
||||
assert_eq!(
|
||||
std::fs::metadata(root.join("later"))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o7777,
|
||||
0o4755
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(&request.layout_path).unwrap();
|
||||
let mut lower = File::open(root.join("directory-to-file")).unwrap();
|
||||
let mut bytes = Vec::new();
|
||||
lower.read_to_end(&mut bytes).unwrap();
|
||||
assert_eq!(bytes, b"replacement");
|
||||
assert!(
|
||||
fixture
|
||||
.root
|
||||
.join("stages")
|
||||
.read_dir()
|
||||
.unwrap()
|
||||
.next()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_opaque_whiteout_removes_only_lower_layer_entries() {
|
||||
let fixture = Fixture::new();
|
||||
let lower = layer(&[Entry {
|
||||
path: "old",
|
||||
kind: EntryType::Regular,
|
||||
data: b"old",
|
||||
target: None,
|
||||
mode: 0o644,
|
||||
}]);
|
||||
let upper = layer(&[
|
||||
Entry {
|
||||
path: "new",
|
||||
kind: EntryType::Regular,
|
||||
data: b"new",
|
||||
target: None,
|
||||
mode: 0o644,
|
||||
},
|
||||
Entry {
|
||||
path: ".wh..wh..opq",
|
||||
kind: EntryType::Regular,
|
||||
data: b"",
|
||||
target: None,
|
||||
mode: 0o000,
|
||||
},
|
||||
]);
|
||||
let request = fixture.request(2, &[(lower, OCI_TAR), (upper, OCI_TAR)]);
|
||||
let artifact = fixture.store.prepare(&request).unwrap();
|
||||
let root = fixture.artifact_root(&artifact);
|
||||
assert!(!root.join("old").exists());
|
||||
assert_eq!(std::fs::read(root.join("new")).unwrap(), b"new");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_and_corrupt_layers_refuse_without_publication() {
|
||||
let cases = [
|
||||
layer(&[Entry {
|
||||
path: "pipe",
|
||||
kind: EntryType::Fifo,
|
||||
data: b"",
|
||||
target: None,
|
||||
mode: 0o644,
|
||||
}]),
|
||||
layer(&[Entry {
|
||||
path: "escape",
|
||||
kind: EntryType::Link,
|
||||
data: b"",
|
||||
target: Some("../outside"),
|
||||
mode: 0o644,
|
||||
}]),
|
||||
xattr_layer(),
|
||||
];
|
||||
for (index, bytes) in cases.into_iter().enumerate() {
|
||||
let fixture = Fixture::new();
|
||||
let request = fixture.request(u8::try_from(index).unwrap() + 10, &[(bytes, OCI_TAR)]);
|
||||
assert!(fixture.store.prepare(&request).is_err());
|
||||
let snapshot = fixture.catalog.snapshot().unwrap();
|
||||
assert!(snapshot.prepared_artifacts.is_empty());
|
||||
assert!(snapshot.prepare_operations.is_empty());
|
||||
assert!(
|
||||
fixture
|
||||
.root
|
||||
.join("stages")
|
||||
.read_dir()
|
||||
.unwrap()
|
||||
.next()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
let fixture = Fixture::new();
|
||||
let bytes = layer(&[Entry {
|
||||
path: "file",
|
||||
kind: EntryType::Regular,
|
||||
data: b"content",
|
||||
target: None,
|
||||
mode: 0o644,
|
||||
}]);
|
||||
let request = fixture.request(20, &[(bytes, OCI_TAR)]);
|
||||
let blob = request
|
||||
.layout_path
|
||||
.join("blobs/sha256")
|
||||
.read_dir()
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().path())
|
||||
.find(|path| std::fs::metadata(path).unwrap().len() > 1024)
|
||||
.unwrap();
|
||||
OpenOptionsExt::append_byte(&blob);
|
||||
assert!(fixture.store.prepare(&request).is_err());
|
||||
assert!(
|
||||
fixture
|
||||
.catalog
|
||||
.snapshot()
|
||||
.unwrap()
|
||||
.prepared_artifacts
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
struct OpenOptionsExt;
|
||||
|
||||
impl OpenOptionsExt {
|
||||
fn append_byte(path: &Path) {
|
||||
let mut file = std::fs::OpenOptions::new().append(true).open(path).unwrap();
|
||||
file.write_all(b"x").unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_expansion_layer_is_disk_spooled_and_published() {
|
||||
let fixture = Fixture::new();
|
||||
let payload = vec![0u8; 32 * 1024 * 1024];
|
||||
let tar = layer(&[Entry {
|
||||
path: "expanded",
|
||||
kind: EntryType::Regular,
|
||||
data: &payload,
|
||||
target: None,
|
||||
mode: 0o600,
|
||||
}]);
|
||||
let compressed = compress(&tar, OCI_GZIP);
|
||||
assert!(compressed.len() * 100 < payload.len());
|
||||
let request = fixture.request(30, &[(compressed, OCI_GZIP)]);
|
||||
let artifact = fixture.store.prepare(&request).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::metadata(fixture.artifact_root(&artifact).join("expanded"))
|
||||
.unwrap()
|
||||
.len(),
|
||||
payload.len() as u64
|
||||
);
|
||||
assert!(
|
||||
fixture
|
||||
.root
|
||||
.join("stages")
|
||||
.read_dir()
|
||||
.unwrap()
|
||||
.next()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_publication_reuses_one_immutable_artifact() {
|
||||
let fixture = Fixture::new();
|
||||
let bytes = layer(&[Entry {
|
||||
path: "payload",
|
||||
kind: EntryType::Regular,
|
||||
data: b"same",
|
||||
target: None,
|
||||
mode: 0o644,
|
||||
}]);
|
||||
let first = fixture.request(40, &[(bytes.clone(), OCI_TAR)]);
|
||||
let mut second = PrepareRequest {
|
||||
operation_id: PrepareOperationId::new([41; 32]),
|
||||
..first.clone()
|
||||
};
|
||||
second.layout_path = first.layout_path.clone();
|
||||
let store = Arc::new(fixture.store.clone());
|
||||
let barrier = Arc::new(Barrier::new(3));
|
||||
let first_handle = {
|
||||
let store = Arc::clone(&store);
|
||||
let barrier = Arc::clone(&barrier);
|
||||
thread::spawn(move || {
|
||||
barrier.wait();
|
||||
store.prepare(&first)
|
||||
})
|
||||
};
|
||||
let second_handle = {
|
||||
let store = Arc::clone(&store);
|
||||
let barrier = Arc::clone(&barrier);
|
||||
thread::spawn(move || {
|
||||
barrier.wait();
|
||||
store.prepare(&second)
|
||||
})
|
||||
};
|
||||
barrier.wait();
|
||||
let first = first_handle.join().unwrap().unwrap();
|
||||
let second = second_handle.join().unwrap().unwrap();
|
||||
assert_eq!(first, second);
|
||||
let snapshot = fixture.catalog.snapshot().unwrap();
|
||||
assert_eq!(snapshot.prepared_artifacts, vec![first]);
|
||||
assert!(snapshot.prepare_operations.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publication_during_reclaim_preserves_replacement_and_deletes_old_inode() {
|
||||
let fixture = Fixture::new();
|
||||
let bytes = layer(&[Entry {
|
||||
path: "payload",
|
||||
kind: EntryType::Regular,
|
||||
data: b"same",
|
||||
target: None,
|
||||
mode: 0o644,
|
||||
}]);
|
||||
let first_request = fixture.request(50, &[(bytes, OCI_TAR)]);
|
||||
let first = fixture.store.prepare(&first_request).unwrap();
|
||||
let reclaiming = fixture.catalog.begin_reclaim(&first).unwrap();
|
||||
let replacement_request = PrepareRequest {
|
||||
operation_id: PrepareOperationId::new([51; 32]),
|
||||
..first_request
|
||||
};
|
||||
let barrier = Arc::new(Barrier::new(3));
|
||||
let store = Arc::new(fixture.store.clone());
|
||||
let reclaim_handle = {
|
||||
let barrier = Arc::clone(&barrier);
|
||||
let store = Arc::clone(&store);
|
||||
thread::spawn(move || {
|
||||
barrier.wait();
|
||||
store.resume_reclaim(&reclaiming)
|
||||
})
|
||||
};
|
||||
let publish_handle = {
|
||||
let barrier = Arc::clone(&barrier);
|
||||
let store = Arc::clone(&store);
|
||||
thread::spawn(move || {
|
||||
barrier.wait();
|
||||
store.prepare(&replacement_request)
|
||||
})
|
||||
};
|
||||
barrier.wait();
|
||||
let reclaim = reclaim_handle.join().unwrap().unwrap();
|
||||
let replacement = publish_handle.join().unwrap().unwrap();
|
||||
assert!(matches!(
|
||||
reclaim,
|
||||
ReclaimOutcome::Removed | ReclaimOutcome::ReplacementPreserved
|
||||
));
|
||||
assert_ne!(replacement.inode, first.inode);
|
||||
assert_eq!(
|
||||
fixture.catalog.snapshot().unwrap().prepared_artifacts,
|
||||
vec![replacement]
|
||||
);
|
||||
assert!(
|
||||
fixture
|
||||
.root
|
||||
.join("quarantine")
|
||||
.read_dir()
|
||||
.unwrap()
|
||||
.next()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_deletes_only_exact_owned_stage_and_preserves_foreign_stage() {
|
||||
let fixture = Fixture::new();
|
||||
let operation_id = PrepareOperationId::new([60; 32]);
|
||||
let stage = Path::new("stages").join(hex(operation_id.as_bytes()));
|
||||
let intent = PrepareOperation {
|
||||
operation_id,
|
||||
expected_artifact_id: PreparedArtifactId::new([61; 32]),
|
||||
source_identity: SourceIdentity::new(b"source".to_vec()),
|
||||
selected_manifest_digest: SelectedManifestDigest::new([62; 32]),
|
||||
policy_id: PolicyId::new([63; 32]),
|
||||
id_map_digest: IdMapDigest::new([64; 32]),
|
||||
stage_path: HostPath::new(stage.as_os_str().as_encoded_bytes().to_vec()),
|
||||
stage_state: PrepareStageState::Intent,
|
||||
device: None,
|
||||
inode: None,
|
||||
};
|
||||
fixture.catalog.record_prepare(&intent).unwrap();
|
||||
std::fs::create_dir(fixture.root.join(&stage)).unwrap();
|
||||
std::fs::write(fixture.root.join(&stage).join("foreign"), b"keep").unwrap();
|
||||
assert!(fixture.store.recover_prepare(&intent).is_err());
|
||||
assert!(fixture.root.join(&stage).join("foreign").exists());
|
||||
std::fs::remove_dir_all(fixture.root.join(&stage)).unwrap();
|
||||
assert_eq!(
|
||||
fixture.store.recover_prepare(&intent).unwrap(),
|
||||
PrepareRecovery::ClearedUnmaterializedIntent
|
||||
);
|
||||
|
||||
let owned_id = PrepareOperationId::new([65; 32]);
|
||||
let owned_stage = Path::new("stages").join(hex(owned_id.as_bytes()));
|
||||
let owned_intent = PrepareOperation {
|
||||
operation_id: owned_id,
|
||||
stage_path: HostPath::new(owned_stage.as_os_str().as_encoded_bytes().to_vec()),
|
||||
..intent
|
||||
};
|
||||
fixture.catalog.record_prepare(&owned_intent).unwrap();
|
||||
std::fs::create_dir(fixture.root.join(&owned_stage)).unwrap();
|
||||
std::fs::write(fixture.root.join(&owned_stage).join("partial"), b"partial").unwrap();
|
||||
let metadata = std::fs::metadata(fixture.root.join(&owned_stage)).unwrap();
|
||||
let owned = PrepareOperation {
|
||||
stage_state: PrepareStageState::Owned,
|
||||
device: Some(DeviceId::new(metadata.dev())),
|
||||
inode: Some(InodeId::new(metadata.ino())),
|
||||
..owned_intent.clone()
|
||||
};
|
||||
fixture
|
||||
.catalog
|
||||
.bind_prepare_stage(&owned_intent, &owned)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
fixture.store.recover_prepare(&owned).unwrap(),
|
||||
PrepareRecovery::RemovedOwnedStage
|
||||
);
|
||||
assert!(!fixture.root.join(&owned_stage).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reclaim_refuses_forged_identity_and_leases() {
|
||||
let fixture = Fixture::new();
|
||||
let bytes = layer(&[Entry {
|
||||
path: "payload",
|
||||
kind: EntryType::Regular,
|
||||
data: b"same",
|
||||
target: None,
|
||||
mode: 0o644,
|
||||
}]);
|
||||
let artifact = fixture
|
||||
.store
|
||||
.prepare(&fixture.request(70, &[(bytes, OCI_TAR)]))
|
||||
.unwrap();
|
||||
let lease = lazaret_core::records::ArtifactLease {
|
||||
prepared_artifact_id: artifact.prepared_artifact_id,
|
||||
domain_id: lazaret_core::identity::DomainId::new([71; 32]),
|
||||
generation_id: lazaret_core::identity::GenerationId::new(1),
|
||||
};
|
||||
fixture.catalog.acquire_lease(&lease).unwrap();
|
||||
assert!(matches!(
|
||||
fixture.store.reclaim(&artifact),
|
||||
Err(error)
|
||||
if matches!(error.downcast_ref::<CatalogError>(), Some(CatalogError::LeasesPresent))
|
||||
));
|
||||
fixture.catalog.release_lease(&lease).unwrap();
|
||||
let mut forged = artifact.clone();
|
||||
forged.inode = InodeId::new(artifact.inode.get() + 1);
|
||||
assert!(fixture.store.reclaim(&forged).is_err());
|
||||
assert!(fixture.artifact_root(&artifact).exists());
|
||||
}
|
||||
|
||||
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
|
||||
},
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue