499 lines
16 KiB
Rust
499 lines
16 KiB
Rust
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 std::time::{Duration, Instant};
|
|
|
|
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, 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;
|