Implement verified OCI prepared-lower lifecycle
This commit is contained in:
parent
54f3a08b91
commit
5458460717
17 changed files with 4973 additions and 273 deletions
1821
Cargo.lock
generated
1821
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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] = &[
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
28
crates/lazaret-prepared-lower/Cargo.toml
Normal file
28
crates/lazaret-prepared-lower/Cargo.toml
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
[package]
|
||||||
|
name = "lazaret-prepared-lower"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
|
flate2.workspace = true
|
||||||
|
lazaret-core = { version = "=0.0.0", path = "../lazaret-core" }
|
||||||
|
lazaret-linux = { version = "=0.0.0", path = "../lazaret-linux" }
|
||||||
|
lazaret-storage-catalog = { version = "=0.0.0", path = "../lazaret-storage-catalog" }
|
||||||
|
oci-client.workspace = true
|
||||||
|
postcard.workspace = true
|
||||||
|
rustix.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
tar.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
zstd.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "=3.27.0"
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
846
crates/lazaret-prepared-lower/src/layer.rs
Normal file
846
crates/lazaret-prepared-lower/src/layer.rs
Normal file
|
|
@ -0,0 +1,846 @@
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::ffi::{OsStr, OsString};
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{self, Read, Seek, SeekFrom, Write};
|
||||||
|
use std::os::fd::{AsFd, OwnedFd};
|
||||||
|
use std::os::unix::ffi::OsStrExt;
|
||||||
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use rustix::fs::{
|
||||||
|
AtFlags, FileType, Gid, Mode, OFlags, ResolveFlags, Timestamps, Uid, chownat, fchmod, fchown,
|
||||||
|
futimens, linkat, mkdirat, openat2, symlinkat, unlinkat, utimensat,
|
||||||
|
};
|
||||||
|
use rustix::io::{Errno, dup};
|
||||||
|
use rustix::time::Timespec;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use tar::{EntryType, Header};
|
||||||
|
|
||||||
|
pub const POLICY_NAME: &str = "lazaret-supported-image-v1";
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct IdMapRange {
|
||||||
|
pub container_id: u32,
|
||||||
|
pub namespace_id: u32,
|
||||||
|
pub size: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct IdMap {
|
||||||
|
pub uid: Vec<IdMapRange>,
|
||||||
|
pub gid: Vec<IdMapRange>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct PreparationLimits {
|
||||||
|
pub max_layers: usize,
|
||||||
|
pub max_entries: u64,
|
||||||
|
pub max_hardlinks: usize,
|
||||||
|
pub max_file_bytes: u64,
|
||||||
|
pub max_total_bytes: u64,
|
||||||
|
pub max_path_bytes: usize,
|
||||||
|
pub max_pax_records: usize,
|
||||||
|
pub max_pax_bytes: usize,
|
||||||
|
pub max_json_bytes: u64,
|
||||||
|
pub max_layer_blob_bytes: u64,
|
||||||
|
pub max_uncompressed_layer_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PreparationLimits {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
max_layers: 256,
|
||||||
|
max_entries: 500_000,
|
||||||
|
max_hardlinks: 250_000,
|
||||||
|
max_file_bytes: 8 * 1024 * 1024 * 1024,
|
||||||
|
max_total_bytes: 128 * 1024 * 1024 * 1024,
|
||||||
|
max_path_bytes: 4096,
|
||||||
|
max_pax_records: 64,
|
||||||
|
max_pax_bytes: 4 * 1024 * 1024,
|
||||||
|
max_json_bytes: 16 * 1024 * 1024,
|
||||||
|
max_layer_blob_bytes: 32 * 1024 * 1024 * 1024,
|
||||||
|
max_uncompressed_layer_bytes: 128 * 1024 * 1024 * 1024,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct LayoutIndex {
|
||||||
|
manifests: Vec<Descriptor>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Manifest {
|
||||||
|
config: Descriptor,
|
||||||
|
layers: Vec<Descriptor>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Config {
|
||||||
|
rootfs: Rootfs,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Rootfs {
|
||||||
|
diff_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize)]
|
||||||
|
struct Descriptor {
|
||||||
|
digest: String,
|
||||||
|
size: u64,
|
||||||
|
#[serde(rename = "mediaType", default)]
|
||||||
|
media_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct VerifiedLayout {
|
||||||
|
layers: Vec<Descriptor>,
|
||||||
|
diff_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Usage {
|
||||||
|
entries: u64,
|
||||||
|
hardlinks: usize,
|
||||||
|
total_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn verify_layout(layout: &Path, limits: PreparationLimits) -> Result<VerifiedLayout> {
|
||||||
|
let index: LayoutIndex = read_json(&layout.join("index.json"), limits.max_json_bytes)?;
|
||||||
|
if index.manifests.len() != 1 {
|
||||||
|
bail!("selected OCI layout must contain exactly one manifest");
|
||||||
|
}
|
||||||
|
let descriptor = &index.manifests[0];
|
||||||
|
let manifest: Manifest =
|
||||||
|
serde_json::from_slice(&verified_blob(layout, descriptor, limits.max_json_bytes)?)?;
|
||||||
|
if manifest.layers.len() > limits.max_layers {
|
||||||
|
bail!("policy {POLICY_NAME} rejects excessive layer count");
|
||||||
|
}
|
||||||
|
let config: Config = serde_json::from_slice(&verified_blob(
|
||||||
|
layout,
|
||||||
|
&manifest.config,
|
||||||
|
limits.max_json_bytes,
|
||||||
|
)?)?;
|
||||||
|
if manifest.layers.len() != config.rootfs.diff_ids.len() {
|
||||||
|
bail!("OCI config diff-ID count does not match layer count");
|
||||||
|
}
|
||||||
|
for layer in &manifest.layers {
|
||||||
|
validate_media_type(&layer.media_type)?;
|
||||||
|
verify_blob_digest(layout, layer, limits.max_layer_blob_bytes)?;
|
||||||
|
}
|
||||||
|
Ok(VerifiedLayout {
|
||||||
|
layers: manifest.layers,
|
||||||
|
diff_ids: config.rootfs.diff_ids,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn apply_layout(
|
||||||
|
layout: &Path,
|
||||||
|
rootfs: &File,
|
||||||
|
verified: &VerifiedLayout,
|
||||||
|
id_map: &IdMap,
|
||||||
|
limits: PreparationLimits,
|
||||||
|
) -> Result<()> {
|
||||||
|
validate_id_map(id_map)?;
|
||||||
|
let mut state = LayerState::default();
|
||||||
|
let mut usage = Usage::default();
|
||||||
|
for (index, (descriptor, diff_id)) in verified.layers.iter().zip(&verified.diff_ids).enumerate()
|
||||||
|
{
|
||||||
|
let spool_name = format!(".layer-{index}.tar");
|
||||||
|
let spool_output = File::from(openat2(
|
||||||
|
rootfs,
|
||||||
|
&spool_name,
|
||||||
|
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||||
|
Mode::from_raw_mode(0o600),
|
||||||
|
secure_resolve(),
|
||||||
|
)?);
|
||||||
|
let observed = decompress_layer(
|
||||||
|
&blob_path(layout, &descriptor.digest)?,
|
||||||
|
&descriptor.media_type,
|
||||||
|
spool_output,
|
||||||
|
limits.max_uncompressed_layer_bytes,
|
||||||
|
)?;
|
||||||
|
if &observed != diff_id {
|
||||||
|
let _ = unlinkat(rootfs, &spool_name, AtFlags::empty());
|
||||||
|
bail!("layer {index} diff-ID mismatch");
|
||||||
|
}
|
||||||
|
let spool = File::from(openat2(
|
||||||
|
rootfs,
|
||||||
|
&spool_name,
|
||||||
|
OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||||
|
Mode::empty(),
|
||||||
|
secure_resolve(),
|
||||||
|
)?);
|
||||||
|
let result = validate_tar(&spool, id_map, limits, &mut usage)
|
||||||
|
.and_then(|()| state.apply(&spool, rootfs));
|
||||||
|
if let Err(error) = result {
|
||||||
|
let _ = unlinkat(rootfs, &spool_name, AtFlags::empty());
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
match unlinkat(rootfs, &spool_name, AtFlags::empty()) {
|
||||||
|
Ok(()) | Err(rustix::io::Errno::NOENT) => {}
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.finish(rootfs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_json<T: serde::de::DeserializeOwned>(path: &Path, limit: u64) -> Result<T> {
|
||||||
|
let metadata = std::fs::metadata(path)?;
|
||||||
|
if metadata.len() > limit {
|
||||||
|
bail!("OCI JSON exceeds {limit} bytes");
|
||||||
|
}
|
||||||
|
Ok(serde_json::from_slice(&std::fs::read(path)?)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verified_blob(layout: &Path, descriptor: &Descriptor, limit: u64) -> Result<Vec<u8>> {
|
||||||
|
let path = blob_path(layout, &descriptor.digest)?;
|
||||||
|
let metadata = std::fs::metadata(&path)?;
|
||||||
|
if metadata.len() != descriptor.size || metadata.len() > limit {
|
||||||
|
bail!("OCI descriptor size or resource limit mismatch");
|
||||||
|
}
|
||||||
|
let bytes = std::fs::read(path)?;
|
||||||
|
if Sha256::digest(&bytes).as_slice() != parse_sha256(&descriptor.digest)? {
|
||||||
|
bail!("OCI descriptor digest mismatch");
|
||||||
|
}
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_blob_digest(layout: &Path, descriptor: &Descriptor, limit: u64) -> Result<()> {
|
||||||
|
let path = blob_path(layout, &descriptor.digest)?;
|
||||||
|
let metadata = std::fs::metadata(&path)?;
|
||||||
|
if metadata.len() != descriptor.size || metadata.len() > limit {
|
||||||
|
bail!("OCI layer size or resource limit mismatch");
|
||||||
|
}
|
||||||
|
let observed = digest_reader(File::open(path)?, limit, None)?;
|
||||||
|
if observed != descriptor.digest {
|
||||||
|
bail!("OCI layer digest mismatch");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blob_path(layout: &Path, digest: &str) -> Result<PathBuf> {
|
||||||
|
let component = digest
|
||||||
|
.strip_prefix("sha256:")
|
||||||
|
.context("only sha256 OCI descriptors are supported")?;
|
||||||
|
if component.len() != 64 || !component.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||||
|
bail!("invalid sha256 OCI descriptor");
|
||||||
|
}
|
||||||
|
Ok(layout.join("blobs/sha256").join(component))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_sha256(digest: &str) -> Result<[u8; 32]> {
|
||||||
|
let component = digest
|
||||||
|
.strip_prefix("sha256:")
|
||||||
|
.context("only sha256 OCI descriptors are supported")?;
|
||||||
|
if component.len() != 64 {
|
||||||
|
bail!("invalid sha256 OCI descriptor");
|
||||||
|
}
|
||||||
|
let mut bytes = [0; 32];
|
||||||
|
for (index, pair) in component.as_bytes().chunks_exact(2).enumerate() {
|
||||||
|
bytes[index] = u8::from_str_radix(std::str::from_utf8(pair)?, 16)?;
|
||||||
|
}
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decompress_layer(source: &Path, media_type: &str, output: File, limit: u64) -> Result<String> {
|
||||||
|
let input = File::open(source)?;
|
||||||
|
let reader: Box<dyn Read> = match media_type {
|
||||||
|
"application/vnd.oci.image.layer.v1.tar" => Box::new(input),
|
||||||
|
"application/vnd.oci.image.layer.v1.tar+gzip" => {
|
||||||
|
Box::new(flate2::read::GzDecoder::new(input))
|
||||||
|
}
|
||||||
|
"application/vnd.oci.image.layer.v1.tar+zstd" => {
|
||||||
|
Box::new(zstd::stream::read::Decoder::new(input)?)
|
||||||
|
}
|
||||||
|
_ => bail!("policy {POLICY_NAME} rejects layer media type {media_type}"),
|
||||||
|
};
|
||||||
|
digest_reader(reader, limit, Some(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn digest_reader(mut reader: impl Read, limit: u64, mut output: Option<File>) -> Result<String> {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
let mut buffer = vec![0; 64 * 1024];
|
||||||
|
let mut total = 0u64;
|
||||||
|
loop {
|
||||||
|
let read = reader.read(&mut buffer)?;
|
||||||
|
if read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
total = total
|
||||||
|
.checked_add(read as u64)
|
||||||
|
.context("content size overflow")?;
|
||||||
|
if total > limit {
|
||||||
|
bail!("content exceeds {limit} bytes");
|
||||||
|
}
|
||||||
|
hasher.update(&buffer[..read]);
|
||||||
|
if let Some(output) = &mut output {
|
||||||
|
output.write_all(&buffer[..read])?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(format!("sha256:{}", hex(&hasher.finalize())))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_tar(
|
||||||
|
source: &File,
|
||||||
|
id_map: &IdMap,
|
||||||
|
limits: PreparationLimits,
|
||||||
|
usage: &mut Usage,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut archive = tar::Archive::new(rewound(source)?);
|
||||||
|
let mut paths = HashSet::new();
|
||||||
|
for entry in archive.entries()? {
|
||||||
|
let mut entry = entry?;
|
||||||
|
let path = normalize_relative(&entry.path()?)?;
|
||||||
|
if !paths.insert(path.clone()) {
|
||||||
|
bail!("policy {POLICY_NAME} rejects duplicate layer path");
|
||||||
|
}
|
||||||
|
usage.entries += 1;
|
||||||
|
if usage.entries > limits.max_entries
|
||||||
|
|| path.as_os_str().as_bytes().len() > limits.max_path_bytes
|
||||||
|
{
|
||||||
|
bail!("policy {POLICY_NAME} rejects entry or path resource limit");
|
||||||
|
}
|
||||||
|
let size = entry.header().size()?;
|
||||||
|
usage.total_bytes = usage
|
||||||
|
.total_bytes
|
||||||
|
.checked_add(size)
|
||||||
|
.context("size overflow")?;
|
||||||
|
if size > limits.max_file_bytes || usage.total_bytes > limits.max_total_bytes {
|
||||||
|
bail!("policy {POLICY_NAME} rejects content resource limit");
|
||||||
|
}
|
||||||
|
let pax: Vec<_> = entry
|
||||||
|
.pax_extensions()?
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.collect::<Result<_, _>>()?;
|
||||||
|
let pax_bytes = pax.iter().try_fold(0usize, |total, item| {
|
||||||
|
total
|
||||||
|
.checked_add(item.key_bytes().len())?
|
||||||
|
.checked_add(item.value_bytes().len())
|
||||||
|
});
|
||||||
|
if pax.len() > limits.max_pax_records
|
||||||
|
|| pax_bytes.is_none_or(|bytes| bytes > limits.max_pax_bytes)
|
||||||
|
{
|
||||||
|
bail!("policy {POLICY_NAME} rejects PAX resource limit");
|
||||||
|
}
|
||||||
|
for item in pax {
|
||||||
|
if !matches!(item.key()?, "path" | "linkpath" | "mtime") {
|
||||||
|
bail!("policy {POLICY_NAME} rejects PAX metadata");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validate_owner(entry.header().uid()?, &id_map.uid, "uid")?;
|
||||||
|
validate_owner(entry.header().gid()?, &id_map.gid, "gid")?;
|
||||||
|
let entry_type = entry.header().entry_type();
|
||||||
|
if !matches!(
|
||||||
|
entry_type,
|
||||||
|
EntryType::Regular
|
||||||
|
| EntryType::Continuous
|
||||||
|
| EntryType::Directory
|
||||||
|
| EntryType::Symlink
|
||||||
|
| EntryType::Link
|
||||||
|
) {
|
||||||
|
bail!("policy {POLICY_NAME} rejects entry type {entry_type:?}");
|
||||||
|
}
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
if name == ".wh." {
|
||||||
|
bail!("whiteout has no target name");
|
||||||
|
}
|
||||||
|
if (name == ".wh..wh..opq" || name.starts_with(".wh."))
|
||||||
|
&& (!matches!(entry_type, EntryType::Regular | EntryType::Continuous)
|
||||||
|
|| entry.header().size()? != 0)
|
||||||
|
{
|
||||||
|
bail!("whiteout is not an empty regular file");
|
||||||
|
}
|
||||||
|
if entry_type == EntryType::Link {
|
||||||
|
let target = entry.link_name()?.context("hardlink has no target")?;
|
||||||
|
normalize_relative(&target)?;
|
||||||
|
usage.hardlinks += 1;
|
||||||
|
if usage.hardlinks > limits.max_hardlinks {
|
||||||
|
bail!("policy {POLICY_NAME} rejects hardlink resource limit");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_relative(path: &Path) -> Result<PathBuf> {
|
||||||
|
let mut normalized = PathBuf::new();
|
||||||
|
for component in path.components() {
|
||||||
|
match component {
|
||||||
|
Component::Normal(value) => normalized.push(value),
|
||||||
|
Component::CurDir => {}
|
||||||
|
Component::ParentDir => bail!("parent traversal is not allowed"),
|
||||||
|
Component::RootDir | Component::Prefix(_) => bail!("absolute path is not allowed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if normalized.as_os_str().is_empty() {
|
||||||
|
bail!("empty path is not allowed");
|
||||||
|
}
|
||||||
|
Ok(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_owner(value: u64, ranges: &[IdMapRange], name: &str) -> Result<()> {
|
||||||
|
let Ok(value) = u32::try_from(value) else {
|
||||||
|
bail!("{name} {value} is outside the target ID map");
|
||||||
|
};
|
||||||
|
if !ranges
|
||||||
|
.iter()
|
||||||
|
.any(|range| value >= range.container_id && value - range.container_id < range.size)
|
||||||
|
{
|
||||||
|
bail!("{name} {value} is outside the target ID map");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_id_map(id_map: &IdMap) -> Result<()> {
|
||||||
|
for (name, ranges) in [("uid", &id_map.uid), ("gid", &id_map.gid)] {
|
||||||
|
if ranges.is_empty() || ranges.iter().any(|range| range.size == 0) {
|
||||||
|
bail!("{name} map is empty or invalid");
|
||||||
|
}
|
||||||
|
for range in ranges {
|
||||||
|
range
|
||||||
|
.container_id
|
||||||
|
.checked_add(range.size - 1)
|
||||||
|
.context("ID map overflows")?;
|
||||||
|
range
|
||||||
|
.namespace_id
|
||||||
|
.checked_add(range.size - 1)
|
||||||
|
.context("ID map overflows")?;
|
||||||
|
}
|
||||||
|
for (index, left) in ranges.iter().enumerate() {
|
||||||
|
for right in &ranges[index + 1..] {
|
||||||
|
if overlaps(left.container_id, left.size, right.container_id, right.size)
|
||||||
|
|| overlaps(left.namespace_id, left.size, right.namespace_id, right.size)
|
||||||
|
{
|
||||||
|
bail!("{name} map contains overlapping ranges");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn overlaps(left: u32, left_size: u32, right: u32, right_size: u32) -> bool {
|
||||||
|
left <= right.saturating_add(right_size - 1) && right <= left.saturating_add(left_size - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_media_type(media_type: &str) -> Result<()> {
|
||||||
|
if matches!(
|
||||||
|
media_type,
|
||||||
|
"application/vnd.oci.image.layer.v1.tar"
|
||||||
|
| "application/vnd.oci.image.layer.v1.tar+gzip"
|
||||||
|
| "application/vnd.oci.image.layer.v1.tar+zstd"
|
||||||
|
) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
bail!("policy {POLICY_NAME} rejects layer media type {media_type}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex(bytes: &[u8]) -> String {
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
|
bytes.iter().fold(
|
||||||
|
String::with_capacity(bytes.len() * 2),
|
||||||
|
|mut output, byte| {
|
||||||
|
write!(output, "{byte:02x}").expect("writing to String cannot fail");
|
||||||
|
output
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct LayerState {
|
||||||
|
directories: HashMap<PathBuf, EntryMetadata>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LayerState {
|
||||||
|
fn apply(&mut self, tar_source: &File, root: &File) -> Result<()> {
|
||||||
|
apply_whiteouts(tar_source, root, &mut self.directories)?;
|
||||||
|
let mut archive = tar::Archive::new(rewound(tar_source)?);
|
||||||
|
let mut hardlinks = Vec::new();
|
||||||
|
for entry in archive.entries()? {
|
||||||
|
let mut entry = entry?;
|
||||||
|
let path = entry.path()?.into_owned();
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
if name == ".wh..wh..opq" || name.starts_with(".wh.") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let metadata = EntryMetadata::from_header(path.clone(), entry.header())?;
|
||||||
|
match entry.header().entry_type() {
|
||||||
|
EntryType::Regular | EntryType::Continuous => {
|
||||||
|
forget_directory_tree(&mut self.directories, &path);
|
||||||
|
remove_path(root.as_fd(), &path)?;
|
||||||
|
create_regular(root.as_fd(), &path, &mut entry, &metadata)?;
|
||||||
|
}
|
||||||
|
EntryType::Directory => {
|
||||||
|
ensure_directory(root.as_fd(), &path)?;
|
||||||
|
self.directories.insert(path, metadata);
|
||||||
|
}
|
||||||
|
EntryType::Symlink => {
|
||||||
|
let target = entry.link_name()?.context("symlink has no target")?;
|
||||||
|
forget_directory_tree(&mut self.directories, &path);
|
||||||
|
remove_path(root.as_fd(), &path)?;
|
||||||
|
let (parent, leaf) = ensure_parent(root.as_fd(), &path)?;
|
||||||
|
symlinkat(target.as_ref(), &parent, &leaf)?;
|
||||||
|
set_symlink_metadata(&parent, &leaf, &metadata)?;
|
||||||
|
}
|
||||||
|
EntryType::Link => {
|
||||||
|
let target = entry
|
||||||
|
.link_name()?
|
||||||
|
.context("hardlink has no target")?
|
||||||
|
.into_owned();
|
||||||
|
forget_directory_tree(&mut self.directories, &path);
|
||||||
|
remove_path(root.as_fd(), &path)?;
|
||||||
|
hardlinks.push((path, target));
|
||||||
|
}
|
||||||
|
entry_type => bail!("validated layer contains unsupported type {entry_type:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolve_hardlinks(root.as_fd(), hardlinks)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(mut self, root: &File) -> Result<()> {
|
||||||
|
let mut entries: Vec<_> = self.directories.drain().map(|(_, value)| value).collect();
|
||||||
|
entries.sort_by_key(|metadata| std::cmp::Reverse(metadata.path.components().count()));
|
||||||
|
for metadata in entries {
|
||||||
|
let fd = openat2(
|
||||||
|
root.as_fd(),
|
||||||
|
&metadata.path,
|
||||||
|
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||||
|
Mode::empty(),
|
||||||
|
secure_resolve(),
|
||||||
|
)?;
|
||||||
|
set_fd_metadata(&fd, &metadata)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EntryMetadata {
|
||||||
|
path: PathBuf,
|
||||||
|
uid: u32,
|
||||||
|
gid: u32,
|
||||||
|
mode: u32,
|
||||||
|
mtime: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EntryMetadata {
|
||||||
|
fn from_header(path: PathBuf, header: &Header) -> Result<Self> {
|
||||||
|
Ok(Self {
|
||||||
|
path,
|
||||||
|
uid: u32::try_from(header.uid()?).context("entry uid exceeds u32")?,
|
||||||
|
gid: u32::try_from(header.gid()?).context("entry gid exceeds u32")?,
|
||||||
|
mode: header.mode()?,
|
||||||
|
mtime: header.mtime()?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_whiteouts(
|
||||||
|
tar_source: &File,
|
||||||
|
root: &File,
|
||||||
|
directories: &mut HashMap<PathBuf, EntryMetadata>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut archive = tar::Archive::new(rewound(tar_source)?);
|
||||||
|
for entry in archive.entries()? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path()?.into_owned();
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
if name == ".wh..wh..opq" {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
remove_children_at(root.as_fd(), parent)?;
|
||||||
|
directories
|
||||||
|
.retain(|candidate, _| candidate == parent || !candidate.starts_with(parent));
|
||||||
|
}
|
||||||
|
} else if let Some(target_name) = name.strip_prefix(".wh.") {
|
||||||
|
let target = path.parent().unwrap_or(Path::new("")).join(target_name);
|
||||||
|
forget_directory_tree(directories, &target);
|
||||||
|
remove_path(root.as_fd(), &target)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn forget_directory_tree(directories: &mut HashMap<PathBuf, EntryMetadata>, path: &Path) {
|
||||||
|
directories.retain(|candidate, _| !candidate.starts_with(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_regular<R: Read>(
|
||||||
|
root: impl AsFd,
|
||||||
|
path: &Path,
|
||||||
|
entry: &mut tar::Entry<'_, R>,
|
||||||
|
metadata: &EntryMetadata,
|
||||||
|
) -> Result<()> {
|
||||||
|
let (parent, leaf) = ensure_parent(root, path)?;
|
||||||
|
let fd = openat2(
|
||||||
|
&parent,
|
||||||
|
&leaf,
|
||||||
|
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||||
|
Mode::from_raw_mode(0o600),
|
||||||
|
secure_resolve(),
|
||||||
|
)?;
|
||||||
|
let mut file = File::from(fd);
|
||||||
|
io::copy(entry, &mut file)?;
|
||||||
|
set_fd_metadata(&file, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_directory(root: impl AsFd, path: &Path) -> Result<()> {
|
||||||
|
if let Some(file_type) = path_type(root.as_fd(), path)? {
|
||||||
|
if file_type == FileType::Directory {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
remove_path(root.as_fd(), path)?;
|
||||||
|
}
|
||||||
|
let (parent, leaf) = ensure_parent(root, path)?;
|
||||||
|
match mkdirat(&parent, &leaf, Mode::from_raw_mode(0o700)) {
|
||||||
|
Ok(()) | Err(Errno::EXIST) => Ok(()),
|
||||||
|
Err(error) => Err(error.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_hardlinks(root: impl AsFd, mut pending: Vec<(PathBuf, PathBuf)>) -> Result<()> {
|
||||||
|
while !pending.is_empty() {
|
||||||
|
let mut remaining = Vec::new();
|
||||||
|
let mut progress = false;
|
||||||
|
for (path, target) in pending {
|
||||||
|
match create_hardlink(root.as_fd(), &path, &target) {
|
||||||
|
Ok(()) => progress = true,
|
||||||
|
Err(error) if error.downcast_ref::<Errno>() == Some(&Errno::NOENT) => {
|
||||||
|
remaining.push((path, target));
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !progress {
|
||||||
|
bail!("unresolved layer hardlinks");
|
||||||
|
}
|
||||||
|
pending = remaining;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_hardlink(root: impl AsFd, path: &Path, target: &Path) -> Result<()> {
|
||||||
|
let target_fd = openat2(
|
||||||
|
root.as_fd(),
|
||||||
|
target,
|
||||||
|
OFlags::PATH | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||||
|
Mode::empty(),
|
||||||
|
secure_resolve(),
|
||||||
|
)?;
|
||||||
|
if FileType::from_raw_mode(rustix::fs::fstat(&target_fd)?.st_mode) != FileType::RegularFile {
|
||||||
|
bail!("hardlink target is not a regular file");
|
||||||
|
}
|
||||||
|
let (parent, leaf) = ensure_parent(root, path)?;
|
||||||
|
linkat(&target_fd, "", &parent, &leaf, AtFlags::EMPTY_PATH)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_type(root: impl AsFd, path: &Path) -> Result<Option<FileType>> {
|
||||||
|
match openat2(
|
||||||
|
root,
|
||||||
|
path,
|
||||||
|
OFlags::PATH | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||||
|
Mode::empty(),
|
||||||
|
secure_resolve(),
|
||||||
|
) {
|
||||||
|
Ok(fd) => Ok(Some(FileType::from_raw_mode(
|
||||||
|
rustix::fs::fstat(fd)?.st_mode,
|
||||||
|
))),
|
||||||
|
Err(Errno::NOENT) => Ok(None),
|
||||||
|
Err(error) => Err(error.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_path(root: impl AsFd, path: &Path) -> Result<()> {
|
||||||
|
let Some((parent, leaf)) = existing_parent(root, path)? else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let file_type = match openat2(
|
||||||
|
&parent,
|
||||||
|
&leaf,
|
||||||
|
OFlags::PATH | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||||
|
Mode::empty(),
|
||||||
|
secure_resolve(),
|
||||||
|
) {
|
||||||
|
Ok(fd) => FileType::from_raw_mode(rustix::fs::fstat(fd)?.st_mode),
|
||||||
|
Err(Errno::NOENT) => return Ok(()),
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
};
|
||||||
|
if file_type == FileType::Directory {
|
||||||
|
let directory = open_component(&parent, &leaf)?;
|
||||||
|
remove_children(&directory)?;
|
||||||
|
unlinkat(&parent, &leaf, AtFlags::REMOVEDIR)?;
|
||||||
|
} else {
|
||||||
|
unlinkat(&parent, &leaf, AtFlags::empty())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_children_at(root: impl AsFd, path: &Path) -> Result<()> {
|
||||||
|
if path.as_os_str().is_empty() {
|
||||||
|
return remove_children(root);
|
||||||
|
}
|
||||||
|
let directory = match openat2(
|
||||||
|
root,
|
||||||
|
path,
|
||||||
|
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||||
|
Mode::empty(),
|
||||||
|
secure_resolve(),
|
||||||
|
) {
|
||||||
|
Ok(fd) => fd,
|
||||||
|
Err(Errno::NOENT) => return Ok(()),
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
};
|
||||||
|
remove_children(&directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn remove_children(directory: impl AsFd) -> Result<()> {
|
||||||
|
let mut buffer = [std::mem::MaybeUninit::uninit(); 8192];
|
||||||
|
let names = {
|
||||||
|
let mut names = Vec::new();
|
||||||
|
let mut entries = rustix::fs::RawDir::new(directory.as_fd(), &mut buffer);
|
||||||
|
while let Some(entry) = entries.next() {
|
||||||
|
let entry = entry?;
|
||||||
|
let name = entry.file_name();
|
||||||
|
if name.to_bytes() != b"." && name.to_bytes() != b".." {
|
||||||
|
names.push(name.to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names
|
||||||
|
};
|
||||||
|
for name in names {
|
||||||
|
let file_type = match openat2(
|
||||||
|
directory.as_fd(),
|
||||||
|
&name,
|
||||||
|
OFlags::PATH | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||||
|
Mode::empty(),
|
||||||
|
secure_resolve(),
|
||||||
|
) {
|
||||||
|
Ok(fd) => FileType::from_raw_mode(rustix::fs::fstat(fd)?.st_mode),
|
||||||
|
Err(Errno::NOENT) => continue,
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
};
|
||||||
|
if file_type == FileType::Directory {
|
||||||
|
let child = open_component(directory.as_fd(), OsStr::from_bytes(name.to_bytes()))?;
|
||||||
|
remove_children(&child)?;
|
||||||
|
unlinkat(directory.as_fd(), name.as_c_str(), AtFlags::REMOVEDIR)?;
|
||||||
|
} else {
|
||||||
|
unlinkat(directory.as_fd(), name.as_c_str(), AtFlags::empty())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_parent(root: impl AsFd, path: &Path) -> Result<(OwnedFd, OsString)> {
|
||||||
|
let leaf = path
|
||||||
|
.file_name()
|
||||||
|
.context("path has no final component")?
|
||||||
|
.to_os_string();
|
||||||
|
let mut current = dup(root)?;
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
for component in parent.components() {
|
||||||
|
let Component::Normal(name) = component else {
|
||||||
|
bail!("path is not relative");
|
||||||
|
};
|
||||||
|
match mkdirat(¤t, name, Mode::from_raw_mode(0o700)) {
|
||||||
|
Ok(()) | Err(Errno::EXIST) => {}
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
}
|
||||||
|
current = open_component(¤t, name)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((current, leaf))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn existing_parent(root: impl AsFd, path: &Path) -> Result<Option<(OwnedFd, OsString)>> {
|
||||||
|
let leaf = path
|
||||||
|
.file_name()
|
||||||
|
.context("path has no final component")?
|
||||||
|
.to_os_string();
|
||||||
|
let mut current = dup(root)?;
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
for component in parent.components() {
|
||||||
|
let Component::Normal(name) = component else {
|
||||||
|
bail!("path is not relative");
|
||||||
|
};
|
||||||
|
current = match open_component(¤t, name) {
|
||||||
|
Ok(fd) => fd,
|
||||||
|
Err(Errno::NOENT) => return Ok(None),
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Some((current, leaf)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_component(parent: impl AsFd, name: &OsStr) -> rustix::io::Result<OwnedFd> {
|
||||||
|
openat2(
|
||||||
|
parent,
|
||||||
|
name,
|
||||||
|
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||||
|
Mode::empty(),
|
||||||
|
secure_resolve(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_fd_metadata(fd: impl AsFd, metadata: &EntryMetadata) -> Result<()> {
|
||||||
|
fchown(
|
||||||
|
&fd,
|
||||||
|
Some(Uid::from_raw(metadata.uid)),
|
||||||
|
Some(Gid::from_raw(metadata.gid)),
|
||||||
|
)?;
|
||||||
|
fchmod(&fd, Mode::from_raw_mode(metadata.mode))?;
|
||||||
|
futimens(&fd, ×tamps(metadata.mtime))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_symlink_metadata(parent: impl AsFd, leaf: &OsStr, metadata: &EntryMetadata) -> Result<()> {
|
||||||
|
chownat(
|
||||||
|
&parent,
|
||||||
|
leaf,
|
||||||
|
Some(Uid::from_raw(metadata.uid)),
|
||||||
|
Some(Gid::from_raw(metadata.gid)),
|
||||||
|
AtFlags::SYMLINK_NOFOLLOW,
|
||||||
|
)?;
|
||||||
|
utimensat(
|
||||||
|
parent,
|
||||||
|
leaf,
|
||||||
|
×tamps(metadata.mtime),
|
||||||
|
AtFlags::SYMLINK_NOFOLLOW,
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn timestamps(seconds: u64) -> Timestamps {
|
||||||
|
let seconds = i64::try_from(seconds).unwrap_or(i64::MAX);
|
||||||
|
let time = Timespec {
|
||||||
|
tv_sec: seconds,
|
||||||
|
tv_nsec: 0,
|
||||||
|
};
|
||||||
|
Timestamps {
|
||||||
|
last_access: time,
|
||||||
|
last_modification: time,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn secure_resolve() -> ResolveFlags {
|
||||||
|
ResolveFlags::BENEATH | ResolveFlags::NO_SYMLINKS | ResolveFlags::NO_MAGICLINKS
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rewound(source: &File) -> Result<File> {
|
||||||
|
let mut file = source.try_clone()?;
|
||||||
|
file.seek(SeekFrom::Start(0))?;
|
||||||
|
Ok(file)
|
||||||
|
}
|
||||||
9
crates/lazaret-prepared-lower/src/lib.rs
Normal file
9
crates/lazaret-prepared-lower/src/lib.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
#![doc = "OCI transport, preparation, publication, and exact-owned reclaim for prepared lowers."]
|
||||||
|
|
||||||
|
mod layer;
|
||||||
|
mod store;
|
||||||
|
mod transport;
|
||||||
|
|
||||||
|
pub use layer::{IdMap, IdMapRange, PreparationLimits};
|
||||||
|
pub use store::{PrepareRecovery, PrepareRequest, PreparedLowerStore, ReclaimOutcome};
|
||||||
|
pub use transport::{OciRegistryTransport, Platform, RegistryCredentials, TransportedImage};
|
||||||
608
crates/lazaret-prepared-lower/src/store.rs
Normal file
608
crates/lazaret-prepared-lower/src/store.rs
Normal file
|
|
@ -0,0 +1,608 @@
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io;
|
||||||
|
use std::os::fd::AsFd;
|
||||||
|
use std::os::unix::ffi::{OsStrExt, OsStringExt};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::thread;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use lazaret_core::identity::{
|
||||||
|
DeviceId, HostPath, IdMapDigest, InodeId, PolicyId, PrepareOperationId, PreparedArtifactId,
|
||||||
|
SelectedManifestDigest, SourceIdentity,
|
||||||
|
};
|
||||||
|
use lazaret_core::records::{ArtifactState, PrepareOperation, PrepareStageState, PreparedArtifact};
|
||||||
|
use lazaret_linux::filesystem::{ExclusiveFileLock, try_exclusive_flock};
|
||||||
|
use lazaret_storage_catalog::{Catalog, CatalogError, CatalogRow};
|
||||||
|
use rustix::fs::{AtFlags, Mode, OFlags, ResolveFlags, mkdirat, openat2, renameat, unlinkat};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
use crate::layer::{
|
||||||
|
IdMap, POLICY_NAME, PreparationLimits, apply_layout, remove_children, verify_layout,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PHYSICAL_LOCK_WAIT: Duration = Duration::from_secs(5);
|
||||||
|
const LOCK_RETRY: Duration = Duration::from_millis(2);
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct PrepareRequest {
|
||||||
|
pub operation_id: PrepareOperationId,
|
||||||
|
pub layout_path: PathBuf,
|
||||||
|
pub source_identity: Vec<u8>,
|
||||||
|
pub selected_manifest_digest: [u8; 32],
|
||||||
|
pub id_map: IdMap,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum PrepareRecovery {
|
||||||
|
ClearedUnmaterializedIntent,
|
||||||
|
RemovedOwnedStage,
|
||||||
|
PublishedCompletedStage,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ReclaimOutcome {
|
||||||
|
Removed,
|
||||||
|
ReplacementPreserved,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct PreparedLowerStore {
|
||||||
|
root: PathBuf,
|
||||||
|
catalog: Catalog,
|
||||||
|
limits: PreparationLimits,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PreparedLowerStore {
|
||||||
|
/// Creates the fixed prepared-lower directories beneath an existing Storage Root.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the root cannot be opened or any fixed path is not a real directory.
|
||||||
|
pub fn initialize(root: impl Into<PathBuf>, catalog: Catalog) -> Result<Self> {
|
||||||
|
let store = Self {
|
||||||
|
root: root.into(),
|
||||||
|
catalog,
|
||||||
|
limits: PreparationLimits::default(),
|
||||||
|
};
|
||||||
|
let root = File::open(&store.root).context("opening Storage Root")?;
|
||||||
|
for name in ["artifacts", "stages", "quarantine"] {
|
||||||
|
match mkdirat(&root, name, Mode::from_raw_mode(0o700)) {
|
||||||
|
Ok(()) | Err(rustix::io::Errno::EXIST) => {}
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
}
|
||||||
|
open_directory_at(&root, Path::new(name))?;
|
||||||
|
}
|
||||||
|
open_lock_at(&root)?;
|
||||||
|
Ok(store)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_limits(mut self, limits: PreparationLimits) -> Self {
|
||||||
|
self.limits = limits;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Materializes and atomically publishes one verified immutable prepared lower.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Refuses corrupt or unsupported OCI input, forged physical ownership, catalog conflicts,
|
||||||
|
/// resource limits, and publication failures.
|
||||||
|
pub fn prepare(&self, request: &PrepareRequest) -> Result<PreparedArtifact> {
|
||||||
|
if request.source_identity.is_empty() || request.source_identity.len() > 4096 {
|
||||||
|
bail!("source identity is empty or exceeds 4096 bytes");
|
||||||
|
}
|
||||||
|
let verified = verify_layout(&request.layout_path, self.limits)?;
|
||||||
|
let identities = Identities::new(request)?;
|
||||||
|
if let Some(existing) = self.available_artifact(identities.artifact_id)? {
|
||||||
|
identities.require_equivalent(&existing)?;
|
||||||
|
self.require_artifact_identity(&existing)?;
|
||||||
|
return Ok(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
let stage_relative = stage_path(request.operation_id);
|
||||||
|
let intent = identities.intent(request.operation_id, &stage_relative);
|
||||||
|
self.catalog.record_prepare(&intent)?;
|
||||||
|
let stage = match self.create_stage(&stage_relative) {
|
||||||
|
Ok(stage) => stage,
|
||||||
|
Err(error) => {
|
||||||
|
let _ = self.catalog.remove_prepare(&intent);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (device, inode) = file_identity(&stage)?;
|
||||||
|
let owned = PrepareOperation {
|
||||||
|
stage_state: PrepareStageState::Owned,
|
||||||
|
device: Some(device),
|
||||||
|
inode: Some(inode),
|
||||||
|
..intent.clone()
|
||||||
|
};
|
||||||
|
if let Err(error) = self.catalog.bind_prepare_stage(&intent, &owned) {
|
||||||
|
if self
|
||||||
|
.remove_exact_path(&stage_relative, device, inode)
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
let _ = self.catalog.remove_prepare(&intent);
|
||||||
|
}
|
||||||
|
return Err(error.into());
|
||||||
|
}
|
||||||
|
if let Err(error) = apply_layout(
|
||||||
|
&request.layout_path,
|
||||||
|
&stage,
|
||||||
|
&verified,
|
||||||
|
&request.id_map,
|
||||||
|
self.limits,
|
||||||
|
)
|
||||||
|
.and_then(|()| sync_tree(&stage))
|
||||||
|
{
|
||||||
|
if self
|
||||||
|
.remove_exact_path(&stage_relative, device, inode)
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
let _ = self.catalog.remove_prepare(&owned);
|
||||||
|
}
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
self.publish_owned(&owned, &identities)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recovers one exact durable prepare operation without adopting foreign paths.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Refuses unexpected paths, missing ownership evidence, identity mismatch, and catalog or
|
||||||
|
/// filesystem failures.
|
||||||
|
pub fn recover_prepare(&self, operation: &PrepareOperation) -> Result<PrepareRecovery> {
|
||||||
|
let expected_stage = stage_path(operation.operation_id);
|
||||||
|
if operation.stage_path.as_bytes() != path_bytes(&expected_stage) {
|
||||||
|
bail!("prepare operation stage path is not canonical");
|
||||||
|
}
|
||||||
|
match operation.stage_state {
|
||||||
|
PrepareStageState::Intent => {
|
||||||
|
if self.open_relative(&expected_stage)?.is_some() {
|
||||||
|
bail!("unowned stage path is present");
|
||||||
|
}
|
||||||
|
self.catalog.remove_prepare(operation)?;
|
||||||
|
Ok(PrepareRecovery::ClearedUnmaterializedIntent)
|
||||||
|
}
|
||||||
|
PrepareStageState::Owned => {
|
||||||
|
let device = operation.device.context("owned prepare lacks device")?;
|
||||||
|
let inode = operation.inode.context("owned prepare lacks inode")?;
|
||||||
|
if self.open_relative(&expected_stage)?.is_some() {
|
||||||
|
self.remove_exact_path(&expected_stage, device, inode)?;
|
||||||
|
self.catalog.remove_prepare(operation)?;
|
||||||
|
return Ok(PrepareRecovery::RemovedOwnedStage);
|
||||||
|
}
|
||||||
|
let artifact_relative = artifact_path(operation.expected_artifact_id);
|
||||||
|
let Some(artifact_fd) = self.open_relative(&artifact_relative)? else {
|
||||||
|
bail!("owned preparation path is absent without exact recovery evidence");
|
||||||
|
};
|
||||||
|
if file_identity(&artifact_fd)? != (device, inode) {
|
||||||
|
bail!("published path does not match preparation ownership");
|
||||||
|
}
|
||||||
|
let artifact =
|
||||||
|
artifact_from_operation(operation, &artifact_relative, device, inode);
|
||||||
|
self.publish_catalog_row(operation, &artifact)?;
|
||||||
|
Ok(PrepareRecovery::PublishedCompletedStage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves an exact zero-lease artifact to quarantine, deletes it, and closes reclaim intent.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Refuses leases, forged catalog rows, physical identity mismatch, foreign paths, and
|
||||||
|
/// filesystem or catalog failures.
|
||||||
|
pub fn reclaim(&self, expected: &PreparedArtifact) -> Result<ReclaimOutcome> {
|
||||||
|
let reclaiming = self.catalog.begin_reclaim(expected)?;
|
||||||
|
self.resume_reclaim(&reclaiming)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resumes an exact durable reclaim after interruption.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Refuses non-reclaiming state, path/identity mismatch, missing physical evidence, and
|
||||||
|
/// catalog or filesystem failures.
|
||||||
|
pub fn resume_reclaim(&self, reclaiming: &PreparedArtifact) -> Result<ReclaimOutcome> {
|
||||||
|
if reclaiming.state != ArtifactState::Reclaiming {
|
||||||
|
bail!("artifact is not reclaiming");
|
||||||
|
}
|
||||||
|
let expected_artifact = artifact_path(reclaiming.prepared_artifact_id);
|
||||||
|
if reclaiming.path.as_bytes() != path_bytes(&expected_artifact) {
|
||||||
|
bail!("artifact path is not canonical");
|
||||||
|
}
|
||||||
|
let quarantine = quarantine_path(reclaiming);
|
||||||
|
{
|
||||||
|
let _lock = self.physical_lock()?;
|
||||||
|
if let Some(canonical) = self.open_relative(&expected_artifact)? {
|
||||||
|
require_identity(&canonical, reclaiming.device, reclaiming.inode)?;
|
||||||
|
self.rename_relative(&expected_artifact, &quarantine)?;
|
||||||
|
self.sync_fixed_directory("artifacts")?;
|
||||||
|
self.sync_fixed_directory("quarantine")?;
|
||||||
|
} else {
|
||||||
|
let existing = self
|
||||||
|
.open_relative(&quarantine)?
|
||||||
|
.context("reclaiming artifact and quarantine are both absent")?;
|
||||||
|
require_identity(&existing, reclaiming.device, reclaiming.inode)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.remove_exact_path(&quarantine, reclaiming.device, reclaiming.inode)?;
|
||||||
|
match self.catalog.finish_reclaim(reclaiming) {
|
||||||
|
Ok(()) => Ok(ReclaimOutcome::Removed),
|
||||||
|
Err(CatalogError::OwnershipMismatch {
|
||||||
|
row: CatalogRow::PreparedArtifact,
|
||||||
|
}) => {
|
||||||
|
let replacement = self
|
||||||
|
.available_artifact(reclaiming.prepared_artifact_id)?
|
||||||
|
.context("reclaim row changed without an available replacement")?;
|
||||||
|
self.require_artifact_identity(&replacement)?;
|
||||||
|
Ok(ReclaimOutcome::ReplacementPreserved)
|
||||||
|
}
|
||||||
|
Err(error) => Err(error.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn publish_owned(
|
||||||
|
&self,
|
||||||
|
operation: &PrepareOperation,
|
||||||
|
identities: &Identities,
|
||||||
|
) -> Result<PreparedArtifact> {
|
||||||
|
let stage = stage_path(operation.operation_id);
|
||||||
|
let artifact_path = artifact_path(operation.expected_artifact_id);
|
||||||
|
let _lock = self.physical_lock()?;
|
||||||
|
if let Some(existing) = self.open_relative(&artifact_path)? {
|
||||||
|
if let Some(row) = self.available_artifact(operation.expected_artifact_id)? {
|
||||||
|
identities.require_equivalent(&row)?;
|
||||||
|
require_identity(&existing, row.device, row.inode)?;
|
||||||
|
let device = operation.device.context("owned prepare lacks device")?;
|
||||||
|
let inode = operation.inode.context("owned prepare lacks inode")?;
|
||||||
|
self.remove_exact_path(&stage, device, inode)?;
|
||||||
|
self.catalog.remove_prepare(operation)?;
|
||||||
|
return Ok(row);
|
||||||
|
}
|
||||||
|
let reclaiming = self
|
||||||
|
.catalog
|
||||||
|
.snapshot()?
|
||||||
|
.prepared_artifacts
|
||||||
|
.into_iter()
|
||||||
|
.find(|candidate| {
|
||||||
|
candidate.prepared_artifact_id == operation.expected_artifact_id
|
||||||
|
&& candidate.state == ArtifactState::Reclaiming
|
||||||
|
})
|
||||||
|
.context("canonical artifact path exists without matching catalog ownership")?;
|
||||||
|
require_identity(&existing, reclaiming.device, reclaiming.inode)?;
|
||||||
|
let quarantine = quarantine_path(&reclaiming);
|
||||||
|
self.rename_relative(&artifact_path, &quarantine)?;
|
||||||
|
self.sync_fixed_directory("quarantine")?;
|
||||||
|
}
|
||||||
|
self.rename_relative(&stage, &artifact_path)?;
|
||||||
|
self.sync_fixed_directory("artifacts")?;
|
||||||
|
let artifact_fd = self
|
||||||
|
.open_relative(&artifact_path)?
|
||||||
|
.context("published artifact disappeared")?;
|
||||||
|
let (device, inode) = file_identity(&artifact_fd)?;
|
||||||
|
let artifact = identities.artifact(&artifact_path, device, inode);
|
||||||
|
self.publish_catalog_row(operation, &artifact)?;
|
||||||
|
Ok(artifact)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn publish_catalog_row(
|
||||||
|
&self,
|
||||||
|
operation: &PrepareOperation,
|
||||||
|
artifact: &PreparedArtifact,
|
||||||
|
) -> Result<()> {
|
||||||
|
let snapshot = self.catalog.snapshot()?;
|
||||||
|
if let Some(reclaiming) = snapshot.prepared_artifacts.into_iter().find(|candidate| {
|
||||||
|
candidate.prepared_artifact_id == artifact.prepared_artifact_id
|
||||||
|
&& candidate.state == ArtifactState::Reclaiming
|
||||||
|
}) {
|
||||||
|
self.catalog
|
||||||
|
.publish_replacement(operation, &reclaiming, artifact)?;
|
||||||
|
} else {
|
||||||
|
self.catalog.publish(operation, artifact)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn available_artifact(&self, id: PreparedArtifactId) -> Result<Option<PreparedArtifact>> {
|
||||||
|
Ok(self
|
||||||
|
.catalog
|
||||||
|
.snapshot()?
|
||||||
|
.prepared_artifacts
|
||||||
|
.into_iter()
|
||||||
|
.find(|artifact| {
|
||||||
|
artifact.prepared_artifact_id == id && artifact.state == ArtifactState::Available
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_artifact_identity(&self, artifact: &PreparedArtifact) -> Result<()> {
|
||||||
|
let path = PathBuf::from(std::ffi::OsString::from_vec(
|
||||||
|
artifact.path.as_bytes().to_vec(),
|
||||||
|
));
|
||||||
|
let expected = artifact_path(artifact.prepared_artifact_id);
|
||||||
|
if path != expected {
|
||||||
|
bail!("artifact path is not canonical");
|
||||||
|
}
|
||||||
|
let file = self
|
||||||
|
.open_relative(&path)?
|
||||||
|
.context("artifact path is absent")?;
|
||||||
|
require_identity(&file, artifact.device, artifact.inode)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_stage(&self, relative: &Path) -> Result<File> {
|
||||||
|
let stages = open_directory_at(&File::open(&self.root)?, Path::new("stages"))?;
|
||||||
|
let name = relative.file_name().context("stage has no name")?;
|
||||||
|
mkdirat(&stages, name, Mode::from_raw_mode(0o700))?;
|
||||||
|
open_directory_at(&stages, Path::new(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_relative(&self, relative: &Path) -> Result<Option<File>> {
|
||||||
|
let root = File::open(&self.root)?;
|
||||||
|
match openat2(
|
||||||
|
&root,
|
||||||
|
relative,
|
||||||
|
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||||
|
Mode::empty(),
|
||||||
|
secure_resolve(),
|
||||||
|
) {
|
||||||
|
Ok(fd) => Ok(Some(File::from(fd))),
|
||||||
|
Err(rustix::io::Errno::NOENT) => Ok(None),
|
||||||
|
Err(error) => Err(error.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rename_relative(&self, from: &Path, to: &Path) -> Result<()> {
|
||||||
|
let root = File::open(&self.root)?;
|
||||||
|
renameat(&root, from, &root, to)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_exact_path(&self, relative: &Path, device: DeviceId, inode: InodeId) -> Result<()> {
|
||||||
|
let root = File::open(&self.root)?;
|
||||||
|
let file = open_directory_at(&root, relative)?;
|
||||||
|
require_identity(&file, device, inode)?;
|
||||||
|
remove_children(&file)?;
|
||||||
|
unlinkat(&root, relative, AtFlags::REMOVEDIR)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn physical_lock(&self) -> Result<ExclusiveFileLock> {
|
||||||
|
let deadline = Instant::now() + PHYSICAL_LOCK_WAIT;
|
||||||
|
loop {
|
||||||
|
let file = open_lock_at(&File::open(&self.root)?)?;
|
||||||
|
match try_exclusive_flock(file) {
|
||||||
|
Ok(lock) => return Ok(lock),
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
|
||||||
|
let now = Instant::now();
|
||||||
|
if now >= deadline {
|
||||||
|
bail!("prepared-lower physical lock wait expired");
|
||||||
|
}
|
||||||
|
thread::sleep(LOCK_RETRY.min(deadline.saturating_duration_since(now)));
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_fixed_directory(&self, name: &str) -> Result<()> {
|
||||||
|
open_directory_at(&File::open(&self.root)?, Path::new(name))?.sync_all()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Identities {
|
||||||
|
artifact_id: PreparedArtifactId,
|
||||||
|
source: SourceIdentity,
|
||||||
|
manifest: SelectedManifestDigest,
|
||||||
|
policy: PolicyId,
|
||||||
|
id_map: IdMapDigest,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Identities {
|
||||||
|
fn new(request: &PrepareRequest) -> Result<Self> {
|
||||||
|
let source = SourceIdentity::new(request.source_identity.clone());
|
||||||
|
let manifest = SelectedManifestDigest::new(request.selected_manifest_digest);
|
||||||
|
let policy = PolicyId::new(hash_parts(&[b"lazaret-policy-v1", POLICY_NAME.as_bytes()]));
|
||||||
|
let id_map_bytes = postcard::to_stdvec(&request.id_map)?;
|
||||||
|
let id_map = IdMapDigest::new(hash_parts(&[b"lazaret-id-map-v1", &id_map_bytes]));
|
||||||
|
let artifact_id = PreparedArtifactId::new(hash_parts(&[
|
||||||
|
b"lazaret-prepared-artifact-v1",
|
||||||
|
source.as_bytes(),
|
||||||
|
manifest.as_bytes(),
|
||||||
|
policy.as_bytes(),
|
||||||
|
id_map.as_bytes(),
|
||||||
|
]));
|
||||||
|
Ok(Self {
|
||||||
|
artifact_id,
|
||||||
|
source,
|
||||||
|
manifest,
|
||||||
|
policy,
|
||||||
|
id_map,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn intent(&self, operation_id: PrepareOperationId, stage: &Path) -> PrepareOperation {
|
||||||
|
PrepareOperation {
|
||||||
|
operation_id,
|
||||||
|
expected_artifact_id: self.artifact_id,
|
||||||
|
source_identity: self.source.clone(),
|
||||||
|
selected_manifest_digest: self.manifest,
|
||||||
|
policy_id: self.policy,
|
||||||
|
id_map_digest: self.id_map,
|
||||||
|
stage_path: HostPath::new(path_bytes(stage).to_vec()),
|
||||||
|
stage_state: PrepareStageState::Intent,
|
||||||
|
device: None,
|
||||||
|
inode: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn artifact(&self, path: &Path, device: DeviceId, inode: InodeId) -> PreparedArtifact {
|
||||||
|
PreparedArtifact {
|
||||||
|
prepared_artifact_id: self.artifact_id,
|
||||||
|
source_identity: self.source.clone(),
|
||||||
|
selected_manifest_digest: self.manifest,
|
||||||
|
policy_id: self.policy,
|
||||||
|
id_map_digest: self.id_map,
|
||||||
|
path: HostPath::new(path_bytes(path).to_vec()),
|
||||||
|
device,
|
||||||
|
inode,
|
||||||
|
state: ArtifactState::Available,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_equivalent(&self, artifact: &PreparedArtifact) -> Result<()> {
|
||||||
|
if artifact.prepared_artifact_id != self.artifact_id
|
||||||
|
|| artifact.source_identity != self.source
|
||||||
|
|| artifact.selected_manifest_digest != self.manifest
|
||||||
|
|| artifact.policy_id != self.policy
|
||||||
|
|| artifact.id_map_digest != self.id_map
|
||||||
|
{
|
||||||
|
bail!("prepared artifact identity inputs differ");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn artifact_from_operation(
|
||||||
|
operation: &PrepareOperation,
|
||||||
|
path: &Path,
|
||||||
|
device: DeviceId,
|
||||||
|
inode: InodeId,
|
||||||
|
) -> PreparedArtifact {
|
||||||
|
PreparedArtifact {
|
||||||
|
prepared_artifact_id: operation.expected_artifact_id,
|
||||||
|
source_identity: operation.source_identity.clone(),
|
||||||
|
selected_manifest_digest: operation.selected_manifest_digest,
|
||||||
|
policy_id: operation.policy_id,
|
||||||
|
id_map_digest: operation.id_map_digest,
|
||||||
|
path: HostPath::new(path_bytes(path).to_vec()),
|
||||||
|
device,
|
||||||
|
inode,
|
||||||
|
state: ArtifactState::Available,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_identity(file: &File) -> Result<(DeviceId, InodeId)> {
|
||||||
|
let stat = rustix::fs::fstat(file)?;
|
||||||
|
Ok((DeviceId::new(stat.st_dev), InodeId::new(stat.st_ino)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_identity(file: &File, device: DeviceId, inode: InodeId) -> Result<()> {
|
||||||
|
if file_identity(file)? == (device, inode) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
bail!("physical path identity mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_directory_at(parent: impl AsFd, path: &Path) -> Result<File> {
|
||||||
|
Ok(File::from(openat2(
|
||||||
|
parent,
|
||||||
|
path,
|
||||||
|
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||||
|
Mode::empty(),
|
||||||
|
secure_resolve(),
|
||||||
|
)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_lock_at(root: impl AsFd) -> Result<File> {
|
||||||
|
Ok(File::from(openat2(
|
||||||
|
root,
|
||||||
|
"prepared-lower.lock",
|
||||||
|
OFlags::RDWR | OFlags::CREATE | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||||
|
Mode::from_raw_mode(0o600),
|
||||||
|
secure_resolve(),
|
||||||
|
)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_tree(directory: &File) -> Result<()> {
|
||||||
|
let mut buffer = [std::mem::MaybeUninit::uninit(); 8192];
|
||||||
|
let names = {
|
||||||
|
let mut names = Vec::new();
|
||||||
|
let mut entries = rustix::fs::RawDir::new(directory.as_fd(), &mut buffer);
|
||||||
|
while let Some(entry) = entries.next() {
|
||||||
|
let entry = entry?;
|
||||||
|
if entry.file_name().to_bytes() != b"." && entry.file_name().to_bytes() != b".." {
|
||||||
|
names.push(entry.file_name().to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names
|
||||||
|
};
|
||||||
|
for name in names {
|
||||||
|
let path_fd = openat2(
|
||||||
|
directory,
|
||||||
|
&name,
|
||||||
|
OFlags::PATH | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||||
|
Mode::empty(),
|
||||||
|
secure_resolve(),
|
||||||
|
)?;
|
||||||
|
let kind = rustix::fs::FileType::from_raw_mode(rustix::fs::fstat(&path_fd)?.st_mode);
|
||||||
|
if kind == rustix::fs::FileType::Directory {
|
||||||
|
let child = open_directory_at(
|
||||||
|
directory,
|
||||||
|
Path::new(std::ffi::OsStr::from_bytes(name.to_bytes())),
|
||||||
|
)?;
|
||||||
|
sync_tree(&child)?;
|
||||||
|
} else if kind == rustix::fs::FileType::RegularFile {
|
||||||
|
File::from(openat2(
|
||||||
|
directory,
|
||||||
|
&name,
|
||||||
|
OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||||
|
Mode::empty(),
|
||||||
|
secure_resolve(),
|
||||||
|
)?)
|
||||||
|
.sync_all()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
directory.sync_all()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_parts(parts: &[&[u8]]) -> [u8; 32] {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
for part in parts {
|
||||||
|
hasher.update((part.len() as u64).to_be_bytes());
|
||||||
|
hasher.update(part);
|
||||||
|
}
|
||||||
|
hasher.finalize().into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stage_path(id: PrepareOperationId) -> PathBuf {
|
||||||
|
Path::new("stages").join(hex(id.as_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn artifact_path(id: PreparedArtifactId) -> PathBuf {
|
||||||
|
Path::new("artifacts").join(hex(id.as_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn quarantine_path(artifact: &PreparedArtifact) -> PathBuf {
|
||||||
|
Path::new("quarantine").join(format!(
|
||||||
|
"{}-{}-{}",
|
||||||
|
hex(artifact.prepared_artifact_id.as_bytes()),
|
||||||
|
artifact.device.get(),
|
||||||
|
artifact.inode.get()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_bytes(path: &Path) -> &[u8] {
|
||||||
|
std::os::unix::ffi::OsStrExt::as_bytes(path.as_os_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex(bytes: &[u8]) -> String {
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
|
bytes.iter().fold(
|
||||||
|
String::with_capacity(bytes.len() * 2),
|
||||||
|
|mut output, byte| {
|
||||||
|
write!(output, "{byte:02x}").expect("writing to String cannot fail");
|
||||||
|
output
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn secure_resolve() -> ResolveFlags {
|
||||||
|
ResolveFlags::BENEATH | ResolveFlags::NO_SYMLINKS | ResolveFlags::NO_MAGICLINKS
|
||||||
|
}
|
||||||
442
crates/lazaret-prepared-lower/src/transport.rs
Normal file
442
crates/lazaret-prepared-lower/src/transport.rs
Normal file
|
|
@ -0,0 +1,442 @@
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::str::FromStr;
|
||||||
|
use std::{fs::OpenOptions, io::Write, os::unix::fs::PermissionsExt};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use oci_client::Reference;
|
||||||
|
use oci_client::client::{Client, ClientConfig};
|
||||||
|
use oci_client::manifest::{IMAGE_LAYER_GZIP_MEDIA_TYPE, IMAGE_LAYER_MEDIA_TYPE, OciImageManifest};
|
||||||
|
use oci_client::secrets::RegistryAuth;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
const OCI_LAYER_ZSTD: &str = "application/vnd.oci.image.layer.v1.tar+zstd";
|
||||||
|
const MAX_CONFIG_BYTES: usize = 16 * 1024 * 1024;
|
||||||
|
const MAX_LAYER_BYTES: u64 = 32 * 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct Platform {
|
||||||
|
pub os: String,
|
||||||
|
pub architecture: String,
|
||||||
|
pub variant: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Platform {
|
||||||
|
#[must_use]
|
||||||
|
pub fn linux(architecture: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
os: "linux".to_owned(),
|
||||||
|
architecture: architecture.into(),
|
||||||
|
variant: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Eq, PartialEq)]
|
||||||
|
pub enum RegistryCredentials {
|
||||||
|
Anonymous,
|
||||||
|
Basic { username: String, password: String },
|
||||||
|
Bearer(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for RegistryCredentials {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Anonymous => formatter.write_str("Anonymous"),
|
||||||
|
Self::Basic { username, .. } => formatter
|
||||||
|
.debug_struct("Basic")
|
||||||
|
.field("username", username)
|
||||||
|
.field("password", &"[redacted]")
|
||||||
|
.finish(),
|
||||||
|
Self::Bearer(_) => formatter
|
||||||
|
.debug_tuple("Bearer")
|
||||||
|
.field(&"[redacted]")
|
||||||
|
.finish(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct TransportedImage {
|
||||||
|
pub layout_path: PathBuf,
|
||||||
|
pub source_identity: Vec<u8>,
|
||||||
|
pub selected_manifest_digest: [u8; 32],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct OciRegistryTransport;
|
||||||
|
|
||||||
|
impl OciRegistryTransport {
|
||||||
|
/// Pulls one explicitly selected platform into a digest-verified private OCI layout.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Refuses invalid references, authentication failures, unsupported platforms, malformed
|
||||||
|
/// descriptors, resource limits, and transport or filesystem errors.
|
||||||
|
pub async fn pull(
|
||||||
|
&self,
|
||||||
|
reference: &str,
|
||||||
|
credentials: &RegistryCredentials,
|
||||||
|
platform: &Platform,
|
||||||
|
layout_path: &Path,
|
||||||
|
) -> Result<TransportedImage> {
|
||||||
|
self.pull_with_config(
|
||||||
|
reference,
|
||||||
|
credentials,
|
||||||
|
platform,
|
||||||
|
layout_path,
|
||||||
|
ClientConfig::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pull_with_config(
|
||||||
|
&self,
|
||||||
|
reference: &str,
|
||||||
|
credentials: &RegistryCredentials,
|
||||||
|
platform: &Platform,
|
||||||
|
layout_path: &Path,
|
||||||
|
mut config: ClientConfig,
|
||||||
|
) -> Result<TransportedImage> {
|
||||||
|
validate_credentials(credentials)?;
|
||||||
|
let reference = Reference::from_str(reference).context("invalid OCI image reference")?;
|
||||||
|
let selected_platform = platform.clone();
|
||||||
|
config.max_concurrent_download = 1;
|
||||||
|
config.platform_resolver = Some(Box::new(move |entries| {
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.find(|entry| {
|
||||||
|
entry.platform.as_ref().is_some_and(|candidate| {
|
||||||
|
candidate.os.to_string() == selected_platform.os
|
||||||
|
&& candidate.architecture.to_string() == selected_platform.architecture
|
||||||
|
&& candidate.variant == selected_platform.variant
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map(|entry| entry.digest.clone())
|
||||||
|
}));
|
||||||
|
let client = Client::new(config);
|
||||||
|
let auth = match credentials {
|
||||||
|
RegistryCredentials::Anonymous => RegistryAuth::Anonymous,
|
||||||
|
RegistryCredentials::Basic { username, password } => {
|
||||||
|
RegistryAuth::Basic(username.clone(), password.clone())
|
||||||
|
}
|
||||||
|
RegistryCredentials::Bearer(token) => RegistryAuth::Bearer(token.clone()),
|
||||||
|
};
|
||||||
|
let (manifest, manifest_digest, config_json) = client
|
||||||
|
.pull_manifest_and_config(&reference, &auth)
|
||||||
|
.await
|
||||||
|
.context("pulling selected OCI manifest and config")?;
|
||||||
|
if config_json.len() > MAX_CONFIG_BYTES {
|
||||||
|
bail!("OCI config exceeds {MAX_CONFIG_BYTES} bytes");
|
||||||
|
}
|
||||||
|
verify_platform(&config_json, platform)?;
|
||||||
|
validate_manifest(&manifest)?;
|
||||||
|
create_layout(layout_path)?;
|
||||||
|
let blobs = layout_path.join("blobs/sha256");
|
||||||
|
write_verified_blob(&blobs, &manifest.config.digest, config_json.as_bytes())?;
|
||||||
|
for descriptor in &manifest.layers {
|
||||||
|
let size = u64::try_from(descriptor.size).context("negative OCI layer size")?;
|
||||||
|
if size > MAX_LAYER_BYTES {
|
||||||
|
bail!("OCI layer exceeds {MAX_LAYER_BYTES} bytes");
|
||||||
|
}
|
||||||
|
let digest_component = digest_component(&descriptor.digest)?;
|
||||||
|
let path = blobs.join(digest_component);
|
||||||
|
if path.exists() {
|
||||||
|
require_existing_blob(&path, &descriptor.digest, size)?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let file = tokio::fs::OpenOptions::new()
|
||||||
|
.create_new(true)
|
||||||
|
.write(true)
|
||||||
|
.open(&path)
|
||||||
|
.await?;
|
||||||
|
if let Err(error) = client.pull_blob(&reference, descriptor, file).await {
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
return Err(error)
|
||||||
|
.with_context(|| format!("pulling OCI layer {}", descriptor.digest));
|
||||||
|
}
|
||||||
|
let observed = std::fs::metadata(&path)?.len();
|
||||||
|
if observed != size {
|
||||||
|
bail!("OCI layer {} size mismatch", descriptor.digest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let local_manifest = serde_json::to_vec(&manifest)?;
|
||||||
|
let local_digest = format!("sha256:{}", hex(&Sha256::digest(&local_manifest)));
|
||||||
|
write_verified_blob(&blobs, &local_digest, &local_manifest)?;
|
||||||
|
write_layout_index(layout_path, &local_digest, local_manifest.len())?;
|
||||||
|
let selected_manifest_digest = parse_sha256(&manifest_digest)?;
|
||||||
|
let source_identity = format!(
|
||||||
|
"oci-v1\0{}\0{}\0{manifest_digest}",
|
||||||
|
reference.registry(),
|
||||||
|
reference.repository()
|
||||||
|
)
|
||||||
|
.into_bytes();
|
||||||
|
Ok(TransportedImage {
|
||||||
|
layout_path: layout_path.to_owned(),
|
||||||
|
source_identity,
|
||||||
|
selected_manifest_digest,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ImageConfigPlatform {
|
||||||
|
os: String,
|
||||||
|
architecture: String,
|
||||||
|
#[serde(default)]
|
||||||
|
variant: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_platform(config: &str, expected: &Platform) -> Result<()> {
|
||||||
|
let observed: ImageConfigPlatform = serde_json::from_str(config)?;
|
||||||
|
if observed.os != expected.os
|
||||||
|
|| observed.architecture != expected.architecture
|
||||||
|
|| observed.variant != expected.variant
|
||||||
|
{
|
||||||
|
bail!(
|
||||||
|
"selected image platform {}/{} does not match requested {}/{}",
|
||||||
|
observed.os,
|
||||||
|
observed.architecture,
|
||||||
|
expected.os,
|
||||||
|
expected.architecture
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_credentials(credentials: &RegistryCredentials) -> Result<()> {
|
||||||
|
match credentials {
|
||||||
|
RegistryCredentials::Anonymous => Ok(()),
|
||||||
|
RegistryCredentials::Basic { username, password }
|
||||||
|
if !username.is_empty() && !password.is_empty() =>
|
||||||
|
{
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
RegistryCredentials::Bearer(token) if !token.is_empty() => Ok(()),
|
||||||
|
RegistryCredentials::Basic { .. } | RegistryCredentials::Bearer(_) => {
|
||||||
|
bail!("registry credentials are incomplete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_manifest(manifest: &OciImageManifest) -> Result<()> {
|
||||||
|
if manifest.schema_version != 2 || manifest.layers.len() > 256 {
|
||||||
|
bail!("unsupported OCI manifest schema or layer count");
|
||||||
|
}
|
||||||
|
for descriptor in &manifest.layers {
|
||||||
|
if !matches!(
|
||||||
|
descriptor.media_type.as_str(),
|
||||||
|
IMAGE_LAYER_MEDIA_TYPE | IMAGE_LAYER_GZIP_MEDIA_TYPE | OCI_LAYER_ZSTD
|
||||||
|
) {
|
||||||
|
bail!("unsupported OCI layer media type {}", descriptor.media_type);
|
||||||
|
}
|
||||||
|
digest_component(&descriptor.digest)?;
|
||||||
|
u64::try_from(descriptor.size).context("negative OCI layer size")?;
|
||||||
|
}
|
||||||
|
digest_component(&manifest.config.digest)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_layout(path: &Path) -> Result<()> {
|
||||||
|
std::fs::create_dir(path).context("creating private OCI layout")?;
|
||||||
|
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?;
|
||||||
|
std::fs::create_dir_all(path.join("blobs/sha256"))?;
|
||||||
|
std::fs::set_permissions(path.join("blobs"), std::fs::Permissions::from_mode(0o700))?;
|
||||||
|
std::fs::set_permissions(
|
||||||
|
path.join("blobs/sha256"),
|
||||||
|
std::fs::Permissions::from_mode(0o700),
|
||||||
|
)?;
|
||||||
|
std::fs::write(
|
||||||
|
path.join("oci-layout"),
|
||||||
|
b"{\"imageLayoutVersion\":\"1.0.0\"}\n",
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_verified_blob(blobs: &Path, digest: &str, bytes: &[u8]) -> Result<()> {
|
||||||
|
if parse_sha256(digest)? != Sha256::digest(bytes).as_slice() {
|
||||||
|
bail!("OCI descriptor digest mismatch for {digest}");
|
||||||
|
}
|
||||||
|
let path = blobs.join(digest_component(digest)?);
|
||||||
|
match OpenOptions::new().create_new(true).write(true).open(&path) {
|
||||||
|
Ok(mut file) => file.write_all(bytes)?,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||||
|
require_existing_blob(&path, digest, bytes.len() as u64)?;
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_existing_blob(path: &Path, digest: &str, size: u64) -> Result<()> {
|
||||||
|
let bytes = std::fs::read(path)?;
|
||||||
|
if bytes.len() as u64 != size || parse_sha256(digest)? != Sha256::digest(&bytes).as_slice() {
|
||||||
|
bail!("existing OCI blob does not match its descriptor");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct LayoutIndex<'a> {
|
||||||
|
schema_version: u8,
|
||||||
|
manifests: [LayoutDescriptor<'a>; 1],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct LayoutDescriptor<'a> {
|
||||||
|
media_type: &'a str,
|
||||||
|
digest: &'a str,
|
||||||
|
size: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_layout_index(path: &Path, digest: &str, size: usize) -> Result<()> {
|
||||||
|
let index = LayoutIndex {
|
||||||
|
schema_version: 2,
|
||||||
|
manifests: [LayoutDescriptor {
|
||||||
|
media_type: "application/vnd.oci.image.manifest.v1+json",
|
||||||
|
digest,
|
||||||
|
size,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
std::fs::write(path.join("index.json"), serde_json::to_vec(&index)?)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn digest_component(digest: &str) -> Result<&str> {
|
||||||
|
let component = digest
|
||||||
|
.strip_prefix("sha256:")
|
||||||
|
.context("only sha256 OCI descriptors are supported")?;
|
||||||
|
if component.len() != 64 || !component.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||||
|
bail!("invalid sha256 OCI descriptor");
|
||||||
|
}
|
||||||
|
Ok(component)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_sha256(digest: &str) -> Result<[u8; 32]> {
|
||||||
|
let component = digest_component(digest)?;
|
||||||
|
let mut bytes = [0; 32];
|
||||||
|
for (index, pair) in component.as_bytes().chunks_exact(2).enumerate() {
|
||||||
|
bytes[index] = u8::from_str_radix(std::str::from_utf8(pair)?, 16)?;
|
||||||
|
}
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex(bytes: &[u8]) -> String {
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
|
bytes.iter().fold(
|
||||||
|
String::with_capacity(bytes.len() * 2),
|
||||||
|
|mut output, byte| {
|
||||||
|
write!(output, "{byte:02x}").expect("writing to String cannot fail");
|
||||||
|
output
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::net::TcpListener;
|
||||||
|
|
||||||
|
use oci_client::client::ClientProtocol;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refuses_mismatched_and_incomplete_platforms() {
|
||||||
|
assert!(
|
||||||
|
verify_platform(
|
||||||
|
r#"{"os":"linux","architecture":"amd64"}"#,
|
||||||
|
&Platform::linux("amd64")
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
verify_platform(
|
||||||
|
r#"{"os":"linux","architecture":"arm64"}"#,
|
||||||
|
&Platform::linux("amd64")
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
verify_platform(
|
||||||
|
r#"{"os":"linux","architecture":"amd64","variant":"v2"}"#,
|
||||||
|
&Platform::linux("amd64")
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(verify_platform(r#"{"architecture":"amd64"}"#, &Platform::linux("amd64")).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn incomplete_authentication_refuses_without_layout_or_secret_disclosure() {
|
||||||
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let result = runtime.block_on(OciRegistryTransport.pull_with_config(
|
||||||
|
"registry.invalid/private/image:latest",
|
||||||
|
&RegistryCredentials::Basic {
|
||||||
|
username: "developer".to_owned(),
|
||||||
|
password: String::new(),
|
||||||
|
},
|
||||||
|
&Platform::linux("amd64"),
|
||||||
|
&temp.path().join("layout"),
|
||||||
|
ClientConfig::default(),
|
||||||
|
));
|
||||||
|
let error = result.unwrap_err().to_string();
|
||||||
|
assert!(error.contains("credentials are incomplete"));
|
||||||
|
assert!(!error.contains("developer"));
|
||||||
|
assert!(!temp.path().join("layout").exists());
|
||||||
|
assert_eq!(
|
||||||
|
format!(
|
||||||
|
"{:?}",
|
||||||
|
RegistryCredentials::Bearer("not-a-real-token".to_owned())
|
||||||
|
),
|
||||||
|
"Bearer(\"[redacted]\")"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registry_authentication_failure_refuses_before_layout_creation() {
|
||||||
|
let listener = match TcpListener::bind("127.0.0.1:0") {
|
||||||
|
Ok(listener) => listener,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => return,
|
||||||
|
Err(error) => panic!("binding test registry: {error}"),
|
||||||
|
};
|
||||||
|
let address = listener.local_addr().unwrap();
|
||||||
|
let server = std::thread::spawn(move || {
|
||||||
|
let (mut stream, _) = listener.accept().unwrap();
|
||||||
|
let mut request = [0; 4096];
|
||||||
|
assert!(stream.read(&mut request).unwrap() > 0);
|
||||||
|
stream
|
||||||
|
.write_all(
|
||||||
|
b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let result = runtime.block_on(OciRegistryTransport.pull_with_config(
|
||||||
|
&format!("{address}/private/image:latest"),
|
||||||
|
&RegistryCredentials::Basic {
|
||||||
|
username: "wrong".to_owned(),
|
||||||
|
password: "wrong".to_owned(),
|
||||||
|
},
|
||||||
|
&Platform::linux("amd64"),
|
||||||
|
&temp.path().join("layout"),
|
||||||
|
ClientConfig {
|
||||||
|
protocol: ClientProtocol::Http,
|
||||||
|
..ClientConfig::default()
|
||||||
|
},
|
||||||
|
));
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(!temp.path().join("layout").exists());
|
||||||
|
server.join().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
742
crates/lazaret-prepared-lower/tests/prepared_lower.rs
Normal file
742
crates/lazaret-prepared-lower/tests/prepared_lower.rs
Normal file
|
|
@ -0,0 +1,742 @@
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{Cursor, Read, Write};
|
||||||
|
use std::os::unix::ffi::OsStringExt;
|
||||||
|
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, Barrier};
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
use flate2::Compression;
|
||||||
|
use flate2::write::GzEncoder;
|
||||||
|
use lazaret_core::identity::{
|
||||||
|
DeviceId, HostPath, IdMapDigest, InodeId, PolicyId, PrepareOperationId, PreparedArtifactId,
|
||||||
|
SelectedManifestDigest, SourceIdentity,
|
||||||
|
};
|
||||||
|
use lazaret_core::records::{PrepareOperation, PrepareStageState};
|
||||||
|
use lazaret_prepared_lower::{
|
||||||
|
IdMap, IdMapRange, PrepareRecovery, PrepareRequest, PreparedLowerStore, ReclaimOutcome,
|
||||||
|
};
|
||||||
|
use lazaret_storage_catalog::{Catalog, CatalogError};
|
||||||
|
use serde_json::json;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use tar::{Builder, EntryType, Header};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
const OCI_TAR: &str = "application/vnd.oci.image.layer.v1.tar";
|
||||||
|
const OCI_GZIP: &str = "application/vnd.oci.image.layer.v1.tar+gzip";
|
||||||
|
const OCI_ZSTD: &str = "application/vnd.oci.image.layer.v1.tar+zstd";
|
||||||
|
|
||||||
|
struct Fixture {
|
||||||
|
directory: TempDir,
|
||||||
|
root: PathBuf,
|
||||||
|
catalog: Catalog,
|
||||||
|
store: PreparedLowerStore,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Fixture {
|
||||||
|
fn new() -> Self {
|
||||||
|
let directory = TempDir::new().unwrap();
|
||||||
|
let root = directory.path().join("storage");
|
||||||
|
std::fs::create_dir(&root).unwrap();
|
||||||
|
let catalog = Catalog::new(root.join("catalog.redb"), root.join("storage-catalog.lock"));
|
||||||
|
catalog.initialize().unwrap();
|
||||||
|
let store = PreparedLowerStore::initialize(&root, catalog.clone()).unwrap();
|
||||||
|
Self {
|
||||||
|
directory,
|
||||||
|
root,
|
||||||
|
catalog,
|
||||||
|
store,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request(&self, operation: u8, layers: &[(Vec<u8>, &str)]) -> PrepareRequest {
|
||||||
|
let (layout_path, manifest) = layout(self.directory.path(), operation, layers);
|
||||||
|
PrepareRequest {
|
||||||
|
operation_id: PrepareOperationId::new([operation; 32]),
|
||||||
|
layout_path,
|
||||||
|
source_identity: b"oci-v1\0registry.invalid\0fixture\0sha256:source".to_vec(),
|
||||||
|
selected_manifest_digest: manifest,
|
||||||
|
id_map: current_id_map(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn artifact_root(&self, artifact: &lazaret_core::records::PreparedArtifact) -> PathBuf {
|
||||||
|
self.root.join(PathBuf::from(std::ffi::OsString::from_vec(
|
||||||
|
artifact.path.as_bytes().to_vec(),
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct Entry<'a> {
|
||||||
|
path: &'a str,
|
||||||
|
kind: EntryType,
|
||||||
|
data: &'a [u8],
|
||||||
|
target: Option<&'a str>,
|
||||||
|
mode: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn layer(entries: &[Entry<'_>]) -> Vec<u8> {
|
||||||
|
let uid = rustix::process::geteuid().as_raw();
|
||||||
|
let gid = rustix::process::getegid().as_raw();
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
{
|
||||||
|
let mut builder = Builder::new(&mut bytes);
|
||||||
|
for entry in entries {
|
||||||
|
let mut header = Header::new_gnu();
|
||||||
|
header.set_path(entry.path).unwrap();
|
||||||
|
header.set_entry_type(entry.kind);
|
||||||
|
header.set_size(entry.data.len() as u64);
|
||||||
|
header.set_uid(u64::from(uid));
|
||||||
|
header.set_gid(u64::from(gid));
|
||||||
|
header.set_mode(entry.mode);
|
||||||
|
header.set_mtime(1_600_000_123);
|
||||||
|
if let Some(target) = entry.target {
|
||||||
|
header.set_link_name(target).unwrap();
|
||||||
|
}
|
||||||
|
header.set_cksum();
|
||||||
|
builder.append(&header, Cursor::new(entry.data)).unwrap();
|
||||||
|
}
|
||||||
|
builder.finish().unwrap();
|
||||||
|
}
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
fn xattr_layer() -> Vec<u8> {
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
{
|
||||||
|
let mut builder = Builder::new(&mut bytes);
|
||||||
|
builder
|
||||||
|
.append_pax_extensions([("SCHILY.xattr.user.fixture", b"value".as_slice())])
|
||||||
|
.unwrap();
|
||||||
|
let uid = rustix::process::geteuid().as_raw();
|
||||||
|
let gid = rustix::process::getegid().as_raw();
|
||||||
|
let mut header = Header::new_gnu();
|
||||||
|
header.set_path("payload").unwrap();
|
||||||
|
header.set_entry_type(EntryType::Regular);
|
||||||
|
header.set_size(1);
|
||||||
|
header.set_uid(u64::from(uid));
|
||||||
|
header.set_gid(u64::from(gid));
|
||||||
|
header.set_mode(0o644);
|
||||||
|
header.set_cksum();
|
||||||
|
builder.append(&header, Cursor::new(b"x")).unwrap();
|
||||||
|
builder.finish().unwrap();
|
||||||
|
}
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compress(bytes: &[u8], media_type: &str) -> Vec<u8> {
|
||||||
|
match media_type {
|
||||||
|
OCI_TAR => bytes.to_vec(),
|
||||||
|
OCI_GZIP => {
|
||||||
|
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
|
||||||
|
encoder.write_all(bytes).unwrap();
|
||||||
|
encoder.finish().unwrap()
|
||||||
|
}
|
||||||
|
OCI_ZSTD => zstd::encode_all(bytes, 1).unwrap(),
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn layout(root: &Path, seed: u8, layers: &[(Vec<u8>, &str)]) -> (PathBuf, [u8; 32]) {
|
||||||
|
let path = root.join(format!("layout-{seed}"));
|
||||||
|
let blobs = path.join("blobs/sha256");
|
||||||
|
std::fs::create_dir_all(&blobs).unwrap();
|
||||||
|
let mut diff_ids = Vec::new();
|
||||||
|
let descriptors: Vec<_> = layers
|
||||||
|
.iter()
|
||||||
|
.map(|(bytes, media_type)| {
|
||||||
|
let uncompressed = match *media_type {
|
||||||
|
OCI_GZIP => {
|
||||||
|
let mut output = Vec::new();
|
||||||
|
flate2::read::GzDecoder::new(bytes.as_slice())
|
||||||
|
.read_to_end(&mut output)
|
||||||
|
.unwrap();
|
||||||
|
output
|
||||||
|
}
|
||||||
|
OCI_ZSTD => zstd::decode_all(bytes.as_slice()).unwrap(),
|
||||||
|
_ => bytes.clone(),
|
||||||
|
};
|
||||||
|
diff_ids.push(format!("sha256:{}", hex(&Sha256::digest(&uncompressed))));
|
||||||
|
let digest = format!("sha256:{}", hex(&Sha256::digest(bytes)));
|
||||||
|
std::fs::write(blobs.join(&digest[7..]), bytes).unwrap();
|
||||||
|
json!({"mediaType": media_type, "digest": digest, "size": bytes.len()})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let config = serde_json::to_vec(&json!({
|
||||||
|
"architecture": "amd64",
|
||||||
|
"os": "linux",
|
||||||
|
"rootfs": {"type": "layers", "diff_ids": diff_ids}
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
let config_digest = format!("sha256:{}", hex(&Sha256::digest(&config)));
|
||||||
|
std::fs::write(blobs.join(&config_digest[7..]), &config).unwrap();
|
||||||
|
let manifest = serde_json::to_vec(&json!({
|
||||||
|
"schemaVersion": 2,
|
||||||
|
"config": {"mediaType": "application/vnd.oci.image.config.v1+json", "digest": config_digest, "size": config.len()},
|
||||||
|
"layers": descriptors
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
let manifest_hash: [u8; 32] = Sha256::digest(&manifest).into();
|
||||||
|
let manifest_digest = format!("sha256:{}", hex(&manifest_hash));
|
||||||
|
std::fs::write(blobs.join(&manifest_digest[7..]), &manifest).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
path.join("index.json"),
|
||||||
|
serde_json::to_vec(&json!({
|
||||||
|
"schemaVersion": 2,
|
||||||
|
"manifests": [{"mediaType": "application/vnd.oci.image.manifest.v1+json", "digest": manifest_digest, "size": manifest.len()}]
|
||||||
|
}))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
path.join("oci-layout"),
|
||||||
|
b"{\"imageLayoutVersion\":\"1.0.0\"}\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
(path, manifest_hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_id_map() -> IdMap {
|
||||||
|
IdMap {
|
||||||
|
uid: vec![IdMapRange {
|
||||||
|
container_id: rustix::process::geteuid().as_raw(),
|
||||||
|
namespace_id: rustix::process::geteuid().as_raw(),
|
||||||
|
size: 1,
|
||||||
|
}],
|
||||||
|
gid: vec![IdMapRange {
|
||||||
|
container_id: rustix::process::getegid().as_raw(),
|
||||||
|
namespace_id: rustix::process::getegid().as_raw(),
|
||||||
|
size: 1,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lower_entries() -> Vec<Entry<'static>> {
|
||||||
|
vec![
|
||||||
|
Entry {
|
||||||
|
path: "gone",
|
||||||
|
kind: EntryType::Regular,
|
||||||
|
data: b"gone",
|
||||||
|
target: None,
|
||||||
|
mode: 0o644,
|
||||||
|
},
|
||||||
|
Entry {
|
||||||
|
path: "opaque",
|
||||||
|
kind: EntryType::Directory,
|
||||||
|
data: b"",
|
||||||
|
target: None,
|
||||||
|
mode: 0o755,
|
||||||
|
},
|
||||||
|
Entry {
|
||||||
|
path: "opaque/old",
|
||||||
|
kind: EntryType::Regular,
|
||||||
|
data: b"old",
|
||||||
|
target: None,
|
||||||
|
mode: 0o644,
|
||||||
|
},
|
||||||
|
Entry {
|
||||||
|
path: "directory-to-file",
|
||||||
|
kind: EntryType::Directory,
|
||||||
|
data: b"",
|
||||||
|
target: None,
|
||||||
|
mode: 0o755,
|
||||||
|
},
|
||||||
|
Entry {
|
||||||
|
path: "directory-to-file/child",
|
||||||
|
kind: EntryType::Regular,
|
||||||
|
data: b"child",
|
||||||
|
target: None,
|
||||||
|
mode: 0o644,
|
||||||
|
},
|
||||||
|
Entry {
|
||||||
|
path: "file-to-directory",
|
||||||
|
kind: EntryType::Regular,
|
||||||
|
data: b"file",
|
||||||
|
target: None,
|
||||||
|
mode: 0o644,
|
||||||
|
},
|
||||||
|
Entry {
|
||||||
|
path: "target",
|
||||||
|
kind: EntryType::Regular,
|
||||||
|
data: b"linked",
|
||||||
|
target: None,
|
||||||
|
mode: 0o2755,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upper_entries() -> Vec<Entry<'static>> {
|
||||||
|
vec![
|
||||||
|
Entry {
|
||||||
|
path: ".wh.gone",
|
||||||
|
kind: EntryType::Regular,
|
||||||
|
data: b"",
|
||||||
|
target: None,
|
||||||
|
mode: 0o000,
|
||||||
|
},
|
||||||
|
Entry {
|
||||||
|
path: "opaque/new",
|
||||||
|
kind: EntryType::Regular,
|
||||||
|
data: b"new",
|
||||||
|
target: None,
|
||||||
|
mode: 0o644,
|
||||||
|
},
|
||||||
|
Entry {
|
||||||
|
path: "opaque/.wh..wh..opq",
|
||||||
|
kind: EntryType::Regular,
|
||||||
|
data: b"",
|
||||||
|
target: None,
|
||||||
|
mode: 0o000,
|
||||||
|
},
|
||||||
|
Entry {
|
||||||
|
path: "directory-to-file",
|
||||||
|
kind: EntryType::Regular,
|
||||||
|
data: b"replacement",
|
||||||
|
target: None,
|
||||||
|
mode: 0o600,
|
||||||
|
},
|
||||||
|
Entry {
|
||||||
|
path: "file-to-directory",
|
||||||
|
kind: EntryType::Directory,
|
||||||
|
data: b"",
|
||||||
|
target: None,
|
||||||
|
mode: 0o755,
|
||||||
|
},
|
||||||
|
Entry {
|
||||||
|
path: "file-to-directory/new",
|
||||||
|
kind: EntryType::Regular,
|
||||||
|
data: b"new",
|
||||||
|
target: None,
|
||||||
|
mode: 0o644,
|
||||||
|
},
|
||||||
|
Entry {
|
||||||
|
path: "symbolic",
|
||||||
|
kind: EntryType::Symlink,
|
||||||
|
data: b"",
|
||||||
|
target: Some("target"),
|
||||||
|
mode: 0o777,
|
||||||
|
},
|
||||||
|
Entry {
|
||||||
|
path: "hard",
|
||||||
|
kind: EntryType::Link,
|
||||||
|
data: b"",
|
||||||
|
target: Some("target"),
|
||||||
|
mode: 0o644,
|
||||||
|
},
|
||||||
|
Entry {
|
||||||
|
path: "forward",
|
||||||
|
kind: EntryType::Link,
|
||||||
|
data: b"",
|
||||||
|
target: Some("later"),
|
||||||
|
mode: 0o644,
|
||||||
|
},
|
||||||
|
Entry {
|
||||||
|
path: "later",
|
||||||
|
kind: EntryType::Regular,
|
||||||
|
data: b"forward",
|
||||||
|
target: None,
|
||||||
|
mode: 0o4755,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn semantic_corpus_preserves_supported_changesets_and_read_only_lower_use() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let first = layer(&lower_entries());
|
||||||
|
let second = layer(&upper_entries());
|
||||||
|
let request = fixture.request(
|
||||||
|
1,
|
||||||
|
&[
|
||||||
|
(compress(&first, OCI_GZIP), OCI_GZIP),
|
||||||
|
(compress(&second, OCI_ZSTD), OCI_ZSTD),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let artifact = fixture.store.prepare(&request).unwrap();
|
||||||
|
let root = fixture.artifact_root(&artifact);
|
||||||
|
assert!(!root.join("gone").exists());
|
||||||
|
assert!(!root.join("opaque/old").exists());
|
||||||
|
assert_eq!(std::fs::read(root.join("opaque/new")).unwrap(), b"new");
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(root.join("directory-to-file")).unwrap(),
|
||||||
|
b"replacement"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(root.join("file-to-directory/new")).unwrap(),
|
||||||
|
b"new"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_link(root.join("symbolic")).unwrap(),
|
||||||
|
Path::new("target")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::metadata(root.join("hard")).unwrap().ino(),
|
||||||
|
std::fs::metadata(root.join("target")).unwrap().ino()
|
||||||
|
);
|
||||||
|
assert_eq!(std::fs::read(root.join("forward")).unwrap(), b"forward");
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::metadata(root.join("later"))
|
||||||
|
.unwrap()
|
||||||
|
.permissions()
|
||||||
|
.mode()
|
||||||
|
& 0o7777,
|
||||||
|
0o4755
|
||||||
|
);
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&request.layout_path).unwrap();
|
||||||
|
let mut lower = File::open(root.join("directory-to-file")).unwrap();
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
lower.read_to_end(&mut bytes).unwrap();
|
||||||
|
assert_eq!(bytes, b"replacement");
|
||||||
|
assert!(
|
||||||
|
fixture
|
||||||
|
.root
|
||||||
|
.join("stages")
|
||||||
|
.read_dir()
|
||||||
|
.unwrap()
|
||||||
|
.next()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn root_opaque_whiteout_removes_only_lower_layer_entries() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let lower = layer(&[Entry {
|
||||||
|
path: "old",
|
||||||
|
kind: EntryType::Regular,
|
||||||
|
data: b"old",
|
||||||
|
target: None,
|
||||||
|
mode: 0o644,
|
||||||
|
}]);
|
||||||
|
let upper = layer(&[
|
||||||
|
Entry {
|
||||||
|
path: "new",
|
||||||
|
kind: EntryType::Regular,
|
||||||
|
data: b"new",
|
||||||
|
target: None,
|
||||||
|
mode: 0o644,
|
||||||
|
},
|
||||||
|
Entry {
|
||||||
|
path: ".wh..wh..opq",
|
||||||
|
kind: EntryType::Regular,
|
||||||
|
data: b"",
|
||||||
|
target: None,
|
||||||
|
mode: 0o000,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
let request = fixture.request(2, &[(lower, OCI_TAR), (upper, OCI_TAR)]);
|
||||||
|
let artifact = fixture.store.prepare(&request).unwrap();
|
||||||
|
let root = fixture.artifact_root(&artifact);
|
||||||
|
assert!(!root.join("old").exists());
|
||||||
|
assert_eq!(std::fs::read(root.join("new")).unwrap(), b"new");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hostile_and_corrupt_layers_refuse_without_publication() {
|
||||||
|
let cases = [
|
||||||
|
layer(&[Entry {
|
||||||
|
path: "pipe",
|
||||||
|
kind: EntryType::Fifo,
|
||||||
|
data: b"",
|
||||||
|
target: None,
|
||||||
|
mode: 0o644,
|
||||||
|
}]),
|
||||||
|
layer(&[Entry {
|
||||||
|
path: "escape",
|
||||||
|
kind: EntryType::Link,
|
||||||
|
data: b"",
|
||||||
|
target: Some("../outside"),
|
||||||
|
mode: 0o644,
|
||||||
|
}]),
|
||||||
|
xattr_layer(),
|
||||||
|
];
|
||||||
|
for (index, bytes) in cases.into_iter().enumerate() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let request = fixture.request(u8::try_from(index).unwrap() + 10, &[(bytes, OCI_TAR)]);
|
||||||
|
assert!(fixture.store.prepare(&request).is_err());
|
||||||
|
let snapshot = fixture.catalog.snapshot().unwrap();
|
||||||
|
assert!(snapshot.prepared_artifacts.is_empty());
|
||||||
|
assert!(snapshot.prepare_operations.is_empty());
|
||||||
|
assert!(
|
||||||
|
fixture
|
||||||
|
.root
|
||||||
|
.join("stages")
|
||||||
|
.read_dir()
|
||||||
|
.unwrap()
|
||||||
|
.next()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let bytes = layer(&[Entry {
|
||||||
|
path: "file",
|
||||||
|
kind: EntryType::Regular,
|
||||||
|
data: b"content",
|
||||||
|
target: None,
|
||||||
|
mode: 0o644,
|
||||||
|
}]);
|
||||||
|
let request = fixture.request(20, &[(bytes, OCI_TAR)]);
|
||||||
|
let blob = request
|
||||||
|
.layout_path
|
||||||
|
.join("blobs/sha256")
|
||||||
|
.read_dir()
|
||||||
|
.unwrap()
|
||||||
|
.map(|entry| entry.unwrap().path())
|
||||||
|
.find(|path| std::fs::metadata(path).unwrap().len() > 1024)
|
||||||
|
.unwrap();
|
||||||
|
OpenOptionsExt::append_byte(&blob);
|
||||||
|
assert!(fixture.store.prepare(&request).is_err());
|
||||||
|
assert!(
|
||||||
|
fixture
|
||||||
|
.catalog
|
||||||
|
.snapshot()
|
||||||
|
.unwrap()
|
||||||
|
.prepared_artifacts
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OpenOptionsExt;
|
||||||
|
|
||||||
|
impl OpenOptionsExt {
|
||||||
|
fn append_byte(path: &Path) {
|
||||||
|
let mut file = std::fs::OpenOptions::new().append(true).open(path).unwrap();
|
||||||
|
file.write_all(b"x").unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn high_expansion_layer_is_disk_spooled_and_published() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let payload = vec![0u8; 32 * 1024 * 1024];
|
||||||
|
let tar = layer(&[Entry {
|
||||||
|
path: "expanded",
|
||||||
|
kind: EntryType::Regular,
|
||||||
|
data: &payload,
|
||||||
|
target: None,
|
||||||
|
mode: 0o600,
|
||||||
|
}]);
|
||||||
|
let compressed = compress(&tar, OCI_GZIP);
|
||||||
|
assert!(compressed.len() * 100 < payload.len());
|
||||||
|
let request = fixture.request(30, &[(compressed, OCI_GZIP)]);
|
||||||
|
let artifact = fixture.store.prepare(&request).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::metadata(fixture.artifact_root(&artifact).join("expanded"))
|
||||||
|
.unwrap()
|
||||||
|
.len(),
|
||||||
|
payload.len() as u64
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
fixture
|
||||||
|
.root
|
||||||
|
.join("stages")
|
||||||
|
.read_dir()
|
||||||
|
.unwrap()
|
||||||
|
.next()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn concurrent_publication_reuses_one_immutable_artifact() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let bytes = layer(&[Entry {
|
||||||
|
path: "payload",
|
||||||
|
kind: EntryType::Regular,
|
||||||
|
data: b"same",
|
||||||
|
target: None,
|
||||||
|
mode: 0o644,
|
||||||
|
}]);
|
||||||
|
let first = fixture.request(40, &[(bytes.clone(), OCI_TAR)]);
|
||||||
|
let mut second = PrepareRequest {
|
||||||
|
operation_id: PrepareOperationId::new([41; 32]),
|
||||||
|
..first.clone()
|
||||||
|
};
|
||||||
|
second.layout_path = first.layout_path.clone();
|
||||||
|
let store = Arc::new(fixture.store.clone());
|
||||||
|
let barrier = Arc::new(Barrier::new(3));
|
||||||
|
let first_handle = {
|
||||||
|
let store = Arc::clone(&store);
|
||||||
|
let barrier = Arc::clone(&barrier);
|
||||||
|
thread::spawn(move || {
|
||||||
|
barrier.wait();
|
||||||
|
store.prepare(&first)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let second_handle = {
|
||||||
|
let store = Arc::clone(&store);
|
||||||
|
let barrier = Arc::clone(&barrier);
|
||||||
|
thread::spawn(move || {
|
||||||
|
barrier.wait();
|
||||||
|
store.prepare(&second)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
barrier.wait();
|
||||||
|
let first = first_handle.join().unwrap().unwrap();
|
||||||
|
let second = second_handle.join().unwrap().unwrap();
|
||||||
|
assert_eq!(first, second);
|
||||||
|
let snapshot = fixture.catalog.snapshot().unwrap();
|
||||||
|
assert_eq!(snapshot.prepared_artifacts, vec![first]);
|
||||||
|
assert!(snapshot.prepare_operations.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn publication_during_reclaim_preserves_replacement_and_deletes_old_inode() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let bytes = layer(&[Entry {
|
||||||
|
path: "payload",
|
||||||
|
kind: EntryType::Regular,
|
||||||
|
data: b"same",
|
||||||
|
target: None,
|
||||||
|
mode: 0o644,
|
||||||
|
}]);
|
||||||
|
let first_request = fixture.request(50, &[(bytes, OCI_TAR)]);
|
||||||
|
let first = fixture.store.prepare(&first_request).unwrap();
|
||||||
|
let reclaiming = fixture.catalog.begin_reclaim(&first).unwrap();
|
||||||
|
let replacement_request = PrepareRequest {
|
||||||
|
operation_id: PrepareOperationId::new([51; 32]),
|
||||||
|
..first_request
|
||||||
|
};
|
||||||
|
let barrier = Arc::new(Barrier::new(3));
|
||||||
|
let store = Arc::new(fixture.store.clone());
|
||||||
|
let reclaim_handle = {
|
||||||
|
let barrier = Arc::clone(&barrier);
|
||||||
|
let store = Arc::clone(&store);
|
||||||
|
thread::spawn(move || {
|
||||||
|
barrier.wait();
|
||||||
|
store.resume_reclaim(&reclaiming)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let publish_handle = {
|
||||||
|
let barrier = Arc::clone(&barrier);
|
||||||
|
let store = Arc::clone(&store);
|
||||||
|
thread::spawn(move || {
|
||||||
|
barrier.wait();
|
||||||
|
store.prepare(&replacement_request)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
barrier.wait();
|
||||||
|
let reclaim = reclaim_handle.join().unwrap().unwrap();
|
||||||
|
let replacement = publish_handle.join().unwrap().unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
reclaim,
|
||||||
|
ReclaimOutcome::Removed | ReclaimOutcome::ReplacementPreserved
|
||||||
|
));
|
||||||
|
assert_ne!(replacement.inode, first.inode);
|
||||||
|
assert_eq!(
|
||||||
|
fixture.catalog.snapshot().unwrap().prepared_artifacts,
|
||||||
|
vec![replacement]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
fixture
|
||||||
|
.root
|
||||||
|
.join("quarantine")
|
||||||
|
.read_dir()
|
||||||
|
.unwrap()
|
||||||
|
.next()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovery_deletes_only_exact_owned_stage_and_preserves_foreign_stage() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let operation_id = PrepareOperationId::new([60; 32]);
|
||||||
|
let stage = Path::new("stages").join(hex(operation_id.as_bytes()));
|
||||||
|
let intent = PrepareOperation {
|
||||||
|
operation_id,
|
||||||
|
expected_artifact_id: PreparedArtifactId::new([61; 32]),
|
||||||
|
source_identity: SourceIdentity::new(b"source".to_vec()),
|
||||||
|
selected_manifest_digest: SelectedManifestDigest::new([62; 32]),
|
||||||
|
policy_id: PolicyId::new([63; 32]),
|
||||||
|
id_map_digest: IdMapDigest::new([64; 32]),
|
||||||
|
stage_path: HostPath::new(stage.as_os_str().as_encoded_bytes().to_vec()),
|
||||||
|
stage_state: PrepareStageState::Intent,
|
||||||
|
device: None,
|
||||||
|
inode: None,
|
||||||
|
};
|
||||||
|
fixture.catalog.record_prepare(&intent).unwrap();
|
||||||
|
std::fs::create_dir(fixture.root.join(&stage)).unwrap();
|
||||||
|
std::fs::write(fixture.root.join(&stage).join("foreign"), b"keep").unwrap();
|
||||||
|
assert!(fixture.store.recover_prepare(&intent).is_err());
|
||||||
|
assert!(fixture.root.join(&stage).join("foreign").exists());
|
||||||
|
std::fs::remove_dir_all(fixture.root.join(&stage)).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
fixture.store.recover_prepare(&intent).unwrap(),
|
||||||
|
PrepareRecovery::ClearedUnmaterializedIntent
|
||||||
|
);
|
||||||
|
|
||||||
|
let owned_id = PrepareOperationId::new([65; 32]);
|
||||||
|
let owned_stage = Path::new("stages").join(hex(owned_id.as_bytes()));
|
||||||
|
let owned_intent = PrepareOperation {
|
||||||
|
operation_id: owned_id,
|
||||||
|
stage_path: HostPath::new(owned_stage.as_os_str().as_encoded_bytes().to_vec()),
|
||||||
|
..intent
|
||||||
|
};
|
||||||
|
fixture.catalog.record_prepare(&owned_intent).unwrap();
|
||||||
|
std::fs::create_dir(fixture.root.join(&owned_stage)).unwrap();
|
||||||
|
std::fs::write(fixture.root.join(&owned_stage).join("partial"), b"partial").unwrap();
|
||||||
|
let metadata = std::fs::metadata(fixture.root.join(&owned_stage)).unwrap();
|
||||||
|
let owned = PrepareOperation {
|
||||||
|
stage_state: PrepareStageState::Owned,
|
||||||
|
device: Some(DeviceId::new(metadata.dev())),
|
||||||
|
inode: Some(InodeId::new(metadata.ino())),
|
||||||
|
..owned_intent.clone()
|
||||||
|
};
|
||||||
|
fixture
|
||||||
|
.catalog
|
||||||
|
.bind_prepare_stage(&owned_intent, &owned)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
fixture.store.recover_prepare(&owned).unwrap(),
|
||||||
|
PrepareRecovery::RemovedOwnedStage
|
||||||
|
);
|
||||||
|
assert!(!fixture.root.join(&owned_stage).exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reclaim_refuses_forged_identity_and_leases() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let bytes = layer(&[Entry {
|
||||||
|
path: "payload",
|
||||||
|
kind: EntryType::Regular,
|
||||||
|
data: b"same",
|
||||||
|
target: None,
|
||||||
|
mode: 0o644,
|
||||||
|
}]);
|
||||||
|
let artifact = fixture
|
||||||
|
.store
|
||||||
|
.prepare(&fixture.request(70, &[(bytes, OCI_TAR)]))
|
||||||
|
.unwrap();
|
||||||
|
let lease = lazaret_core::records::ArtifactLease {
|
||||||
|
prepared_artifact_id: artifact.prepared_artifact_id,
|
||||||
|
domain_id: lazaret_core::identity::DomainId::new([71; 32]),
|
||||||
|
generation_id: lazaret_core::identity::GenerationId::new(1),
|
||||||
|
};
|
||||||
|
fixture.catalog.acquire_lease(&lease).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
fixture.store.reclaim(&artifact),
|
||||||
|
Err(error)
|
||||||
|
if matches!(error.downcast_ref::<CatalogError>(), Some(CatalogError::LeasesPresent))
|
||||||
|
));
|
||||||
|
fixture.catalog.release_lease(&lease).unwrap();
|
||||||
|
let mut forged = artifact.clone();
|
||||||
|
forged.inode = InodeId::new(artifact.inode.get() + 1);
|
||||||
|
assert!(fixture.store.reclaim(&forged).is_err());
|
||||||
|
assert!(fixture.artifact_root(&artifact).exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex(bytes: &[u8]) -> String {
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
|
bytes.iter().fold(
|
||||||
|
String::with_capacity(bytes.len() * 2),
|
||||||
|
|mut output, byte| {
|
||||||
|
write!(output, "{byte:02x}").expect("writing to String cannot fail");
|
||||||
|
output
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue