use std::fs::File; use std::io::{Cursor, Read, Write}; use std::os::unix::ffi::{OsStrExt, 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_linux::filesystem::try_exclusive_flock; use lazaret_prepared_lower::{ IdMap, IdMapRange, PrepareRecovery, PrepareRequest, PreparedDestination, PreparedDestinationKind, PreparedLowerError, 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 MOUNT_CHILD_ENV: &str = "LAZARET_PREPARED_LOWER_MOUNT_CHILD"; 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 artifact_root = Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../target/test-artifacts/prepared-lower"); std::fs::create_dir_all(&artifact_root).unwrap(); let directory = tempfile::Builder::new() .prefix("fixture-") .tempdir_in(artifact_root) .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, &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(), destinations: Vec::new(), } } 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(), ))) } fn independent_store(&self) -> (Catalog, PreparedLowerStore) { let catalog = Catalog::new( self.root.join("catalog.redb"), self.root.join("storage-catalog.lock"), ); let store = PreparedLowerStore::initialize(&self.root, catalog.clone()).unwrap(); (catalog, store) } } #[test] fn preexisting_fixed_children_require_exact_private_mode() { for name in ["artifacts", "stages", "quarantine"] { let fixture = Fixture::new(); std::fs::set_permissions( fixture.root.join(name), std::fs::Permissions::from_mode(0o777), ) .unwrap(); assert!( matches!( PreparedLowerStore::initialize(&fixture.root, fixture.catalog.clone()), Err(PreparedLowerError::Confinement { .. }) ), "accepted wrong mode for {name}" ); } } #[test] fn published_artifact_authority_is_revalidated_beneath_the_retained_root() { let fixture = Fixture::new(); let request = fixture.request( 1, &[( layer(&[Entry { path: "payload", kind: EntryType::Regular, data: b"content", target: None, mode: 0o644, }]), OCI_TAR, )], ); let artifact = fixture.store.prepare(&request).unwrap(); let authority = fixture.store.open_artifact_authority(&artifact).unwrap(); let metadata = authority.metadata().unwrap(); assert_eq!(metadata.dev(), artifact.device.get()); assert_eq!(metadata.ino(), artifact.inode.get()); } #[test] fn replaced_published_artifact_is_refused() { let fixture = Fixture::new(); let request = fixture.request( 1, &[( layer(&[Entry { path: "payload", kind: EntryType::Regular, data: b"content", target: None, mode: 0o644, }]), OCI_TAR, )], ); let artifact = fixture.store.prepare(&request).unwrap(); let artifact_root = fixture.artifact_root(&artifact); let displaced = fixture.root.join("displaced-artifact"); std::fs::rename(&artifact_root, &displaced).unwrap(); std::fs::create_dir(&artifact_root).unwrap(); assert!(fixture.store.open_artifact_authority(&artifact).is_err()); } #[test] #[allow(clippy::disallowed_methods, clippy::disallowed_types)] fn retained_fixed_child_bind_mount_is_refused_when_mount_setup_is_available() { if std::env::var_os(MOUNT_CHILD_ENV).is_some() { let directory = TempDir::new().unwrap(); let root = directory.path().join("storage"); let source = directory.path().join("source"); std::fs::create_dir(&root).unwrap(); std::fs::create_dir(&source).unwrap(); let catalog = Catalog::new(root.join("catalog.redb"), root.join("storage-catalog.lock")); catalog.initialize().unwrap(); PreparedLowerStore::initialize(&root, catalog.clone()).unwrap(); let target = root.join("artifacts"); if let Err(error) = rustix::mount::mount_bind(&source, &target) { eprintln!( "skipping physical mount-crossing assertion: bind mount unavailable: {error}" ); return; } let result = PreparedLowerStore::initialize(&root, catalog); rustix::mount::unmount(&target, rustix::mount::UnmountFlags::DETACH).unwrap(); assert!(result.is_err()); return; } let output = std::process::Command::new("unshare") .args([ "--user", "--map-root-user", "--mount", "--propagation", "private", "--", ]) .arg(std::env::current_exe().unwrap()) .args([ "--exact", "retained_fixed_child_bind_mount_is_refused_when_mount_setup_is_available", ]) .env(MOUNT_CHILD_ENV, "1") .output(); let output = match output { Ok(output) => output, Err(error) => { eprintln!("skipping physical mount-crossing assertion: unshare unavailable: {error}"); return; } }; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); if stderr.contains("Operation not permitted") { eprintln!("skipping physical mount-crossing assertion: {stderr}"); return; } panic!("mount-crossing child failed: {stderr}"); } } #[derive(Clone)] struct Entry<'a> { path: &'a str, kind: EntryType, data: &'a [u8], target: Option<&'a str>, mode: u32, } fn layer(entries: &[Entry<'_>]) -> Vec { let uid = rustix::process::geteuid().as_raw(); let gid = rustix::process::getegid().as_raw(); layer_with_owner(entries, uid, gid) } fn layer_with_owner(entries: &[Entry<'_>], uid: u32, gid: u32) -> Vec { 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 { 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 { 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, &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> { 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> { 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, }, ] } use publication::hex; #[path = "prepared_lower/publication.rs"] mod publication; #[path = "prepared_lower/semantics.rs"] mod semantics;