lazaret/crates/lazaret-prepared-lower/tests/prepared_lower.rs

499 lines
16 KiB
Rust

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<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(),
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<u8> {
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<u8> {
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,
},
]
}
use publication::hex;
#[path = "prepared_lower/publication.rs"]
mod publication;
#[path = "prepared_lower/semantics.rs"]
mod semantics;