Implement verified OCI prepared-lower lifecycle

This commit is contained in:
ms2k 2026-07-15 15:22:02 +09:00
commit 5458460717
17 changed files with 4973 additions and 273 deletions

1821
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,7 @@ members = [
"crates/lazaret-host-coordinator", "crates/lazaret-host-coordinator",
"crates/lazaret-linux", "crates/lazaret-linux",
"crates/lazaret-network-sidecar", "crates/lazaret-network-sidecar",
"crates/lazaret-prepared-lower",
"crates/lazaret-runtime-supervisor", "crates/lazaret-runtime-supervisor",
"crates/lazaret-storage-catalog", "crates/lazaret-storage-catalog",
] ]
@ -18,14 +19,20 @@ rust-version = "1.90"
license = "Apache-2.0" license = "Apache-2.0"
[workspace.dependencies] [workspace.dependencies]
anyhow = "=1.0.103"
flate2 = "=1.1.9"
oci-client = { version = "=0.17.0", default-features = false, features = ["rustls-tls"] }
oci-spec = { version = "=0.10.0", default-features = false, features = ["runtime"] } oci-spec = { version = "=0.10.0", default-features = false, features = ["runtime"] }
postcard = { version = "=1.1.3", default-features = false, features = ["use-std"] } postcard = { version = "=1.1.3", default-features = false, features = ["use-std"] }
redb = "=4.1.0" redb = "=4.1.0"
serde = { version = "=1.0.228", features = ["derive"] } serde = { version = "=1.0.228", features = ["derive"] }
serde_json = "=1.0.150" serde_json = "=1.0.150"
rustix = { version = "=1.1.4", features = ["event", "fs", "net", "pipe", "process", "thread"] } rustix = { version = "=1.1.4", features = ["event", "fs", "net", "pipe", "process", "thread", "time"] }
sha2 = "=0.11.0" sha2 = "=0.11.0"
tar = "=0.4.46"
tokio = { version = "=1.52.3", features = ["fs", "io-util", "process", "rt"] }
toml = { version = "=1.1.3", default-features = false, features = ["parse", "serde", "std"] } toml = { version = "=1.1.3", default-features = false, features = ["parse", "serde", "std"] }
zstd = "=0.13.3"
[workspace.lints.rust] [workspace.lints.rust]
unsafe_code = "forbid" unsafe_code = "forbid"

View file

@ -10,14 +10,14 @@ use super::{
NetworkReadinessEvidence, NetworkReadinessIdentity, Participant, ParticipantOwnership, NetworkReadinessEvidence, NetworkReadinessIdentity, Participant, ParticipantOwnership,
ParticipantState, PermittedDestinationObservation, PersistedHostProcessOwnership, ParticipantState, PermittedDestinationObservation, PersistedHostProcessOwnership,
PidfsProcessAuthorityV1, PidfsProcessIdentityV1, PolicyAction, PolicyDelivery, PrefixRule, PidfsProcessAuthorityV1, PidfsProcessIdentityV1, PolicyAction, PolicyDelivery, PrefixRule,
PrepareOperation, PreparedArtifact, PreparedLowerOwnership, ProcessOwnershipCommitAck, PrepareOperation, PrepareStageState, PreparedArtifact, PreparedLowerOwnership,
ProcessOwnershipMessageVersion, ProtocolCredential, ProtocolCredentialRole, ProcessOwnershipCommitAck, ProcessOwnershipMessageVersion, ProtocolCredential,
ProtocolPublicationIdentity, PtyIdentity, PublicationOwnership, ResolvedAutoApprovalRule, ProtocolCredentialRole, ProtocolPublicationIdentity, PtyIdentity, PublicationOwnership,
ResolvedVolume, Resolver, ResultStream, SandboxCredentialBundle, SandboxOwnership, Session, ResolvedAutoApprovalRule, ResolvedVolume, Resolver, ResultStream, SandboxCredentialBundle,
SessionBinding, SessionClose, SessionExit, SessionExitOutcome, SessionRequestCapability, SandboxOwnership, Session, SessionBinding, SessionClose, SessionExit, SessionExitOutcome,
SessionTerminal, SessionTerminalEvent, SessionTerminalMessage, SessionTerminalState, SessionRequestCapability, SessionTerminal, SessionTerminalEvent, SessionTerminalMessage,
SignedProtocolRecord, StagedProcessOwnership, StateAnchorOwnership, StorageCatalog, SessionTerminalState, SignedProtocolRecord, StagedProcessOwnership, StateAnchorOwnership,
StoreMetadata, TerminalInput, TerminalOutput, TerminalResize, TerminalSignal, StorageCatalog, StoreMetadata, TerminalInput, TerminalOutput, TerminalResize, TerminalSignal,
TerminalSignalKind, TlsCredential, TlsRole, TrustManifest, TrustedHostConfigurationV1, TerminalSignalKind, TlsCredential, TlsRole, TrustManifest, TrustedHostConfigurationV1,
}; };
@ -127,7 +127,8 @@ schema_types!(
"ResolvedVolume" => ResolvedVolume, "ResolvedVolume" => ResolvedVolume,
"ResolvedAutoApprovalRule" => ResolvedAutoApprovalRule, "ResolvedAutoApprovalRule" => ResolvedAutoApprovalRule,
"DomainStore" => DomainStore, "DomainStore" => DomainStore,
"StorageCatalog" => StorageCatalog; "StorageCatalog" => StorageCatalog,
"PrepareStageState" => PrepareStageState;
); );
pub const DECLARED_RUNTIME_SCHEMA_TYPE_NAMES: &[&str] = &[ pub const DECLARED_RUNTIME_SCHEMA_TYPE_NAMES: &[&str] = &[

View file

@ -35,9 +35,29 @@ pub struct PrepareOperation {
pub operation_id: PrepareOperationId, pub operation_id: PrepareOperationId,
pub expected_artifact_id: PreparedArtifactId, pub expected_artifact_id: PreparedArtifactId,
pub source_identity: SourceIdentity, pub source_identity: SourceIdentity,
pub selected_manifest_digest: SelectedManifestDigest,
pub policy_id: PolicyId,
pub id_map_digest: IdMapDigest,
pub stage_path: HostPath, pub stage_path: HostPath,
pub device: DeviceId, pub stage_state: PrepareStageState,
pub inode: InodeId, pub device: Option<DeviceId>,
pub inode: Option<InodeId>,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum PrepareStageState {
Intent,
Owned,
}
impl PrepareOperation {
#[must_use]
pub const fn has_valid_evidence_shape(&self) -> bool {
matches!(
(self.stage_state, self.device, self.inode),
(PrepareStageState::Intent, None, None) | (PrepareStageState::Owned, Some(_), Some(_))
)
}
} }
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]

View file

@ -347,12 +347,17 @@ fn every_schema_shape_round_trips_and_rejects_malformed_or_unknown_versions() {
operation_id: PrepareOperationId::new(opaque(68)), operation_id: PrepareOperationId::new(opaque(68)),
expected_artifact_id: PreparedArtifactId::new(opaque(24)), expected_artifact_id: PreparedArtifactId::new(opaque(24)),
source_identity: SourceIdentity::new(vec![25, 26]), source_identity: SourceIdentity::new(vec![25, 26]),
selected_manifest_digest: SelectedManifestDigest::new(opaque(27)),
policy_id: PolicyId::new(opaque(28)),
id_map_digest: IdMapDigest::new(opaque(29)),
stage_path: HostPath::new(b"stage".to_vec()), stage_path: HostPath::new(b"stage".to_vec()),
device: DeviceId::new(69), stage_state: PrepareStageState::Owned,
inode: InodeId::new(70), device: Some(DeviceId::new(69)),
inode: Some(InodeId::new(70)),
}, },
&mut seen, &mut seen,
); );
verify_shape(PrepareStageState::Owned, &mut seen);
verify_shape( verify_shape(
AddressFamilyDisposition { AddressFamilyDisposition {
family: AddressFamily::Ipv6, family: AddressFamily::Ipv6,

View 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

View 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(&current, name, Mode::from_raw_mode(0o700)) {
Ok(()) | Err(Errno::EXIST) => {}
Err(error) => return Err(error.into()),
}
current = open_component(&current, 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(&current, 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, &timestamps(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,
&timestamps(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)
}

View 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};

View 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
}

View 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();
}
}

View 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
},
)
}

View file

@ -10,8 +10,8 @@ use std::time::{Duration, Instant};
use lazaret_core::codec::{self, CodecError}; use lazaret_core::codec::{self, CodecError};
use lazaret_core::identity::{PrepareOperationId, PreparedArtifactId}; use lazaret_core::identity::{PrepareOperationId, PreparedArtifactId};
use lazaret_core::records::{ use lazaret_core::records::{
ArtifactLease, ArtifactState, PrepareOperation, PreparedArtifact, StorageCatalog, ArtifactLease, ArtifactState, PrepareOperation, PrepareStageState, PreparedArtifact,
StorageCatalogMetadata, StorageCatalog, StorageCatalogMetadata,
}; };
use lazaret_linux::filesystem::{ExclusiveFileLock, try_exclusive_flock}; use lazaret_linux::filesystem::{ExclusiveFileLock, try_exclusive_flock};
use redb::{ use redb::{
@ -106,6 +106,11 @@ impl Catalog {
/// ///
/// Returns an error if the operation identity already exists or the transaction fails. /// Returns an error if the operation identity already exists or the transaction fails.
pub fn record_prepare(&self, operation: &PrepareOperation) -> Result<(), CatalogError> { pub fn record_prepare(&self, operation: &PrepareOperation) -> Result<(), CatalogError> {
if operation.stage_state != PrepareStageState::Intent
|| !operation.has_valid_evidence_shape()
{
return Err(CatalogError::InvalidPrepareState);
}
self.write(|transaction| { self.write(|transaction| {
let key = operation_key(operation.operation_id); let key = operation_key(operation.operation_id);
let mut table = transaction let mut table = transaction
@ -120,6 +125,44 @@ impl Catalog {
}) })
} }
/// Binds exact physical stage ownership to an already durable preparation intent.
///
/// # Errors
///
/// Returns an error for nonmatching identity, invalid phase/evidence, or transaction failure.
pub fn bind_prepare_stage(
&self,
expected_intent: &PrepareOperation,
owned_stage: &PrepareOperation,
) -> Result<(), CatalogError> {
if expected_intent.stage_state != PrepareStageState::Intent
|| owned_stage.stage_state != PrepareStageState::Owned
|| !expected_intent.has_valid_evidence_shape()
|| !owned_stage.has_valid_evidence_shape()
|| expected_intent.operation_id != owned_stage.operation_id
|| expected_intent.expected_artifact_id != owned_stage.expected_artifact_id
|| expected_intent.source_identity != owned_stage.source_identity
|| expected_intent.selected_manifest_digest != owned_stage.selected_manifest_digest
|| expected_intent.policy_id != owned_stage.policy_id
|| expected_intent.id_map_digest != owned_stage.id_map_digest
|| expected_intent.stage_path != owned_stage.stage_path
{
return Err(CatalogError::InvalidPrepareState);
}
self.write(|transaction| {
let key = operation_key(expected_intent.operation_id);
let mut table = transaction
.open_table(PREPARES)
.map_err(CatalogError::redb)?;
require_exact(&table, &key, expected_intent, CatalogRow::PrepareOperation)?;
let encoded = encode(owned_stage)?;
table
.insert(key.as_slice(), encoded.as_slice())
.map_err(CatalogError::redb)?;
Ok(())
})
}
/// Removes only an exactly matching preparation ownership row. /// Removes only an exactly matching preparation ownership row.
/// ///
/// # Errors /// # Errors
@ -148,11 +191,19 @@ impl Catalog {
expected_operation: &PrepareOperation, expected_operation: &PrepareOperation,
artifact: &PreparedArtifact, artifact: &PreparedArtifact,
) -> Result<(), CatalogError> { ) -> Result<(), CatalogError> {
if artifact.state != ArtifactState::Available { if artifact.state != ArtifactState::Available
|| expected_operation.stage_state != PrepareStageState::Owned
|| !expected_operation.has_valid_evidence_shape()
{
return Err(CatalogError::InvalidArtifactState); return Err(CatalogError::InvalidArtifactState);
} }
if artifact.prepared_artifact_id != expected_operation.expected_artifact_id if artifact.prepared_artifact_id != expected_operation.expected_artifact_id
|| artifact.source_identity != expected_operation.source_identity || artifact.source_identity != expected_operation.source_identity
|| artifact.selected_manifest_digest != expected_operation.selected_manifest_digest
|| artifact.policy_id != expected_operation.policy_id
|| artifact.id_map_digest != expected_operation.id_map_digest
|| expected_operation.device != Some(artifact.device)
|| expected_operation.inode != Some(artifact.inode)
{ {
return Err(CatalogError::OwnershipMismatch { return Err(CatalogError::OwnershipMismatch {
row: CatalogRow::PrepareOperation, row: CatalogRow::PrepareOperation,
@ -196,6 +247,82 @@ impl Catalog {
}) })
} }
/// Atomically replaces an exact reclaiming generation and consumes its prepare ownership.
///
/// # Errors
///
/// Returns an error for mismatched old/new ownership, invalid state, leases, or transaction
/// failure.
pub fn publish_replacement(
&self,
expected_operation: &PrepareOperation,
expected_reclaiming: &PreparedArtifact,
replacement: &PreparedArtifact,
) -> Result<(), CatalogError> {
if expected_operation.stage_state != PrepareStageState::Owned
|| !expected_operation.has_valid_evidence_shape()
|| expected_reclaiming.state != ArtifactState::Reclaiming
|| replacement.state != ArtifactState::Available
|| replacement.prepared_artifact_id != expected_reclaiming.prepared_artifact_id
|| replacement.prepared_artifact_id != expected_operation.expected_artifact_id
|| replacement.source_identity != expected_operation.source_identity
|| replacement.selected_manifest_digest != expected_operation.selected_manifest_digest
|| replacement.policy_id != expected_operation.policy_id
|| replacement.id_map_digest != expected_operation.id_map_digest
|| expected_operation.device != Some(replacement.device)
|| expected_operation.inode != Some(replacement.inode)
|| replacement.source_identity != expected_reclaiming.source_identity
|| replacement.selected_manifest_digest != expected_reclaiming.selected_manifest_digest
|| replacement.policy_id != expected_reclaiming.policy_id
|| replacement.id_map_digest != expected_reclaiming.id_map_digest
|| replacement.path != expected_reclaiming.path
{
return Err(CatalogError::InvalidPrepareState);
}
self.write(|transaction| {
let prepare_key = operation_key(expected_operation.operation_id);
let artifact_key = artifact_key(replacement.prepared_artifact_id);
{
let prepares = transaction
.open_table(PREPARES)
.map_err(CatalogError::redb)?;
require_exact(
&prepares,
&prepare_key,
expected_operation,
CatalogRow::PrepareOperation,
)?;
let artifacts = transaction
.open_table(ARTIFACTS)
.map_err(CatalogError::redb)?;
require_exact(
&artifacts,
&artifact_key,
expected_reclaiming,
CatalogRow::PreparedArtifact,
)?;
}
if artifact_has_lease(transaction, replacement.prepared_artifact_id)? {
return Err(CatalogError::LeasesPresent);
}
let encoded = encode(replacement)?;
let mut artifacts = transaction
.open_table(ARTIFACTS)
.map_err(CatalogError::redb)?;
artifacts
.insert(artifact_key.as_slice(), encoded.as_slice())
.map_err(CatalogError::redb)?;
drop(artifacts);
let mut prepares = transaction
.open_table(PREPARES)
.map_err(CatalogError::redb)?;
prepares
.remove(prepare_key.as_slice())
.map_err(CatalogError::redb)?;
Ok(())
})
}
/// Inserts one exact Domain-generation lease for an available artifact. /// Inserts one exact Domain-generation lease for an available artifact.
/// ///
/// # Errors /// # Errors
@ -570,6 +697,7 @@ pub enum CatalogError {
AlreadyExists { row: CatalogRow }, AlreadyExists { row: CatalogRow },
OwnershipMismatch { row: CatalogRow }, OwnershipMismatch { row: CatalogRow },
InvalidArtifactState, InvalidArtifactState,
InvalidPrepareState,
ArtifactUnavailable, ArtifactUnavailable,
LeasesPresent, LeasesPresent,
} }
@ -598,6 +726,9 @@ impl fmt::Display for CatalogError {
Self::InvalidArtifactState => { Self::InvalidArtifactState => {
formatter.write_str("artifact state is invalid for operation") formatter.write_str("artifact state is invalid for operation")
} }
Self::InvalidPrepareState => {
formatter.write_str("prepare phase or physical evidence is invalid")
}
Self::ArtifactUnavailable => { Self::ArtifactUnavailable => {
formatter.write_str("artifact is not available for leasing") formatter.write_str("artifact is not available for leasing")
} }

View file

@ -8,7 +8,9 @@ use lazaret_core::identity::{
DeviceId, DomainId, GenerationId, HostPath, IdMapDigest, InodeId, PolicyId, PrepareOperationId, DeviceId, DomainId, GenerationId, HostPath, IdMapDigest, InodeId, PolicyId, PrepareOperationId,
PreparedArtifactId, SelectedManifestDigest, SourceIdentity, PreparedArtifactId, SelectedManifestDigest, SourceIdentity,
}; };
use lazaret_core::records::{ArtifactLease, ArtifactState, PrepareOperation, PreparedArtifact}; use lazaret_core::records::{
ArtifactLease, ArtifactState, PrepareOperation, PrepareStageState, PreparedArtifact,
};
use lazaret_linux::filesystem::try_exclusive_flock; use lazaret_linux::filesystem::try_exclusive_flock;
use lazaret_storage_catalog::{Catalog, CatalogError, CatalogRow}; use lazaret_storage_catalog::{Catalog, CatalogError, CatalogRow};
use redb::{Database, TableDefinition}; use redb::{Database, TableDefinition};
@ -52,16 +54,29 @@ impl Fixture {
operation_id: PrepareOperationId::new([seed; 32]), operation_id: PrepareOperationId::new([seed; 32]),
expected_artifact_id: artifact.prepared_artifact_id, expected_artifact_id: artifact.prepared_artifact_id,
source_identity: artifact.source_identity.clone(), source_identity: artifact.source_identity.clone(),
selected_manifest_digest: artifact.selected_manifest_digest,
policy_id: artifact.policy_id,
id_map_digest: artifact.id_map_digest,
stage_path: HostPath::new(format!("stages/{seed}").into_bytes()), stage_path: HostPath::new(format!("stages/{seed}").into_bytes()),
device: DeviceId::new(u64::from(seed) + 200), stage_state: PrepareStageState::Owned,
inode: InodeId::new(u64::from(seed) + 300), device: Some(artifact.device),
inode: Some(artifact.inode),
} }
} }
fn publish(&self, seed: u8) -> PreparedArtifact { fn publish(&self, seed: u8) -> PreparedArtifact {
let artifact = Self::artifact(seed); let artifact = Self::artifact(seed);
let operation = Self::prepare(&artifact, seed); let operation = Self::prepare(&artifact, seed);
self.catalog.record_prepare(&operation).unwrap(); let intent = PrepareOperation {
stage_state: PrepareStageState::Intent,
device: None,
inode: None,
..operation.clone()
};
self.catalog.record_prepare(&intent).unwrap();
self.catalog
.bind_prepare_stage(&intent, &operation)
.unwrap();
self.catalog.publish(&operation, &artifact).unwrap(); self.catalog.publish(&operation, &artifact).unwrap();
artifact artifact
} }
@ -97,10 +112,20 @@ fn publication_is_one_exact_transaction() {
let fixture = Fixture::new(); let fixture = Fixture::new();
let artifact = Fixture::artifact(2); let artifact = Fixture::artifact(2);
let operation = Fixture::prepare(&artifact, 2); let operation = Fixture::prepare(&artifact, 2);
fixture.catalog.record_prepare(&operation).unwrap(); let intent = PrepareOperation {
stage_state: PrepareStageState::Intent,
device: None,
inode: None,
..operation.clone()
};
fixture.catalog.record_prepare(&intent).unwrap();
fixture
.catalog
.bind_prepare_stage(&intent, &operation)
.unwrap();
let mut forged = operation.clone(); let mut forged = operation.clone();
forged.inode = InodeId::new(999); forged.inode = Some(InodeId::new(999));
assert!(matches!( assert!(matches!(
fixture.catalog.publish(&forged, &artifact), fixture.catalog.publish(&forged, &artifact),
Err(CatalogError::OwnershipMismatch { Err(CatalogError::OwnershipMismatch {

View file

@ -4,12 +4,17 @@ yanked = "deny"
[bans] [bans]
multiple-versions = "deny" multiple-versions = "deny"
wildcards = "deny" wildcards = "deny"
skip = [
{ name = "getrandom", version = "0.2.17", reason = "oci-client authentication uses jsonwebtoken's current getrandom line" },
{ name = "untrusted", version = "0.7.1", reason = "oci-client rustls authentication uses aws-lc-rs's compatibility line" },
]
[licenses] [licenses]
allow = [ allow = [
"Apache-2.0", "Apache-2.0",
"BSD-2-Clause", "BSD-2-Clause",
"BSD-3-Clause", "BSD-3-Clause",
"CDLA-Permissive-2.0",
"ISC", "ISC",
"MIT", "MIT",
"Unicode-3.0", "Unicode-3.0",

View file

@ -1061,11 +1061,22 @@ classDiagram
operation_id operation_id
expected_artifact_id expected_artifact_id
source_identity source_identity
selected_manifest_digest
policy_id
id_map_digest
stage_path stage_path
stage_state
device device
inode inode
} }
class PrepareStageState {
<<serialized>>
<<enumeration>>
Intent
Owned
}
StorageCatalog "1" *-- "1" StoreMetadata StorageCatalog "1" *-- "1" StoreMetadata
StorageCatalog "1" *-- "0..*" PreparedArtifact StorageCatalog "1" *-- "0..*" PreparedArtifact
StorageCatalog "1" *-- "0..*" ArtifactLease StorageCatalog "1" *-- "0..*" ArtifactLease
@ -1073,8 +1084,14 @@ classDiagram
PreparedArtifact --> ArtifactState PreparedArtifact --> ArtifactState
PreparedArtifact "1" --> "0..*" ArtifactLease PreparedArtifact "1" --> "0..*" ArtifactLease
PrepareOperation "0..*" --> "0..1" PreparedArtifact : publishes PrepareOperation "0..*" --> "0..1" PreparedArtifact : publishes
PrepareOperation --> PrepareStageState
``` ```
A prepare operation is durably recorded with `stage_state=Intent` and absent
device/inode evidence before its stage is created. The exact same operation then
moves to `Owned` with both values present. Only an owned operation can publish or
authorize exact abandoned-stage recovery.
An artifact lease identifies one prepared artifact, Domain, and generation. Its An artifact lease identifies one prepared artifact, Domain, and generation. Its
Domain and generation fields correspond to a Domain Store generation; the stores Domain and generation fields correspond to a Domain Store generation; the stores
do not form one transactional database. do not form one transactional database.

View file

@ -114,6 +114,8 @@ class VerificationTests(unittest.TestCase):
) )
) )
self.assertTrue(VERIFY.license_permitted("GPL-3.0-only OR MIT", allowed)) self.assertTrue(VERIFY.license_permitted("GPL-3.0-only OR MIT", allowed))
self.assertTrue(VERIFY.license_permitted("MIT/Apache-2.0", allowed))
self.assertTrue(VERIFY.license_permitted("Apache-2.0 / MIT", allowed))
self.assertFalse(VERIFY.license_permitted("GPL-3.0-only", allowed)) self.assertFalse(VERIFY.license_permitted("GPL-3.0-only", allowed))
self.assertFalse(VERIFY.license_permitted("MIT AND GPL-3.0-only", allowed)) self.assertFalse(VERIFY.license_permitted("MIT AND GPL-3.0-only", allowed))

View file

@ -350,6 +350,7 @@ def audit_dependencies(metadata: dict) -> None:
def license_permitted(expression: str, allowed: set[str]) -> bool: def license_permitted(expression: str, allowed: set[str]) -> bool:
expression = re.sub(r"\s+WITH\s+[A-Za-z0-9.-]+", "", expression) expression = re.sub(r"\s+WITH\s+[A-Za-z0-9.-]+", "", expression)
expression = re.sub(r"\s*/\s*", " OR ", expression)
return bool(expression) and any( return bool(expression) and any(
all( all(
term.strip(" ()") in allowed term.strip(" ()") in allowed