mirror of
https://github.com/marcoallegretti/karapace.git
synced 2026-03-26 21:43:09 +00:00
2443 lines
81 KiB
Rust
2443 lines
81 KiB
Rust
#![allow(unsafe_code)]
|
|
|
|
use karapace_core::{Engine, StoreLock};
|
|
use karapace_store::{EnvState, StoreLayout};
|
|
use std::fs;
|
|
use std::os::unix::fs::PermissionsExt;
|
|
use std::path::Path;
|
|
use std::sync::{Arc, Barrier};
|
|
use std::thread;
|
|
|
|
/// Skip test if running as root — root bypasses filesystem permission checks,
|
|
/// so read-only directory tests are meaningless in containers running as uid 0.
|
|
fn skip_if_root() -> bool {
|
|
#[allow(unsafe_code)]
|
|
unsafe {
|
|
libc::getuid() == 0
|
|
}
|
|
}
|
|
|
|
fn write_manifest(dir: &Path, content: &str) -> std::path::PathBuf {
|
|
let path = dir.join("karapace.toml");
|
|
fs::write(&path, content).unwrap();
|
|
path
|
|
}
|
|
|
|
fn mock_manifest(packages: &[&str]) -> String {
|
|
format!(
|
|
r#"
|
|
manifest_version = 1
|
|
[base]
|
|
image = "rolling"
|
|
[system]
|
|
packages = [{}]
|
|
[runtime]
|
|
backend = "mock"
|
|
"#,
|
|
packages
|
|
.iter()
|
|
.map(|p| format!("\"{p}\""))
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn build_destroy_rebuild_produces_identical_env_id() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git", "clang"]));
|
|
|
|
let r1 = engine.build(&manifest).unwrap();
|
|
let id1 = r1.identity.env_id.clone();
|
|
|
|
engine.destroy(&id1).unwrap();
|
|
|
|
let meta_store = karapace_store::MetadataStore::new(StoreLayout::new(store.path()));
|
|
meta_store.remove(&id1).unwrap();
|
|
|
|
let r2 = engine.build(&manifest).unwrap();
|
|
assert_eq!(
|
|
id1, r2.identity.env_id,
|
|
"rebuild after destroy must produce identical env_id"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn multi_environment_isolation() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let p1 = tempfile::tempdir().unwrap();
|
|
let p2 = tempfile::tempdir().unwrap();
|
|
|
|
let m1 = write_manifest(p1.path(), &mock_manifest(&["git"]));
|
|
let m2 = write_manifest(p2.path(), &mock_manifest(&["cmake"]));
|
|
|
|
let r1 = engine.build(&m1).unwrap();
|
|
let r2 = engine.build(&m2).unwrap();
|
|
|
|
assert_ne!(r1.identity.env_id, r2.identity.env_id);
|
|
|
|
let envs = engine.list().unwrap();
|
|
assert_eq!(envs.len(), 2);
|
|
|
|
engine.destroy(&r1.identity.env_id).unwrap();
|
|
let envs = engine.list().unwrap();
|
|
assert_eq!(envs.len(), 1);
|
|
|
|
let meta = engine.inspect(&r2.identity.env_id).unwrap();
|
|
assert_eq!(meta.state, EnvState::Built);
|
|
}
|
|
|
|
// §11.2: GC safety under load
|
|
#[test]
|
|
fn gc_safety_with_multiple_environments() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let mut env_ids = Vec::new();
|
|
for i in 0..10 {
|
|
let p = tempfile::tempdir().unwrap();
|
|
let pkgs: Vec<String> = (0..=i).map(|j| format!("pkg{j}")).collect();
|
|
let pkg_refs: Vec<&str> = pkgs.iter().map(String::as_str).collect();
|
|
let m = write_manifest(p.path(), &mock_manifest(&pkg_refs));
|
|
let r = engine.build(&m).unwrap();
|
|
env_ids.push(r.identity.env_id);
|
|
}
|
|
|
|
assert_eq!(engine.list().unwrap().len(), 10);
|
|
|
|
for id in &env_ids[..5] {
|
|
engine.destroy(id).unwrap();
|
|
}
|
|
|
|
let layout = StoreLayout::new(store.path());
|
|
let lock = StoreLock::acquire(&layout.lock_file()).unwrap();
|
|
let _report = engine.gc(&lock, false).unwrap();
|
|
|
|
for id in &env_ids[5..] {
|
|
let meta = engine.inspect(id).unwrap();
|
|
assert_eq!(meta.state, EnvState::Built, "active env should survive GC");
|
|
}
|
|
}
|
|
|
|
// §11.2: Concurrent build safety
|
|
#[test]
|
|
fn concurrent_builds_are_safe() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let store_path = store.path().to_path_buf();
|
|
|
|
let barrier = Arc::new(Barrier::new(4));
|
|
let mut handles = Vec::new();
|
|
|
|
for i in 0..4 {
|
|
let sp = store_path.clone();
|
|
let b = Arc::clone(&barrier);
|
|
handles.push(thread::spawn(move || {
|
|
let engine = Engine::new(&sp);
|
|
let p = tempfile::tempdir().unwrap();
|
|
let pkgs = [format!("thread-pkg-{i}")];
|
|
let pkg_refs: Vec<&str> = pkgs.iter().map(String::as_str).collect();
|
|
let m = write_manifest(p.path(), &mock_manifest(&pkg_refs));
|
|
|
|
b.wait();
|
|
engine.build(&m).unwrap()
|
|
}));
|
|
}
|
|
|
|
let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
|
|
|
|
let engine = Engine::new(&store_path);
|
|
let envs = engine.list().unwrap();
|
|
assert_eq!(envs.len(), 4, "all 4 concurrent builds should succeed");
|
|
|
|
let ids: std::collections::HashSet<_> =
|
|
results.iter().map(|r| r.identity.env_id.clone()).collect();
|
|
assert_eq!(ids.len(), 4, "all env IDs should be unique");
|
|
}
|
|
|
|
// §11.3: Reproducibility test
|
|
#[test]
|
|
fn same_manifest_produces_identical_env_id_across_engines() {
|
|
let manifest_content = mock_manifest(&["git", "clang", "cmake"]);
|
|
|
|
let store1 = tempfile::tempdir().unwrap();
|
|
let store2 = tempfile::tempdir().unwrap();
|
|
let p1 = tempfile::tempdir().unwrap();
|
|
let p2 = tempfile::tempdir().unwrap();
|
|
|
|
let m1 = write_manifest(p1.path(), &manifest_content);
|
|
let m2 = write_manifest(p2.path(), &manifest_content);
|
|
|
|
let engine1 = Engine::new(store1.path());
|
|
let engine2 = Engine::new(store2.path());
|
|
|
|
let r1 = engine1.build(&m1).unwrap();
|
|
let r2 = engine2.build(&m2).unwrap();
|
|
|
|
assert_eq!(
|
|
r1.identity.env_id, r2.identity.env_id,
|
|
"same manifest on different stores must produce identical env_id"
|
|
);
|
|
assert_eq!(r1.lock_file.env_id, r2.lock_file.env_id);
|
|
assert_eq!(
|
|
r1.lock_file.base_image_digest,
|
|
r2.lock_file.base_image_digest
|
|
);
|
|
}
|
|
|
|
// §16 Core invariant: host system remains untouched
|
|
#[test]
|
|
fn host_system_untouched_after_lifecycle() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let home_before: Vec<_> = fs::read_dir(std::env::var("HOME").unwrap_or("/tmp".to_owned()))
|
|
.unwrap()
|
|
.filter_map(Result::ok)
|
|
.map(|e| e.path())
|
|
.collect();
|
|
|
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git"]));
|
|
let r = engine.build(&manifest).unwrap();
|
|
engine.freeze(&r.identity.env_id).unwrap();
|
|
engine.destroy(&r.identity.env_id).unwrap();
|
|
|
|
let home_after: Vec<_> = fs::read_dir(std::env::var("HOME").unwrap_or("/tmp".to_owned()))
|
|
.unwrap()
|
|
.filter_map(Result::ok)
|
|
.map(|e| e.path())
|
|
.collect();
|
|
|
|
assert_eq!(home_before, home_after, "host HOME must not be modified");
|
|
}
|
|
|
|
// §16 Core invariant: no silent drift
|
|
#[test]
|
|
fn drift_always_observable() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git"]));
|
|
let r = engine.build(&manifest).unwrap();
|
|
|
|
// Clear the upper dir to simulate a clean post-build state.
|
|
// The mock backend creates files there during build; a real backend's
|
|
// overlay upper would also have content, but drift is measured from
|
|
// the post-build snapshot baseline.
|
|
let upper = engine.store_layout().upper_dir(&r.identity.env_id);
|
|
if upper.exists() {
|
|
fs::remove_dir_all(&upper).unwrap();
|
|
fs::create_dir_all(&upper).unwrap();
|
|
}
|
|
|
|
let report = karapace_core::diff_overlay(engine.store_layout(), &r.identity.env_id).unwrap();
|
|
assert!(!report.has_drift, "fresh build should have no drift");
|
|
|
|
fs::write(upper.join("injected.txt"), "mutation").unwrap();
|
|
|
|
let report = karapace_core::diff_overlay(engine.store_layout(), &r.identity.env_id).unwrap();
|
|
assert!(report.has_drift, "mutation must be detected");
|
|
assert!(report.added.contains(&"injected.txt".to_owned()));
|
|
}
|
|
|
|
// §15: Crash safety — interrupted write leaves no partial objects
|
|
#[test]
|
|
fn store_integrity_after_partial_operations() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let layout = StoreLayout::new(store.path());
|
|
layout.initialize().unwrap();
|
|
|
|
let obj_store = karapace_store::ObjectStore::new(layout.clone());
|
|
obj_store.put(b"valid1").unwrap();
|
|
obj_store.put(b"valid2").unwrap();
|
|
|
|
let stray_path = layout.objects_dir().join("not_a_real_hash");
|
|
fs::write(&stray_path, b"corrupted data").unwrap();
|
|
|
|
let report = karapace_store::verify_store_integrity(&layout).unwrap();
|
|
assert_eq!(report.failed.len(), 1, "corrupted object must be detected");
|
|
assert_eq!(report.passed, 2);
|
|
}
|
|
|
|
// §5.2: commit persists overlay into store
|
|
#[test]
|
|
fn commit_persists_overlay_drift() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git"]));
|
|
let r = engine.build(&manifest).unwrap();
|
|
|
|
// Clear mock backend artifacts from upper, then add user files
|
|
let upper = engine.store_layout().upper_dir(&r.identity.env_id);
|
|
if upper.exists() {
|
|
fs::remove_dir_all(&upper).unwrap();
|
|
}
|
|
fs::create_dir_all(&upper).unwrap();
|
|
fs::write(upper.join("user_file.txt"), "user data").unwrap();
|
|
fs::create_dir_all(upper.join("subdir")).unwrap();
|
|
fs::write(upper.join("subdir").join("nested.txt"), "nested data").unwrap();
|
|
|
|
let snapshot_hash = engine.commit(&r.identity.env_id).unwrap();
|
|
assert!(
|
|
!snapshot_hash.is_empty(),
|
|
"commit should return a snapshot hash"
|
|
);
|
|
|
|
// Look up the snapshot layer to get the tar_hash
|
|
let layer_store = karapace_store::LayerStore::new(StoreLayout::new(store.path()));
|
|
let layer = layer_store.get(&snapshot_hash).unwrap();
|
|
assert_eq!(layer.kind, karapace_store::LayerKind::Snapshot);
|
|
assert!(!layer.tar_hash.is_empty());
|
|
|
|
let obj_store = karapace_store::ObjectStore::new(StoreLayout::new(store.path()));
|
|
assert!(
|
|
obj_store.exists(&layer.tar_hash),
|
|
"committed tar object must exist in store"
|
|
);
|
|
|
|
// Verify the committed tar can be unpacked and contains the user files
|
|
let tar_data = obj_store.get(&layer.tar_hash).unwrap();
|
|
let unpack_dir = tempfile::tempdir().unwrap();
|
|
karapace_store::unpack_layer(&tar_data, unpack_dir.path()).unwrap();
|
|
assert!(unpack_dir.path().join("user_file.txt").exists());
|
|
assert!(unpack_dir.path().join("subdir").join("nested.txt").exists());
|
|
}
|
|
|
|
// INV-S1: build → modify → commit → restore → diff = zero drift
|
|
#[test]
|
|
fn restore_roundtrip() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git"]));
|
|
let r = engine.build(&manifest).unwrap();
|
|
|
|
// Clear mock artifacts and add user files
|
|
let upper = engine.store_layout().upper_dir(&r.identity.env_id);
|
|
if upper.exists() {
|
|
fs::remove_dir_all(&upper).unwrap();
|
|
}
|
|
fs::create_dir_all(&upper).unwrap();
|
|
fs::write(upper.join("user_file.txt"), "snapshot content").unwrap();
|
|
fs::create_dir_all(upper.join("data")).unwrap();
|
|
fs::write(upper.join("data").join("config.json"), r#"{"key":"val"}"#).unwrap();
|
|
|
|
// Commit the snapshot
|
|
let snapshot_hash = engine.commit(&r.identity.env_id).unwrap();
|
|
|
|
// Mutate the upper dir (simulating user modifications after snapshot)
|
|
fs::write(upper.join("user_file.txt"), "MODIFIED AFTER SNAPSHOT").unwrap();
|
|
fs::write(upper.join("extra.txt"), "extra file").unwrap();
|
|
|
|
// Restore from the snapshot
|
|
engine.restore(&r.identity.env_id, &snapshot_hash).unwrap();
|
|
|
|
// Verify the upper dir matches the snapshot content exactly
|
|
assert_eq!(
|
|
fs::read_to_string(upper.join("user_file.txt")).unwrap(),
|
|
"snapshot content"
|
|
);
|
|
assert_eq!(
|
|
fs::read_to_string(upper.join("data").join("config.json")).unwrap(),
|
|
r#"{"key":"val"}"#
|
|
);
|
|
// The extra file should NOT exist after restore
|
|
assert!(
|
|
!upper.join("extra.txt").exists(),
|
|
"extra file must be gone after restore"
|
|
);
|
|
}
|
|
|
|
// INV-S2: restore nonexistent snapshot → error
|
|
#[test]
|
|
fn restore_nonexistent_snapshot_fails() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git"]));
|
|
let r = engine.build(&manifest).unwrap();
|
|
|
|
let result = engine.restore(&r.identity.env_id, "nonexistent_hash");
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
// list_snapshots returns committed snapshots
|
|
#[test]
|
|
fn list_snapshots_after_commit() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git"]));
|
|
let r = engine.build(&manifest).unwrap();
|
|
|
|
// No snapshots initially
|
|
let snaps = engine.list_snapshots(&r.identity.env_id).unwrap();
|
|
assert!(snaps.is_empty());
|
|
|
|
// Commit a snapshot
|
|
let _hash = engine.commit(&r.identity.env_id).unwrap();
|
|
|
|
let snaps = engine.list_snapshots(&r.identity.env_id).unwrap();
|
|
assert_eq!(snaps.len(), 1);
|
|
assert_eq!(snaps[0].kind, karapace_store::LayerKind::Snapshot);
|
|
assert!(!snaps[0].tar_hash.is_empty());
|
|
}
|
|
|
|
// §12: GC scales to at least 100 environments
|
|
#[test]
|
|
fn gc_scales_to_100_environments() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
for i in 0..100 {
|
|
let p = tempfile::tempdir().unwrap();
|
|
let pkgs: Vec<String> = (0..=i).map(|j| format!("scale-pkg-{j}")).collect();
|
|
let pkg_refs: Vec<&str> = pkgs.iter().map(String::as_str).collect();
|
|
let m = write_manifest(p.path(), &mock_manifest(&pkg_refs));
|
|
engine.build(&m).unwrap();
|
|
}
|
|
|
|
assert_eq!(engine.list().unwrap().len(), 100);
|
|
|
|
let layout = StoreLayout::new(store.path());
|
|
let lock = StoreLock::acquire(&layout.lock_file()).unwrap();
|
|
let start = std::time::Instant::now();
|
|
let report = engine.gc(&lock, true).unwrap();
|
|
let elapsed = start.elapsed();
|
|
|
|
assert!(
|
|
elapsed.as_secs() < 10,
|
|
"GC dry-run on 100 envs took {elapsed:?}, must be under 10s"
|
|
);
|
|
assert_eq!(report.orphaned_envs.len(), 0, "all envs have ref_count > 0");
|
|
}
|
|
|
|
// §12: Warm cache build under 10 seconds
|
|
#[test]
|
|
fn warm_build_under_10_seconds() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git", "clang", "cmake"]));
|
|
|
|
// Cold build
|
|
engine.build(&manifest).unwrap();
|
|
|
|
// Warm rebuild (store already populated)
|
|
let start = std::time::Instant::now();
|
|
engine.rebuild(&manifest).unwrap();
|
|
let elapsed = start.elapsed();
|
|
|
|
assert!(
|
|
elapsed.as_secs() < 10,
|
|
"warm rebuild took {elapsed:?}, must be under 10s"
|
|
);
|
|
}
|
|
|
|
// §3.1: Build fails deterministically — different manifests get different IDs
|
|
#[test]
|
|
fn different_manifests_different_ids() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let p1 = tempfile::tempdir().unwrap();
|
|
let p2 = tempfile::tempdir().unwrap();
|
|
let m1 = write_manifest(p1.path(), &mock_manifest(&["git"]));
|
|
let m2 = write_manifest(p2.path(), &mock_manifest(&["git", "cmake"]));
|
|
|
|
let r1 = engine.build(&m1).unwrap();
|
|
let r2 = engine.build(&m2).unwrap();
|
|
assert_ne!(r1.identity.env_id, r2.identity.env_id);
|
|
}
|
|
|
|
// §5.1: Whiteout files detected as removed
|
|
#[test]
|
|
fn whiteout_files_detected_as_removals() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git"]));
|
|
let r = engine.build(&manifest).unwrap();
|
|
|
|
// Clear mock backend artifacts, then add only the whiteout
|
|
let upper = engine.store_layout().upper_dir(&r.identity.env_id);
|
|
if upper.exists() {
|
|
fs::remove_dir_all(&upper).unwrap();
|
|
}
|
|
fs::create_dir_all(&upper).unwrap();
|
|
// Overlayfs whiteout: .wh.filename means filename was deleted
|
|
fs::write(upper.join(".wh.deleted_config"), "").unwrap();
|
|
|
|
let report = karapace_core::diff_overlay(engine.store_layout(), &r.identity.env_id).unwrap();
|
|
assert!(report.has_drift);
|
|
assert!(report.removed.contains(&"deleted_config".to_owned()));
|
|
assert!(report.added.is_empty());
|
|
}
|
|
|
|
// §5.1: Modified files classified correctly when lower layer exists
|
|
#[test]
|
|
fn modified_files_detected_against_lower_layer() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git"]));
|
|
let r = engine.build(&manifest).unwrap();
|
|
|
|
// Clear mock backend artifacts from upper
|
|
let upper = engine.store_layout().upper_dir(&r.identity.env_id);
|
|
if upper.exists() {
|
|
fs::remove_dir_all(&upper).unwrap();
|
|
}
|
|
fs::create_dir_all(&upper).unwrap();
|
|
|
|
// Simulate a lower layer file
|
|
let env_dir = engine.store_layout().env_path(&r.identity.env_id);
|
|
let lower = env_dir.join("lower");
|
|
fs::create_dir_all(&lower).unwrap();
|
|
fs::write(lower.join("config.txt"), "original").unwrap();
|
|
|
|
// Same file in upper = modification
|
|
fs::write(upper.join("config.txt"), "modified content").unwrap();
|
|
// New file = added
|
|
fs::write(upper.join("new_script.sh"), "#!/bin/sh").unwrap();
|
|
|
|
let report = karapace_core::diff_overlay(engine.store_layout(), &r.identity.env_id).unwrap();
|
|
assert!(report.has_drift);
|
|
assert_eq!(report.modified, vec!["config.txt"]);
|
|
assert_eq!(report.added, vec!["new_script.sh"]);
|
|
assert!(report.removed.is_empty());
|
|
}
|
|
|
|
// §6.1: exec works via mock backend
|
|
#[test]
|
|
fn exec_via_mock_backend() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git"]));
|
|
let r = engine.build(&manifest).unwrap();
|
|
|
|
let result = engine.exec(&r.identity.env_id, &["echo".to_owned(), "hello".to_owned()]);
|
|
assert!(result.is_ok());
|
|
}
|
|
|
|
// §3.2: Lock file integrity verifiable after build
|
|
#[test]
|
|
fn lock_file_integrity_after_build() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git", "clang"]));
|
|
let r = engine.build(&manifest).unwrap();
|
|
|
|
// Lock file was written
|
|
let lock_path = project.path().join("karapace.lock");
|
|
assert!(lock_path.exists());
|
|
|
|
// Verify integrity
|
|
assert!(r.lock_file.verify_integrity().is_ok());
|
|
|
|
// Verify manifest intent
|
|
let parsed = karapace_schema::parse_manifest_file(&manifest).unwrap();
|
|
let normalized = parsed.normalize().unwrap();
|
|
assert!(r.lock_file.verify_manifest_intent(&normalized).is_ok());
|
|
}
|
|
|
|
// §5.2: Frozen environment cannot be entered
|
|
#[test]
|
|
fn frozen_env_cannot_be_entered() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git"]));
|
|
let r = engine.build(&manifest).unwrap();
|
|
engine.freeze(&r.identity.env_id).unwrap();
|
|
|
|
let result = engine.enter(&r.identity.env_id);
|
|
assert!(result.is_err(), "entering a frozen env must fail");
|
|
}
|
|
|
|
// §15: Crash injection — partial write must not corrupt store
|
|
#[test]
|
|
fn crash_injection_partial_write_detected() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let layout = StoreLayout::new(store.path());
|
|
layout.initialize().unwrap();
|
|
|
|
let obj_store = karapace_store::ObjectStore::new(layout.clone());
|
|
|
|
// Write valid objects
|
|
let h1 = obj_store.put(b"valid-object-1").unwrap();
|
|
let h2 = obj_store.put(b"valid-object-2").unwrap();
|
|
|
|
// Simulate a crash during write: create a file with a valid hash name
|
|
// but corrupted content (as if the process died mid-write and the
|
|
// atomic rename somehow partially completed — or manual tampering)
|
|
let fake_hash = blake3::hash(b"original-content").to_hex().to_string();
|
|
let fake_path = layout.objects_dir().join(&fake_hash);
|
|
fs::write(&fake_path, b"truncated-garbage").unwrap();
|
|
|
|
// Store integrity check must detect all corruption
|
|
let report = karapace_store::verify_store_integrity(&layout).unwrap();
|
|
assert_eq!(report.checked, 3);
|
|
assert_eq!(report.passed, 2, "two valid objects should pass");
|
|
assert_eq!(
|
|
report.failed.len(),
|
|
1,
|
|
"corrupted object should be detected"
|
|
);
|
|
assert_eq!(report.failed[0].hash, fake_hash);
|
|
|
|
// Valid objects must still be readable
|
|
assert!(obj_store.get(&h1).is_ok());
|
|
assert!(obj_store.get(&h2).is_ok());
|
|
|
|
// Corrupted object must fail on read
|
|
assert!(obj_store.get(&fake_hash).is_err());
|
|
}
|
|
|
|
// §15: Crash injection — version file corruption detected
|
|
#[test]
|
|
fn crash_injection_version_corruption_detected() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let layout = StoreLayout::new(store.path());
|
|
layout.initialize().unwrap();
|
|
|
|
// Corrupt the version file
|
|
let version_path = store.path().join("store").join("version");
|
|
fs::write(&version_path, r#"{"format_version": 999}"#).unwrap();
|
|
|
|
// Re-initializing should detect version mismatch
|
|
let result = layout.initialize();
|
|
assert!(result.is_err(), "mismatched version must be rejected");
|
|
}
|
|
|
|
// §12: Environment entry under 1 second (mock backend)
|
|
#[test]
|
|
fn environment_entry_under_1_second() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git"]));
|
|
let r = engine.build(&manifest).unwrap();
|
|
|
|
let start = std::time::Instant::now();
|
|
// Mock enter is effectively instant — this tests the overhead of
|
|
// metadata lookup, state transition, backend dispatch, and cleanup.
|
|
engine.enter(&r.identity.env_id).unwrap();
|
|
let elapsed = start.elapsed();
|
|
|
|
assert!(
|
|
elapsed.as_secs() < 1,
|
|
"environment entry took {elapsed:?}, must be under 1s"
|
|
);
|
|
}
|
|
|
|
// §4.1: Archive lifecycle — archive preserves but prevents entry
|
|
#[test]
|
|
fn archive_lifecycle() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git"]));
|
|
let r = engine.build(&manifest).unwrap();
|
|
|
|
// Archive from Built state
|
|
engine.archive(&r.identity.env_id).unwrap();
|
|
let meta = engine.inspect(&r.identity.env_id).unwrap();
|
|
assert_eq!(meta.state, EnvState::Archived);
|
|
|
|
// Archived env cannot be entered
|
|
let result = engine.enter(&r.identity.env_id);
|
|
assert!(result.is_err(), "entering an archived env must fail");
|
|
|
|
// Archived env can be rebuilt
|
|
let r2 = engine.rebuild(&manifest).unwrap();
|
|
let meta2 = engine.inspect(&r2.identity.env_id).unwrap();
|
|
assert_eq!(meta2.state, EnvState::Built);
|
|
}
|
|
|
|
// §4.1: Freeze then archive lifecycle
|
|
#[test]
|
|
fn freeze_then_archive() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git"]));
|
|
let r = engine.build(&manifest).unwrap();
|
|
|
|
engine.freeze(&r.identity.env_id).unwrap();
|
|
engine.archive(&r.identity.env_id).unwrap();
|
|
|
|
let meta = engine.inspect(&r.identity.env_id).unwrap();
|
|
assert_eq!(meta.state, EnvState::Archived);
|
|
}
|
|
|
|
// §4.1: Destroy of non-existent env fails gracefully
|
|
#[test]
|
|
fn destroy_nonexistent_env_fails_gracefully() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
let result = engine.destroy("0000000000000000000000000000000000000000000000000000000000000000");
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
// §3.2: Lock file v2 contains resolved package versions (not just names)
|
|
#[test]
|
|
fn lock_file_v2_has_resolved_versions() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git", "clang"]));
|
|
let r = engine.build(&manifest).unwrap();
|
|
|
|
assert_eq!(r.lock_file.lock_version, 2);
|
|
assert_eq!(r.lock_file.resolved_packages.len(), 2);
|
|
for pkg in &r.lock_file.resolved_packages {
|
|
assert!(!pkg.name.is_empty());
|
|
assert!(!pkg.version.is_empty());
|
|
assert_ne!(pkg.version, "unresolved");
|
|
}
|
|
}
|
|
|
|
// §6.2: Cannot destroy a running environment (must stop first)
|
|
#[test]
|
|
fn destroy_running_env_is_rejected() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git"]));
|
|
let r = engine.build(&manifest).unwrap();
|
|
|
|
// Simulate entering (mock backend sets state to Running)
|
|
engine.enter(&r.identity.env_id).unwrap();
|
|
|
|
// Now try to destroy — should fail because mock leaves it in Running
|
|
// Note: mock enter() sets internal state but engine resets to Built on success,
|
|
// so we manually set it to Running to test the guard
|
|
let meta_store = karapace_store::MetadataStore::new(engine.store_layout().clone());
|
|
meta_store
|
|
.update_state(&r.identity.env_id, EnvState::Running)
|
|
.unwrap();
|
|
|
|
let result = engine.destroy(&r.identity.env_id);
|
|
assert!(result.is_err(), "destroy must reject Running environments");
|
|
let err_msg = result.unwrap_err().to_string();
|
|
assert!(
|
|
err_msg.contains("running") || err_msg.contains("Running"),
|
|
"error should mention Running state: {err_msg}"
|
|
);
|
|
}
|
|
|
|
// Quick-style manifest generation + build (tests the flow used by `karapace quick`)
|
|
#[test]
|
|
fn quick_style_generated_manifest_builds_correctly() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
// Simulate what cmd_quick does: generate a manifest from CLI flags
|
|
let image = "rolling";
|
|
let packages = ["git", "curl"];
|
|
let mut manifest = String::new();
|
|
manifest.push_str("manifest_version = 1\n\n");
|
|
manifest.push_str("[base]\n");
|
|
{
|
|
use std::fmt::Write as _;
|
|
let _ = write!(manifest, "image = \"{image}\"\n\n");
|
|
}
|
|
manifest.push_str("[system]\npackages = [");
|
|
let pkg_list: Vec<String> = packages.iter().map(|p| format!("\"{p}\"")).collect();
|
|
manifest.push_str(&pkg_list.join(", "));
|
|
manifest.push_str("]\n\n");
|
|
manifest.push_str("[runtime]\nbackend = \"mock\"\n");
|
|
|
|
let manifest_path = write_manifest(project.path(), &manifest);
|
|
let result = engine.build(&manifest_path).unwrap();
|
|
|
|
assert!(!result.identity.env_id.is_empty());
|
|
assert!(!result.identity.short_id.is_empty());
|
|
|
|
let meta = engine.inspect(&result.identity.env_id).unwrap();
|
|
assert_eq!(meta.state, EnvState::Built);
|
|
}
|
|
|
|
// Quick-style minimal manifest (no packages, no hardware)
|
|
#[test]
|
|
fn quick_style_minimal_manifest_builds() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let manifest =
|
|
"manifest_version = 1\n\n[base]\nimage = \"rolling\"\n\n[runtime]\nbackend = \"mock\"\n";
|
|
let manifest_path = write_manifest(project.path(), manifest);
|
|
let result = engine.build(&manifest_path).unwrap();
|
|
|
|
assert!(!result.identity.env_id.is_empty());
|
|
}
|
|
|
|
// §16: No hidden mutable state — init + build produce consistent lock
|
|
#[test]
|
|
fn init_then_build_produces_consistent_state() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git"]));
|
|
let init_result = engine.init(&manifest).unwrap();
|
|
|
|
// Init creates metadata in Defined state
|
|
let meta = engine.inspect(&init_result.identity.env_id).unwrap();
|
|
assert_eq!(meta.state, EnvState::Defined);
|
|
|
|
// Build resolves and creates a new identity (different from init's preliminary)
|
|
let build_result = engine.build(&manifest).unwrap();
|
|
|
|
// The lock file from build is verifiable
|
|
assert!(build_result.lock_file.verify_integrity().is_ok());
|
|
assert_eq!(build_result.lock_file.lock_version, 2);
|
|
}
|
|
|
|
// INV-S2: Restore atomicity — original upper dir preserved if snapshot invalid
|
|
#[test]
|
|
fn restore_preserves_upper_on_invalid_snapshot() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git"]));
|
|
let r = engine.build(&manifest).unwrap();
|
|
let env_id = r.identity.env_id.to_string();
|
|
|
|
// Create a file in upper that we want to verify survives a failed restore
|
|
let upper = store.path().join("env").join(&env_id).join("upper");
|
|
fs::create_dir_all(&upper).unwrap();
|
|
fs::write(upper.join("sentinel.txt"), "must survive").unwrap();
|
|
|
|
// Attempt restore with a nonexistent snapshot — should fail
|
|
let result = engine.restore(&env_id, "nonexistent_hash_abc123");
|
|
assert!(result.is_err());
|
|
|
|
// Original upper content must still exist
|
|
assert!(upper.join("sentinel.txt").exists());
|
|
assert_eq!(
|
|
fs::read_to_string(upper.join("sentinel.txt")).unwrap(),
|
|
"must survive"
|
|
);
|
|
}
|
|
|
|
// INV-S3: Multiple snapshots listed in deterministic hash order
|
|
#[test]
|
|
fn snapshot_ordering_is_deterministic() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git"]));
|
|
let r = engine.build(&manifest).unwrap();
|
|
let env_id = r.identity.env_id.to_string();
|
|
|
|
// Create upper dir with different content for each commit
|
|
let upper = store.path().join("env").join(&env_id).join("upper");
|
|
|
|
fs::create_dir_all(&upper).unwrap();
|
|
fs::write(upper.join("v1.txt"), "version 1").unwrap();
|
|
let h1 = engine.commit(&env_id).unwrap();
|
|
|
|
fs::write(upper.join("v2.txt"), "version 2").unwrap();
|
|
let h2 = engine.commit(&env_id).unwrap();
|
|
|
|
fs::write(upper.join("v3.txt"), "version 3").unwrap();
|
|
let h3 = engine.commit(&env_id).unwrap();
|
|
|
|
// All hashes must be different
|
|
assert_ne!(h1, h2);
|
|
assert_ne!(h2, h3);
|
|
assert_ne!(h1, h3);
|
|
|
|
// list_snapshots returns sorted by hash — verify determinism
|
|
let snaps = engine.list_snapshots(&env_id).unwrap();
|
|
assert_eq!(snaps.len(), 3);
|
|
let hashes: Vec<&str> = snaps.iter().map(|s| s.hash.as_str()).collect();
|
|
let mut sorted = hashes.clone();
|
|
sorted.sort_unstable();
|
|
assert_eq!(hashes, sorted, "snapshots must be sorted by hash");
|
|
|
|
// Calling again must return same order
|
|
let snaps2 = engine.list_snapshots(&env_id).unwrap();
|
|
let hashes2: Vec<&str> = snaps2.iter().map(|s| s.hash.as_str()).collect();
|
|
assert_eq!(hashes, hashes2, "snapshot ordering must be deterministic");
|
|
}
|
|
|
|
// INV-W1: WAL recovery cleans orphaned env_dir after simulated build crash
|
|
#[test]
|
|
fn wal_recovery_cleans_orphaned_env_dir() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let layout = StoreLayout::new(store.path());
|
|
layout.initialize().unwrap();
|
|
|
|
// Simulate a crash during build:
|
|
// 1. Create a WAL entry for a build operation
|
|
// 2. Create an orphaned env_dir (as if build started but crashed)
|
|
// 3. Verify that Engine::new() recovery cleans it up
|
|
let wal = karapace_store::WriteAheadLog::new(&layout);
|
|
wal.initialize().unwrap();
|
|
|
|
let fake_env_id = "crash_test_env_abc123";
|
|
let orphan_dir = store.path().join("env").join(fake_env_id);
|
|
fs::create_dir_all(&orphan_dir).unwrap();
|
|
fs::write(orphan_dir.join("partial_build_artifact"), "data").unwrap();
|
|
|
|
let op_id = wal
|
|
.begin(karapace_store::WalOpKind::Build, fake_env_id)
|
|
.unwrap();
|
|
wal.add_rollback_step(
|
|
&op_id,
|
|
karapace_store::RollbackStep::RemoveDir(orphan_dir.clone()),
|
|
)
|
|
.unwrap();
|
|
|
|
// Do NOT commit the WAL — simulates a crash
|
|
|
|
// Now create a new Engine, which should trigger WAL recovery
|
|
let _engine = Engine::new(store.path());
|
|
|
|
// The orphaned env_dir must be cleaned up
|
|
assert!(
|
|
!orphan_dir.exists(),
|
|
"WAL recovery must remove orphaned env_dir"
|
|
);
|
|
|
|
// WAL must be empty after recovery
|
|
let wal2 = karapace_store::WriteAheadLog::new(&layout);
|
|
assert!(wal2.list_incomplete().unwrap().is_empty());
|
|
}
|
|
|
|
// --- A3: WAL & Crash Safety Hardening ---
|
|
|
|
// Simulate crash during commit: WAL entry exists, staging dir left behind
|
|
#[test]
|
|
fn wal_crash_during_commit_leaves_recoverable_state() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let layout = StoreLayout::new(store.path());
|
|
layout.initialize().unwrap();
|
|
|
|
let wal = karapace_store::WriteAheadLog::new(&layout);
|
|
wal.initialize().unwrap();
|
|
|
|
// Simulate a commit that created a staging dir but crashed before completion
|
|
let staging_dir = layout.staging_dir().join("restore-crash_test");
|
|
fs::create_dir_all(&staging_dir).unwrap();
|
|
fs::write(staging_dir.join("partial_data.txt"), "partial").unwrap();
|
|
|
|
let op_id = wal
|
|
.begin(karapace_store::WalOpKind::Commit, "crash_env")
|
|
.unwrap();
|
|
wal.add_rollback_step(
|
|
&op_id,
|
|
karapace_store::RollbackStep::RemoveDir(staging_dir.clone()),
|
|
)
|
|
.unwrap();
|
|
|
|
// Do NOT commit — simulates crash
|
|
|
|
// Recovery via new Engine must clean up
|
|
let _engine = Engine::new(store.path());
|
|
|
|
assert!(
|
|
!staging_dir.exists(),
|
|
"WAL recovery must remove orphaned staging dir"
|
|
);
|
|
let wal2 = karapace_store::WriteAheadLog::new(&layout);
|
|
assert!(wal2.list_incomplete().unwrap().is_empty());
|
|
}
|
|
|
|
// Simulate crash during restore: old upper removed but new upper not yet renamed
|
|
#[test]
|
|
fn wal_crash_during_restore_rollback() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let layout = StoreLayout::new(store.path());
|
|
layout.initialize().unwrap();
|
|
|
|
let wal = karapace_store::WriteAheadLog::new(&layout);
|
|
wal.initialize().unwrap();
|
|
|
|
let fake_env_id = "restore_crash_env";
|
|
|
|
// Create orphaned staging dir (as if restore was in progress)
|
|
let staging = layout.staging_dir().join(format!("restore-{fake_env_id}"));
|
|
fs::create_dir_all(&staging).unwrap();
|
|
fs::write(staging.join("snapshot_file.txt"), "snapshot data").unwrap();
|
|
|
|
let op_id = wal
|
|
.begin(karapace_store::WalOpKind::Restore, fake_env_id)
|
|
.unwrap();
|
|
wal.add_rollback_step(
|
|
&op_id,
|
|
karapace_store::RollbackStep::RemoveDir(staging.clone()),
|
|
)
|
|
.unwrap();
|
|
|
|
// Crash — WAL entry remains
|
|
|
|
// Recovery should clean up staging
|
|
let count = wal.recover().unwrap();
|
|
assert_eq!(count, 1);
|
|
assert!(!staging.exists());
|
|
}
|
|
|
|
// Multiple incomplete WAL entries all rolled back in order
|
|
#[test]
|
|
fn wal_multi_entry_recovery() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let layout = StoreLayout::new(store.path());
|
|
layout.initialize().unwrap();
|
|
|
|
let wal = karapace_store::WriteAheadLog::new(&layout);
|
|
wal.initialize().unwrap();
|
|
|
|
// Create 3 orphaned directories with WAL entries
|
|
let mut dirs = Vec::new();
|
|
for i in 0..3 {
|
|
let dir = store.path().join(format!("orphan_{i}"));
|
|
fs::create_dir_all(&dir).unwrap();
|
|
fs::write(dir.join("data"), format!("data_{i}")).unwrap();
|
|
|
|
let op_id = wal
|
|
.begin(karapace_store::WalOpKind::Build, &format!("env_{i}"))
|
|
.unwrap();
|
|
wal.add_rollback_step(&op_id, karapace_store::RollbackStep::RemoveDir(dir.clone()))
|
|
.unwrap();
|
|
dirs.push(dir);
|
|
}
|
|
|
|
assert_eq!(wal.list_incomplete().unwrap().len(), 3);
|
|
|
|
let count = wal.recover().unwrap();
|
|
assert_eq!(count, 3);
|
|
|
|
for dir in &dirs {
|
|
assert!(!dir.exists(), "all orphaned dirs must be cleaned up");
|
|
}
|
|
assert!(wal.list_incomplete().unwrap().is_empty());
|
|
}
|
|
|
|
// Incomplete temp file in objects dir should not be visible
|
|
#[test]
|
|
fn incomplete_temp_file_invisible_to_store() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let layout = StoreLayout::new(store.path());
|
|
layout.initialize().unwrap();
|
|
|
|
let obj_store = karapace_store::ObjectStore::new(layout.clone());
|
|
|
|
// Write a valid object
|
|
let hash = obj_store.put(b"valid data").unwrap();
|
|
|
|
// Create a temp file (simulating interrupted atomic write)
|
|
let temp_path = layout.objects_dir().join(".tmp_partial_write");
|
|
fs::write(&temp_path, b"incomplete").unwrap();
|
|
|
|
// list() should only return the valid object (skips dotfiles)
|
|
let list = obj_store.list().unwrap();
|
|
assert_eq!(list.len(), 1);
|
|
assert_eq!(list[0], hash);
|
|
|
|
// Integrity check should only check the valid object
|
|
let report = karapace_store::verify_store_integrity(&layout).unwrap();
|
|
assert_eq!(report.checked, 1);
|
|
assert_eq!(report.passed, 1);
|
|
assert!(report.failed.is_empty());
|
|
}
|
|
|
|
// Verify atomic rename is used: concurrent writes to same hash don't corrupt
|
|
#[test]
|
|
fn concurrent_object_writes_are_safe() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let layout = StoreLayout::new(store.path());
|
|
layout.initialize().unwrap();
|
|
|
|
let store_path = store.path().to_path_buf();
|
|
let barrier = Arc::new(Barrier::new(8));
|
|
let mut handles = Vec::new();
|
|
|
|
for _ in 0..8 {
|
|
let sp = store_path.clone();
|
|
let b = Arc::clone(&barrier);
|
|
handles.push(thread::spawn(move || {
|
|
let layout = StoreLayout::new(&sp);
|
|
let obj_store = karapace_store::ObjectStore::new(layout);
|
|
b.wait();
|
|
// All threads write the same data — should deduplicate safely
|
|
obj_store.put(b"concurrent-data").unwrap()
|
|
}));
|
|
}
|
|
|
|
let hashes: Vec<String> = handles.into_iter().map(|h| h.join().unwrap()).collect();
|
|
// All must produce the same hash
|
|
let first = &hashes[0];
|
|
for h in &hashes {
|
|
assert_eq!(h, first);
|
|
}
|
|
|
|
// Data must be readable and intact
|
|
let obj_store = karapace_store::ObjectStore::new(StoreLayout::new(store.path()));
|
|
let data = obj_store.get(first).unwrap();
|
|
assert_eq!(data, b"concurrent-data");
|
|
}
|
|
|
|
// --- M1: WAL Crash Safety Hardening (2.0) ---
|
|
|
|
// M1.1: Verify rollback step is registered before side-effect in build
|
|
#[test]
|
|
fn wal_rollback_registered_before_build_side_effect() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let layout = StoreLayout::new(store.path());
|
|
layout.initialize().unwrap();
|
|
|
|
let wal = karapace_store::WriteAheadLog::new(&layout);
|
|
wal.initialize().unwrap();
|
|
|
|
// Simulate the new build pattern: register rollback step, then create dir
|
|
let fake_env_id = "m1_test_build_order";
|
|
let op_id = wal
|
|
.begin(karapace_store::WalOpKind::Build, fake_env_id)
|
|
.unwrap();
|
|
|
|
let env_dir = store.path().join("env").join(fake_env_id);
|
|
// Rollback step registered BEFORE dir creation
|
|
wal.add_rollback_step(
|
|
&op_id,
|
|
karapace_store::RollbackStep::RemoveDir(env_dir.clone()),
|
|
)
|
|
.unwrap();
|
|
|
|
// Verify WAL entry has the rollback step before dir even exists
|
|
let entries = wal.list_incomplete().unwrap();
|
|
assert_eq!(entries.len(), 1);
|
|
assert!(!entries[0].rollback_steps.is_empty());
|
|
assert!(!env_dir.exists(), "dir should not exist yet");
|
|
|
|
// Now create the dir (simulating actual build)
|
|
fs::create_dir_all(&env_dir).unwrap();
|
|
|
|
// Simulate crash: don't commit. Recovery should clean up.
|
|
let _engine = Engine::new(store.path());
|
|
assert!(
|
|
!env_dir.exists(),
|
|
"WAL recovery must remove orphaned dir even when rollback was registered before creation"
|
|
);
|
|
}
|
|
|
|
// M1.1: Verify rollback of nonexistent path is a safe no-op
|
|
#[test]
|
|
fn wal_rollback_nonexistent_path_is_noop() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let layout = StoreLayout::new(store.path());
|
|
layout.initialize().unwrap();
|
|
|
|
let wal = karapace_store::WriteAheadLog::new(&layout);
|
|
wal.initialize().unwrap();
|
|
|
|
let op_id = wal
|
|
.begin(karapace_store::WalOpKind::Build, "noop_test")
|
|
.unwrap();
|
|
|
|
let nonexistent = store.path().join("env").join("does_not_exist");
|
|
wal.add_rollback_step(
|
|
&op_id,
|
|
karapace_store::RollbackStep::RemoveDir(nonexistent.clone()),
|
|
)
|
|
.unwrap();
|
|
|
|
// Recovery should succeed without error even though the path doesn't exist
|
|
let wal2 = karapace_store::WriteAheadLog::new(&layout);
|
|
let recovered = wal2.recover().unwrap();
|
|
assert_eq!(recovered, 1);
|
|
|
|
// WAL should be clean
|
|
assert!(wal2.list_incomplete().unwrap().is_empty());
|
|
}
|
|
|
|
// M1.2: Destroy with WAL protection — normal path
|
|
#[test]
|
|
fn destroy_with_wal_commits_cleanly() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git"]));
|
|
let r = engine.build(&manifest).unwrap();
|
|
let env_id = r.identity.env_id.to_string();
|
|
|
|
// Destroy should succeed and leave no WAL entries
|
|
engine.destroy(&env_id).unwrap();
|
|
|
|
let layout = StoreLayout::new(store.path());
|
|
let wal = karapace_store::WriteAheadLog::new(&layout);
|
|
assert!(
|
|
wal.list_incomplete().unwrap().is_empty(),
|
|
"WAL must be clean after successful destroy"
|
|
);
|
|
}
|
|
|
|
// M1.2: Destroy WAL crash recovery — crash between env_dir removal and metadata removal
|
|
#[test]
|
|
fn wal_crash_during_destroy_is_recoverable() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let layout = StoreLayout::new(store.path());
|
|
layout.initialize().unwrap();
|
|
|
|
let wal = karapace_store::WriteAheadLog::new(&layout);
|
|
wal.initialize().unwrap();
|
|
|
|
let fake_env_id = "destroy_crash_env";
|
|
|
|
// Create env dir and metadata to simulate a built environment
|
|
let env_dir = store.path().join("env").join(fake_env_id);
|
|
fs::create_dir_all(&env_dir).unwrap();
|
|
fs::write(env_dir.join("some_file"), "data").unwrap();
|
|
|
|
let meta_dir = layout.metadata_dir();
|
|
fs::create_dir_all(&meta_dir).unwrap();
|
|
fs::write(
|
|
meta_dir.join(fake_env_id),
|
|
r#"{"env_id":"destroy_crash_env"}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
// Simulate a destroy that crashed: WAL entry with rollback steps, not committed
|
|
let op_id = wal
|
|
.begin(karapace_store::WalOpKind::Destroy, fake_env_id)
|
|
.unwrap();
|
|
wal.add_rollback_step(
|
|
&op_id,
|
|
karapace_store::RollbackStep::RemoveDir(env_dir.clone()),
|
|
)
|
|
.unwrap();
|
|
wal.add_rollback_step(
|
|
&op_id,
|
|
karapace_store::RollbackStep::RemoveFile(meta_dir.join(fake_env_id)),
|
|
)
|
|
.unwrap();
|
|
|
|
// Recovery via Engine::new should clean up
|
|
let _engine = Engine::new(store.path());
|
|
|
|
assert!(
|
|
!env_dir.exists(),
|
|
"WAL recovery must remove orphaned env_dir from incomplete destroy"
|
|
);
|
|
|
|
let wal2 = karapace_store::WriteAheadLog::new(&layout);
|
|
assert!(wal2.list_incomplete().unwrap().is_empty());
|
|
}
|
|
|
|
// M1.3: Commit layer rollback — crash after layer write, before WAL commit
|
|
#[test]
|
|
fn wal_crash_during_commit_cleans_orphaned_snapshot_layer() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let layout = StoreLayout::new(store.path());
|
|
layout.initialize().unwrap();
|
|
|
|
let wal = karapace_store::WriteAheadLog::new(&layout);
|
|
wal.initialize().unwrap();
|
|
|
|
// Create a fake snapshot layer manifest file
|
|
let layers_dir = layout.layers_dir();
|
|
fs::create_dir_all(&layers_dir).unwrap();
|
|
let fake_hash = "orphaned_snapshot_hash_abc123";
|
|
let layer_path = layers_dir.join(fake_hash);
|
|
fs::write(
|
|
&layer_path,
|
|
r#"{"hash":"orphaned_snapshot_hash_abc123","kind":"Snapshot"}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
// Simulate commit crash: WAL entry with layer rollback, not committed
|
|
let op_id = wal
|
|
.begin(karapace_store::WalOpKind::Commit, "commit_crash_env")
|
|
.unwrap();
|
|
wal.add_rollback_step(
|
|
&op_id,
|
|
karapace_store::RollbackStep::RemoveFile(layer_path.clone()),
|
|
)
|
|
.unwrap();
|
|
|
|
// Recovery should remove the orphaned snapshot layer
|
|
let _engine = Engine::new(store.path());
|
|
|
|
assert!(
|
|
!layer_path.exists(),
|
|
"WAL recovery must remove orphaned snapshot layer from incomplete commit"
|
|
);
|
|
}
|
|
|
|
// M1.4: GC with WAL marker — normal path
|
|
#[test]
|
|
fn gc_with_wal_commits_cleanly() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
let layout = StoreLayout::new(store.path());
|
|
layout.initialize().unwrap();
|
|
|
|
// Run GC (nothing to collect, but WAL should be clean after)
|
|
let lock = StoreLock::acquire(&layout.lock_file()).unwrap();
|
|
let report = engine.gc(&lock, true).unwrap();
|
|
assert_eq!(report.orphaned_envs.len(), 0);
|
|
|
|
let wal = karapace_store::WriteAheadLog::new(&layout);
|
|
assert!(
|
|
wal.list_incomplete().unwrap().is_empty(),
|
|
"WAL must be clean after successful GC"
|
|
);
|
|
}
|
|
|
|
// M1.4: GC incomplete WAL entry recovered safely
|
|
#[test]
|
|
fn gc_incomplete_wal_entry_recovered_safely() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let layout = StoreLayout::new(store.path());
|
|
layout.initialize().unwrap();
|
|
|
|
let wal = karapace_store::WriteAheadLog::new(&layout);
|
|
wal.initialize().unwrap();
|
|
|
|
// Simulate an incomplete GC: WAL entry exists, GC didn't finish
|
|
let _op_id = wal.begin(karapace_store::WalOpKind::Gc, "gc").unwrap();
|
|
|
|
// Recovery via Engine::new should clean up the WAL entry (no rollback steps needed)
|
|
let _engine = Engine::new(store.path());
|
|
|
|
let wal2 = karapace_store::WriteAheadLog::new(&layout);
|
|
assert!(
|
|
wal2.list_incomplete().unwrap().is_empty(),
|
|
"incomplete GC WAL entry must be cleaned up on recovery"
|
|
);
|
|
}
|
|
|
|
// M1.4: GC is idempotent after partial run
|
|
#[test]
|
|
fn gc_is_idempotent_after_partial_run() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git"]));
|
|
let r = engine.build(&manifest).unwrap();
|
|
let env_id = r.identity.env_id.to_string();
|
|
|
|
// Destroy to create orphaned objects/layers
|
|
engine.destroy(&env_id).unwrap();
|
|
|
|
// First GC run
|
|
let layout = StoreLayout::new(store.path());
|
|
let lock = StoreLock::acquire(&layout.lock_file()).unwrap();
|
|
let report1 = engine.gc(&lock, false).unwrap();
|
|
// Second GC run — should be a no-op (idempotent)
|
|
let report2 = engine.gc(&lock, false).unwrap();
|
|
assert_eq!(
|
|
report2.removed_envs + report2.removed_layers + report2.removed_objects,
|
|
0,
|
|
"second GC run should find nothing to collect: {report2:?}"
|
|
);
|
|
|
|
// First run should have found something to collect
|
|
assert!(
|
|
report1.removed_envs + report1.removed_layers + report1.removed_objects > 0,
|
|
"first GC run should have collected orphans: {report1:?}"
|
|
);
|
|
}
|
|
|
|
// --- M6: Failure Mode Testing (2.0) ---
|
|
|
|
// M6.2: Object read fails gracefully on permission denied
|
|
#[test]
|
|
fn object_get_fails_on_permission_denied() {
|
|
if skip_if_root() {
|
|
return;
|
|
}
|
|
let store = tempfile::tempdir().unwrap();
|
|
let layout = StoreLayout::new(store.path());
|
|
layout.initialize().unwrap();
|
|
|
|
let obj_store = karapace_store::ObjectStore::new(layout.clone());
|
|
let hash = obj_store.put(b"test data").unwrap();
|
|
|
|
// Remove read permission on the object file
|
|
let obj_path = layout.objects_dir().join(&hash);
|
|
fs::set_permissions(&obj_path, fs::Permissions::from_mode(0o000)).unwrap();
|
|
|
|
let result = obj_store.get(&hash);
|
|
assert!(result.is_err(), "get must fail on permission denied");
|
|
|
|
// Restore permissions for cleanup
|
|
fs::set_permissions(&obj_path, fs::Permissions::from_mode(0o644)).unwrap();
|
|
}
|
|
|
|
// M6.2: Metadata write fails gracefully on read-only store
|
|
#[test]
|
|
fn metadata_put_fails_on_read_only_dir() {
|
|
if skip_if_root() {
|
|
return;
|
|
}
|
|
let store = tempfile::tempdir().unwrap();
|
|
let layout = StoreLayout::new(store.path());
|
|
layout.initialize().unwrap();
|
|
|
|
let meta_store = karapace_store::MetadataStore::new(layout.clone());
|
|
|
|
// Make metadata dir read-only
|
|
let meta_dir = layout.metadata_dir();
|
|
fs::set_permissions(&meta_dir, fs::Permissions::from_mode(0o555)).unwrap();
|
|
|
|
let meta = karapace_store::EnvMetadata {
|
|
env_id: "test_ro".into(),
|
|
short_id: "test_ro".into(),
|
|
name: None,
|
|
state: EnvState::Defined,
|
|
manifest_hash: "mhash".into(),
|
|
base_layer: "base".into(),
|
|
dependency_layers: vec![],
|
|
policy_layer: None,
|
|
created_at: "2025-01-01T00:00:00Z".to_owned(),
|
|
updated_at: "2025-01-01T00:00:00Z".to_owned(),
|
|
ref_count: 1,
|
|
checksum: None,
|
|
};
|
|
let result = meta_store.put(&meta);
|
|
assert!(result.is_err(), "put must fail on read-only metadata dir");
|
|
|
|
// Restore permissions for cleanup
|
|
fs::set_permissions(&meta_dir, fs::Permissions::from_mode(0o755)).unwrap();
|
|
}
|
|
|
|
// M6.3: Concurrent GC blocked by store lock
|
|
#[test]
|
|
fn concurrent_gc_blocked_by_lock() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let _engine = Engine::new(store.path());
|
|
let layout = StoreLayout::new(store.path());
|
|
layout.initialize().unwrap();
|
|
|
|
// Acquire lock in main thread
|
|
let lock = StoreLock::acquire(&layout.lock_file()).unwrap();
|
|
|
|
// Try to acquire lock again — should return None (non-blocking try)
|
|
let result = StoreLock::try_acquire(&layout.lock_file()).unwrap();
|
|
assert!(
|
|
result.is_none(),
|
|
"second lock acquisition must return None while first is held"
|
|
);
|
|
|
|
drop(lock);
|
|
|
|
// After drop, lock should be available again
|
|
let lock2 = StoreLock::try_acquire(&layout.lock_file()).unwrap();
|
|
assert!(lock2.is_some(), "lock must be available after drop");
|
|
}
|
|
|
|
// M6: Layer corruption detected on get
|
|
#[test]
|
|
fn layer_corruption_detected_on_get() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let layout = StoreLayout::new(store.path());
|
|
layout.initialize().unwrap();
|
|
|
|
let layer_store = karapace_store::LayerStore::new(layout.clone());
|
|
let layer = karapace_store::LayerManifest {
|
|
hash: "test_layer".to_owned(),
|
|
kind: karapace_store::LayerKind::Base,
|
|
parent: None,
|
|
object_refs: vec![],
|
|
read_only: true,
|
|
tar_hash: String::new(),
|
|
};
|
|
let content_hash = layer_store.put(&layer).unwrap();
|
|
|
|
// Corrupt the layer file (flip bytes but keep it valid-ish)
|
|
let layer_path = layout.layers_dir().join(&content_hash);
|
|
fs::write(&layer_path, b"corrupted data that is not the original").unwrap();
|
|
|
|
let result = layer_store.get(&content_hash);
|
|
assert!(
|
|
result.is_err(),
|
|
"corrupted layer must be detected by hash verification"
|
|
);
|
|
}
|
|
|
|
// M6: Metadata corruption detected on get
|
|
#[test]
|
|
fn metadata_corruption_detected_on_get() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let layout = StoreLayout::new(store.path());
|
|
layout.initialize().unwrap();
|
|
|
|
let meta_store = karapace_store::MetadataStore::new(layout.clone());
|
|
let meta = karapace_store::EnvMetadata {
|
|
env_id: "corrupt_test".into(),
|
|
short_id: "corrupt_test".into(),
|
|
name: None,
|
|
state: EnvState::Built,
|
|
manifest_hash: "mhash".into(),
|
|
base_layer: "base".into(),
|
|
dependency_layers: vec![],
|
|
policy_layer: None,
|
|
created_at: "2025-01-01T00:00:00Z".to_owned(),
|
|
updated_at: "2025-01-01T00:00:00Z".to_owned(),
|
|
ref_count: 1,
|
|
checksum: None,
|
|
};
|
|
meta_store.put(&meta).unwrap();
|
|
|
|
// Corrupt the metadata file while keeping valid JSON with wrong checksum
|
|
let meta_path = layout.metadata_dir().join("corrupt_test");
|
|
let mut content = fs::read_to_string(&meta_path).unwrap();
|
|
content = content.replace("corrupt_test", "tampered_val");
|
|
fs::write(&meta_path, &content).unwrap();
|
|
|
|
let result = meta_store.get("corrupt_test");
|
|
assert!(
|
|
result.is_err(),
|
|
"tampered metadata must be detected by checksum verification"
|
|
);
|
|
}
|
|
|
|
// M6: Destroy non-existent environment returns clean error
|
|
#[test]
|
|
fn destroy_nonexistent_env_returns_error() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let result = engine.destroy("does_not_exist_abc123");
|
|
assert!(result.is_err());
|
|
let err = result.unwrap_err().to_string();
|
|
assert!(
|
|
err.contains("not found") || err.contains("NotFound") || err.contains("does_not_exist"),
|
|
"error should indicate env not found: {err}"
|
|
);
|
|
}
|
|
|
|
// M6: Build with invalid manifest returns clean error
|
|
#[test]
|
|
fn build_invalid_manifest_returns_error() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let manifest = project.path().join("karapace.toml");
|
|
fs::write(&manifest, "this is not valid toml [[[").unwrap();
|
|
|
|
let result = engine.build(&manifest);
|
|
assert!(result.is_err(), "build with invalid manifest must fail");
|
|
}
|
|
|
|
// stop() on non-Running env returns correct error
|
|
#[test]
|
|
fn stop_non_running_env_returns_error() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git"]));
|
|
let r = engine.build(&manifest).unwrap();
|
|
|
|
// Environment is in Built state, not Running
|
|
let result = engine.stop(&r.identity.env_id);
|
|
assert!(result.is_err(), "stop on Built env must fail");
|
|
let err_msg = result.unwrap_err().to_string();
|
|
assert!(
|
|
err_msg.to_lowercase().contains("running")
|
|
|| err_msg.to_lowercase().contains("not running"),
|
|
"error should mention running state: {err_msg}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn stop_succeeds_when_metadata_stale_but_running_marker_exists() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git"]));
|
|
let r = engine.build(&manifest).unwrap();
|
|
let env_id = r.identity.env_id.to_string();
|
|
|
|
let mut child = std::process::Command::new("sleep")
|
|
.arg("60")
|
|
.spawn()
|
|
.expect("spawn sleep");
|
|
let pid = child.id();
|
|
let pid_i32 = i32::try_from(pid).expect("pid fits in i32");
|
|
|
|
let env_dir = store.path().join("env").join(&env_id);
|
|
fs::create_dir_all(&env_dir).unwrap();
|
|
fs::write(env_dir.join(".running"), pid.to_string()).unwrap();
|
|
|
|
let result = engine.stop(&env_id);
|
|
|
|
unsafe {
|
|
libc::kill(pid_i32, libc::SIGKILL);
|
|
}
|
|
let _ = child.wait();
|
|
|
|
assert!(
|
|
result.is_ok(),
|
|
"stop must succeed when .running exists even if metadata is stale: {:?}",
|
|
result.err()
|
|
);
|
|
|
|
let meta = engine.inspect(&env_id).unwrap();
|
|
assert_eq!(meta.state, EnvState::Built);
|
|
}
|
|
|
|
#[test]
|
|
fn stop_preserves_frozen_state() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git"]));
|
|
let r = engine.build(&manifest).unwrap();
|
|
let env_id = r.identity.env_id.to_string();
|
|
|
|
let mut child = std::process::Command::new("sleep")
|
|
.arg("60")
|
|
.spawn()
|
|
.expect("spawn sleep");
|
|
let pid = child.id();
|
|
let pid_i32 = i32::try_from(pid).expect("pid fits in i32");
|
|
|
|
let layout = StoreLayout::new(store.path());
|
|
let meta_store = karapace_store::MetadataStore::new(layout.clone());
|
|
meta_store.update_state(&env_id, EnvState::Frozen).unwrap();
|
|
|
|
let env_dir = store.path().join("env").join(&env_id);
|
|
fs::create_dir_all(&env_dir).unwrap();
|
|
fs::write(env_dir.join(".running"), pid.to_string()).unwrap();
|
|
|
|
let result = engine.stop(&env_id);
|
|
|
|
unsafe {
|
|
libc::kill(pid_i32, libc::SIGKILL);
|
|
}
|
|
let _ = child.wait();
|
|
|
|
assert!(
|
|
result.is_ok(),
|
|
"stop must succeed even if env is frozen but still running: {:?}",
|
|
result.err()
|
|
);
|
|
|
|
let meta = engine.inspect(&env_id).unwrap();
|
|
assert_eq!(meta.state, EnvState::Frozen);
|
|
}
|
|
|
|
#[test]
|
|
fn stale_running_marker_cleaned_on_engine_new() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
|
|
let engine = Engine::new(store.path());
|
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git"]));
|
|
let result = engine.build(&manifest);
|
|
assert!(result.is_ok());
|
|
let env_id = result.unwrap().identity.env_id;
|
|
|
|
let env_dir = store.path().join("env").join(&*env_id);
|
|
fs::create_dir_all(&env_dir).unwrap();
|
|
let running_marker = env_dir.join(".running");
|
|
fs::write(&running_marker, "stale").unwrap();
|
|
assert!(running_marker.exists());
|
|
|
|
let _engine2 = Engine::new(store.path());
|
|
assert!(
|
|
!running_marker.exists(),
|
|
"stale .running marker must be removed by Engine::new()"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_on_readonly_objects_dir_returns_error() {
|
|
if skip_if_root() {
|
|
return;
|
|
}
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
|
|
// Initialize the store, then make objects dir read-only
|
|
let layout = StoreLayout::new(store.path());
|
|
layout.initialize().unwrap();
|
|
let objects_dir = layout.objects_dir();
|
|
fs::set_permissions(&objects_dir, fs::Permissions::from_mode(0o444)).unwrap();
|
|
|
|
let engine = Engine::new(store.path());
|
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git"]));
|
|
let result = engine.build(&manifest);
|
|
|
|
// Restore permissions for cleanup
|
|
fs::set_permissions(&objects_dir, fs::Permissions::from_mode(0o755)).unwrap();
|
|
|
|
assert!(
|
|
result.is_err(),
|
|
"build must fail when objects dir is read-only"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_on_readonly_metadata_dir_returns_error() {
|
|
if skip_if_root() {
|
|
return;
|
|
}
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
|
|
let layout = StoreLayout::new(store.path());
|
|
layout.initialize().unwrap();
|
|
let meta_dir = layout.metadata_dir();
|
|
fs::set_permissions(&meta_dir, fs::Permissions::from_mode(0o444)).unwrap();
|
|
|
|
let engine = Engine::new(store.path());
|
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git"]));
|
|
let result = engine.build(&manifest);
|
|
|
|
fs::set_permissions(&meta_dir, fs::Permissions::from_mode(0o755)).unwrap();
|
|
|
|
assert!(
|
|
result.is_err(),
|
|
"build must fail when metadata dir is read-only"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn commit_on_readonly_layers_dir_returns_error() {
|
|
if skip_if_root() {
|
|
return;
|
|
}
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git"]));
|
|
let r = engine.build(&manifest).unwrap();
|
|
let env_id = r.identity.env_id.to_string();
|
|
|
|
// Create upper dir with content
|
|
let upper = store.path().join("env").join(&env_id).join("upper");
|
|
fs::create_dir_all(&upper).unwrap();
|
|
fs::write(upper.join("file.txt"), "data").unwrap();
|
|
|
|
// Make layers dir read-only
|
|
let layout = StoreLayout::new(store.path());
|
|
let layers_dir = layout.layers_dir();
|
|
fs::set_permissions(&layers_dir, fs::Permissions::from_mode(0o444)).unwrap();
|
|
|
|
let result = engine.commit(&env_id);
|
|
|
|
fs::set_permissions(&layers_dir, fs::Permissions::from_mode(0o755)).unwrap();
|
|
|
|
assert!(
|
|
result.is_err(),
|
|
"commit must fail when layers dir is read-only"
|
|
);
|
|
|
|
// Store should still be usable after the error
|
|
let meta = engine.inspect(&env_id).unwrap();
|
|
assert_eq!(
|
|
meta.state,
|
|
EnvState::Built,
|
|
"env must still be in Built state after failed commit"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn write_failure_never_panics() {
|
|
if skip_if_root() {
|
|
return;
|
|
}
|
|
let store = tempfile::tempdir().unwrap();
|
|
let layout = StoreLayout::new(store.path());
|
|
layout.initialize().unwrap();
|
|
|
|
// Test ObjectStore::put on read-only dir
|
|
let objects_dir = layout.objects_dir();
|
|
fs::set_permissions(&objects_dir, fs::Permissions::from_mode(0o444)).unwrap();
|
|
let obj_store = karapace_store::ObjectStore::new(layout.clone());
|
|
let result = obj_store.put(b"test data");
|
|
fs::set_permissions(&objects_dir, fs::Permissions::from_mode(0o755)).unwrap();
|
|
assert!(
|
|
result.is_err(),
|
|
"ObjectStore::put must return Err, not panic"
|
|
);
|
|
|
|
// Test LayerStore::put on read-only dir
|
|
let layers_dir = layout.layers_dir();
|
|
fs::set_permissions(&layers_dir, fs::Permissions::from_mode(0o444)).unwrap();
|
|
let layer_store = karapace_store::LayerStore::new(layout.clone());
|
|
let layer = karapace_store::LayerManifest {
|
|
hash: "test".into(),
|
|
kind: karapace_store::LayerKind::Base,
|
|
parent: None,
|
|
object_refs: vec![],
|
|
read_only: true,
|
|
tar_hash: "test".into(),
|
|
};
|
|
let result = layer_store.put(&layer);
|
|
fs::set_permissions(&layers_dir, fs::Permissions::from_mode(0o755)).unwrap();
|
|
assert!(
|
|
result.is_err(),
|
|
"LayerStore::put must return Err, not panic"
|
|
);
|
|
|
|
// Test MetadataStore::put on read-only dir
|
|
let meta_dir = layout.metadata_dir();
|
|
fs::set_permissions(&meta_dir, fs::Permissions::from_mode(0o444)).unwrap();
|
|
let meta_store = karapace_store::MetadataStore::new(layout.clone());
|
|
let meta = karapace_store::EnvMetadata {
|
|
env_id: "test123".into(),
|
|
short_id: "test123".into(),
|
|
name: None,
|
|
state: EnvState::Defined,
|
|
manifest_hash: "mh".into(),
|
|
base_layer: "bl".into(),
|
|
dependency_layers: vec![],
|
|
policy_layer: None,
|
|
created_at: "2025-01-01T00:00:00Z".to_owned(),
|
|
updated_at: "2025-01-01T00:00:00Z".to_owned(),
|
|
ref_count: 1,
|
|
checksum: None,
|
|
};
|
|
let result = meta_store.put(&meta);
|
|
fs::set_permissions(&meta_dir, fs::Permissions::from_mode(0o755)).unwrap();
|
|
assert!(
|
|
result.is_err(),
|
|
"MetadataStore::put must return Err, not panic"
|
|
);
|
|
}
|
|
|
|
// --- §2: Bit-flip corruption detection ---
|
|
|
|
#[test]
|
|
fn bitflip_corruption_detected_on_objects() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let layout = StoreLayout::new(store.path());
|
|
layout.initialize().unwrap();
|
|
let obj_store = karapace_store::ObjectStore::new(layout.clone());
|
|
|
|
// Write several objects
|
|
let h1 = obj_store.put(b"object-alpha").unwrap();
|
|
let h2 = obj_store.put(b"object-beta-longer-content-here").unwrap();
|
|
let h3 = obj_store.put(b"object-gamma").unwrap();
|
|
|
|
// Flip a random byte in object 2
|
|
let path = layout.objects_dir().join(&h2);
|
|
let mut data = fs::read(&path).unwrap();
|
|
let flip_idx = data.len() / 2;
|
|
data[flip_idx] ^= 0xFF;
|
|
fs::write(&path, &data).unwrap();
|
|
|
|
// Intact objects must still be readable
|
|
assert!(obj_store.get(&h1).is_ok());
|
|
assert!(obj_store.get(&h3).is_ok());
|
|
|
|
// Corrupted object must fail with typed error
|
|
let result = obj_store.get(&h2);
|
|
assert!(result.is_err(), "bit-flipped object must be detected");
|
|
|
|
// verify_store_integrity must report exactly 1 failure
|
|
let report = karapace_store::verify_store_integrity(&layout).unwrap();
|
|
assert_eq!(report.checked, 3);
|
|
assert_eq!(report.passed, 2);
|
|
assert_eq!(report.failed.len(), 1);
|
|
assert_eq!(report.failed[0].hash, h2);
|
|
}
|
|
|
|
// --- §6: GC dry-run equivalence ---
|
|
|
|
#[test]
|
|
fn gc_dry_run_matches_real_gc() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
// Build 5 environments, destroy 3
|
|
let mut env_ids = Vec::new();
|
|
for i in 0..5 {
|
|
let p = tempfile::tempdir().unwrap();
|
|
let pkgs: Vec<String> = (0..=i).map(|j| format!("pkg{j}")).collect();
|
|
let pkg_refs: Vec<&str> = pkgs.iter().map(String::as_str).collect();
|
|
let m = write_manifest(p.path(), &mock_manifest(&pkg_refs));
|
|
let r = engine.build(&m).unwrap();
|
|
env_ids.push(r.identity.env_id.to_string());
|
|
}
|
|
for id in &env_ids[..3] {
|
|
engine.destroy(id).unwrap();
|
|
}
|
|
|
|
let layout = StoreLayout::new(store.path());
|
|
let lock = StoreLock::acquire(&layout.lock_file()).unwrap();
|
|
|
|
// Dry run — populates orphaned_* but doesn't remove
|
|
let dry_report = engine.gc(&lock, true).unwrap();
|
|
assert_eq!(
|
|
dry_report.removed_envs, 0,
|
|
"dry-run must not remove anything"
|
|
);
|
|
|
|
// Real GC — actually removes
|
|
let real_report = engine.gc(&lock, false).unwrap();
|
|
|
|
// Dry-run orphaned counts must match real removed counts
|
|
assert_eq!(
|
|
dry_report.orphaned_envs.len(),
|
|
real_report.removed_envs,
|
|
"dry-run orphaned_envs must match real removed_envs"
|
|
);
|
|
assert_eq!(
|
|
dry_report.orphaned_layers.len(),
|
|
real_report.removed_layers,
|
|
"dry-run orphaned_layers must match real removed_layers"
|
|
);
|
|
assert_eq!(
|
|
dry_report.orphaned_objects.len(),
|
|
real_report.removed_objects,
|
|
"dry-run orphaned_objects must match real removed_objects"
|
|
);
|
|
}
|
|
|
|
// --- §15: Soak test — 100 build/destroy/GC cycles ---
|
|
|
|
#[test]
|
|
fn soak_100_build_destroy_gc_cycles() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
let layout = StoreLayout::new(store.path());
|
|
|
|
for cycle in 0..100 {
|
|
let p = tempfile::tempdir().unwrap();
|
|
let pkgs: Vec<String> = (0..=(cycle % 5)).map(|j| format!("pkg{j}")).collect();
|
|
let pkg_refs: Vec<&str> = pkgs.iter().map(String::as_str).collect();
|
|
let m = write_manifest(p.path(), &mock_manifest(&pkg_refs));
|
|
|
|
let r = engine.build(&m).unwrap();
|
|
let env_id = r.identity.env_id.to_string();
|
|
|
|
// Verify it's inspectable
|
|
let meta = engine.inspect(&env_id).unwrap();
|
|
assert_eq!(meta.state, EnvState::Built);
|
|
|
|
// Destroy
|
|
engine.destroy(&env_id).unwrap();
|
|
|
|
// GC every 10 cycles
|
|
if cycle % 10 == 9 {
|
|
let lock = StoreLock::acquire(&layout.lock_file()).unwrap();
|
|
let _report = engine.gc(&lock, false).unwrap();
|
|
}
|
|
}
|
|
|
|
// Final state: no environments should remain
|
|
let envs = engine.list().unwrap();
|
|
assert_eq!(
|
|
envs.len(),
|
|
0,
|
|
"all environments must be destroyed after soak"
|
|
);
|
|
|
|
// Final GC — store should be clean
|
|
let lock = StoreLock::acquire(&layout.lock_file()).unwrap();
|
|
let _report = engine.gc(&lock, false).unwrap();
|
|
|
|
// WAL must be clean
|
|
let wal = karapace_store::WriteAheadLog::new(&layout);
|
|
assert!(wal.list_incomplete().unwrap().is_empty());
|
|
|
|
// Store integrity must pass
|
|
let integrity = karapace_store::verify_store_integrity(&layout).unwrap();
|
|
assert!(
|
|
integrity.failed.is_empty(),
|
|
"store integrity must be clean after 100 cycles"
|
|
);
|
|
}
|
|
|
|
// --- §11: Store v1 rejection test ---
|
|
|
|
#[test]
|
|
fn old_store_version_rejected_with_clear_error() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let layout = StoreLayout::new(store.path());
|
|
layout.initialize().unwrap();
|
|
|
|
// Overwrite the version file with v1 (old format)
|
|
let version_path = store.path().join("store").join("version");
|
|
fs::write(&version_path, r#"{"format_version": 1}"#).unwrap();
|
|
|
|
// Re-initializing must reject v1
|
|
let result = layout.initialize();
|
|
assert!(
|
|
result.is_err(),
|
|
"store v1 must be rejected by the current binary"
|
|
);
|
|
|
|
// The error message should mention the version
|
|
let err_msg = result.unwrap_err().to_string();
|
|
assert!(
|
|
err_msg.contains("version") || err_msg.contains("format"),
|
|
"error should mention version mismatch: {err_msg}"
|
|
);
|
|
}
|
|
|
|
// --- §4: Resolver / lockfile determinism ---
|
|
|
|
#[test]
|
|
fn build_with_invalid_manifest_returns_manifest_error() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let manifest = write_manifest(
|
|
project.path(),
|
|
r"
|
|
manifest_version = 1
|
|
[base]
|
|
",
|
|
);
|
|
let result = engine.build(&manifest);
|
|
assert!(result.is_err(), "build with missing image must fail");
|
|
}
|
|
|
|
#[test]
|
|
fn build_with_invalid_backend_returns_error() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let manifest = write_manifest(
|
|
project.path(),
|
|
r#"
|
|
manifest_version = 1
|
|
[base]
|
|
image = "rolling"
|
|
[runtime]
|
|
backend = "nonexistent_backend"
|
|
"#,
|
|
);
|
|
let result = engine.build(&manifest);
|
|
assert!(result.is_err(), "build with unknown backend must fail");
|
|
}
|
|
|
|
#[test]
|
|
fn lockfile_determinism_same_inputs_same_env_id() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
// Build twice with identical manifests in different project dirs
|
|
let p1 = tempfile::tempdir().unwrap();
|
|
let m1 = write_manifest(p1.path(), &mock_manifest(&["git", "vim"]));
|
|
let r1 = engine.build(&m1).unwrap();
|
|
|
|
// Destroy first, then rebuild with same manifest
|
|
engine.destroy(&r1.identity.env_id).unwrap();
|
|
|
|
let p2 = tempfile::tempdir().unwrap();
|
|
let m2 = write_manifest(p2.path(), &mock_manifest(&["git", "vim"]));
|
|
let r2 = engine.build(&m2).unwrap();
|
|
|
|
assert_eq!(
|
|
r1.identity.env_id, r2.identity.env_id,
|
|
"same manifest must produce same env_id"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn different_packages_produce_different_env_id() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let p1 = tempfile::tempdir().unwrap();
|
|
let m1 = write_manifest(p1.path(), &mock_manifest(&["git"]));
|
|
let r1 = engine.build(&m1).unwrap();
|
|
|
|
let p2 = tempfile::tempdir().unwrap();
|
|
let m2 = write_manifest(p2.path(), &mock_manifest(&["vim"]));
|
|
let r2 = engine.build(&m2).unwrap();
|
|
|
|
assert_ne!(
|
|
r1.identity.env_id, r2.identity.env_id,
|
|
"different packages must produce different env_id"
|
|
);
|
|
}
|
|
|
|
// --- §6: GC 1000-object stress test ---
|
|
|
|
#[test]
|
|
fn gc_stress_1000_objects() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let layout = StoreLayout::new(store.path());
|
|
layout.initialize().unwrap();
|
|
let obj_store = karapace_store::ObjectStore::new(layout.clone());
|
|
|
|
// Create 1000 orphaned objects (no layer or metadata references them)
|
|
for i in 0..1000 {
|
|
obj_store
|
|
.put(format!("orphaned-object-{i}").as_bytes())
|
|
.unwrap();
|
|
}
|
|
|
|
// Also create a live environment so GC has reachable refs to trace
|
|
let engine = Engine::new(store.path());
|
|
let p = tempfile::tempdir().unwrap();
|
|
let m = write_manifest(p.path(), &mock_manifest(&["git"]));
|
|
let _r = engine.build(&m).unwrap();
|
|
|
|
// Run GC
|
|
let lock = StoreLock::acquire(&layout.lock_file()).unwrap();
|
|
let report = engine.gc(&lock, false).unwrap();
|
|
|
|
// All 1000 orphans must be collected
|
|
assert_eq!(
|
|
report.removed_objects, 1000,
|
|
"GC must collect all 1000 orphaned objects, got {}",
|
|
report.removed_objects
|
|
);
|
|
|
|
// Live env must survive
|
|
assert_eq!(engine.list().unwrap().len(), 1);
|
|
|
|
// Store integrity must still pass
|
|
let integrity = karapace_store::verify_store_integrity(&layout).unwrap();
|
|
assert!(
|
|
integrity.failed.is_empty(),
|
|
"store integrity must pass after GC stress"
|
|
);
|
|
}
|
|
|
|
// --- §5: Concurrent build operations ---
|
|
|
|
#[test]
|
|
fn concurrent_builds_do_not_corrupt_store() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let store_path = store.path().to_owned();
|
|
|
|
let barrier = Arc::new(Barrier::new(4));
|
|
let mut handles = Vec::new();
|
|
|
|
for thread_idx in 0..4 {
|
|
let path = store_path.clone();
|
|
let b = Arc::clone(&barrier);
|
|
handles.push(thread::spawn(move || {
|
|
let engine = Engine::new(&path);
|
|
b.wait();
|
|
|
|
let p = tempfile::tempdir().unwrap();
|
|
let pkgs: Vec<String> = (0..=(thread_idx % 3))
|
|
.map(|j| format!("t{thread_idx}pkg{j}"))
|
|
.collect();
|
|
let pkg_refs: Vec<&str> = pkgs.iter().map(String::as_str).collect();
|
|
let m_content = format!(
|
|
r#"
|
|
manifest_version = 1
|
|
[base]
|
|
image = "rolling"
|
|
[system]
|
|
packages = [{}]
|
|
[runtime]
|
|
backend = "mock"
|
|
"#,
|
|
pkg_refs
|
|
.iter()
|
|
.map(|p| format!("\"{p}\""))
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
);
|
|
let manifest_path = p.path().join("karapace.toml");
|
|
fs::write(&manifest_path, &m_content).unwrap();
|
|
engine.build(&manifest_path).unwrap();
|
|
}));
|
|
}
|
|
|
|
for h in handles {
|
|
h.join().unwrap();
|
|
}
|
|
|
|
// Verify store is healthy after concurrent builds
|
|
let engine = Engine::new(&store_path);
|
|
let envs = engine.list().unwrap();
|
|
assert_eq!(envs.len(), 4, "all 4 concurrent builds must succeed");
|
|
|
|
let layout = StoreLayout::new(&store_path);
|
|
let integrity = karapace_store::verify_store_integrity(&layout).unwrap();
|
|
assert!(
|
|
integrity.failed.is_empty(),
|
|
"store must be intact after concurrent builds"
|
|
);
|
|
}
|
|
|
|
// --- M6.1: WAL write failure simulation ---
|
|
|
|
#[test]
|
|
fn wal_write_fails_on_readonly_dir() {
|
|
if skip_if_root() {
|
|
return;
|
|
}
|
|
let store = tempfile::tempdir().unwrap();
|
|
let layout = StoreLayout::new(store.path());
|
|
layout.initialize().unwrap();
|
|
|
|
let wal = karapace_store::WriteAheadLog::new(&layout);
|
|
wal.initialize().unwrap();
|
|
|
|
// Make WAL dir read-only
|
|
let wal_dir = store.path().join("store").join("wal");
|
|
fs::set_permissions(&wal_dir, fs::Permissions::from_mode(0o444)).unwrap();
|
|
|
|
let result = wal.begin(karapace_store::WalOpKind::Build, "test-env");
|
|
|
|
fs::set_permissions(&wal_dir, fs::Permissions::from_mode(0o755)).unwrap();
|
|
|
|
assert!(
|
|
result.is_err(),
|
|
"WAL begin must fail when WAL dir is read-only"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_fails_cleanly_when_wal_dir_is_readonly() {
|
|
if skip_if_root() {
|
|
return;
|
|
}
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
|
|
// Initialize layout and WAL so the dir exists
|
|
let layout = StoreLayout::new(store.path());
|
|
layout.initialize().unwrap();
|
|
let wal = karapace_store::WriteAheadLog::new(&layout);
|
|
wal.initialize().unwrap();
|
|
|
|
let engine = Engine::new(store.path());
|
|
|
|
// Now make WAL dir read-only to simulate disk full
|
|
let wal_dir = layout.root().join("store").join("wal");
|
|
fs::set_permissions(&wal_dir, fs::Permissions::from_mode(0o444)).unwrap();
|
|
|
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git"]));
|
|
let result = engine.build(&manifest);
|
|
|
|
fs::set_permissions(&wal_dir, fs::Permissions::from_mode(0o755)).unwrap();
|
|
|
|
assert!(
|
|
result.is_err(),
|
|
"build must fail cleanly when WAL dir is read-only (simulates disk full)"
|
|
);
|
|
}
|
|
|
|
// --- M6.4: stop() SIGTERM/SIGKILL path tests ---
|
|
|
|
#[test]
|
|
fn stop_sends_sigterm_to_real_process() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git"]));
|
|
let r = engine.build(&manifest).unwrap();
|
|
let env_id = r.identity.env_id.to_string();
|
|
|
|
// Spawn a real sleep process and wait on it later to avoid zombie
|
|
let mut child = std::process::Command::new("sleep")
|
|
.arg("60")
|
|
.spawn()
|
|
.expect("spawn sleep");
|
|
let pid = child.id();
|
|
let pid_i32 = i32::try_from(pid).expect("pid fits in i32");
|
|
|
|
// Set metadata to Running state and write .running marker with real PID
|
|
let layout = StoreLayout::new(store.path());
|
|
let meta_store = karapace_store::MetadataStore::new(layout.clone());
|
|
meta_store.update_state(&env_id, EnvState::Running).unwrap();
|
|
|
|
let env_dir = store.path().join("env").join(&env_id);
|
|
fs::create_dir_all(&env_dir).unwrap();
|
|
fs::write(env_dir.join(".running"), pid.to_string()).unwrap();
|
|
|
|
// Verify the process is alive
|
|
assert!(
|
|
Path::new(&format!("/proc/{pid}")).exists(),
|
|
"sleep process must be alive before stop"
|
|
);
|
|
|
|
// Stop should send SIGTERM (the mock backend returns pid=99999 which won't exist,
|
|
// but the real process PID was written to .running). The mock backend's status()
|
|
// returns pid=99999 for running envs, so stop() will try to kill 99999 (ESRCH).
|
|
// This tests the ESRCH handling path.
|
|
let result = engine.stop(&env_id);
|
|
|
|
// Clean up the real process regardless of result
|
|
unsafe {
|
|
libc::kill(pid_i32, libc::SIGKILL);
|
|
}
|
|
let _ = child.wait();
|
|
|
|
assert!(
|
|
result.is_ok(),
|
|
"stop must succeed even when backend PID no longer exists (ESRCH path): {:?}",
|
|
result.err()
|
|
);
|
|
|
|
// Verify state was reset to Built
|
|
let meta = engine.inspect(&env_id).unwrap();
|
|
assert_eq!(
|
|
meta.state,
|
|
EnvState::Built,
|
|
"state must be reset to Built after stop"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn stop_invalid_pid_handled_gracefully() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git"]));
|
|
let r = engine.build(&manifest).unwrap();
|
|
let env_id = r.identity.env_id.to_string();
|
|
|
|
// Manually set state to Running (the mock backend will report pid=99999)
|
|
let layout = StoreLayout::new(store.path());
|
|
let meta_store = karapace_store::MetadataStore::new(layout.clone());
|
|
meta_store.update_state(&env_id, EnvState::Running).unwrap();
|
|
|
|
// stop() will try to kill pid 99999 which doesn't exist → ESRCH
|
|
let result = engine.stop(&env_id);
|
|
|
|
// Must handle gracefully (ESRCH = process already exited)
|
|
assert!(
|
|
result.is_ok(),
|
|
"stop with non-existent PID must succeed (ESRCH handled): {:?}",
|
|
result.err()
|
|
);
|
|
|
|
let meta = engine.inspect(&env_id).unwrap();
|
|
assert_eq!(meta.state, EnvState::Built);
|
|
}
|
|
|
|
// --- M7: Coverage expansion ---
|
|
|
|
#[test]
|
|
fn freeze_and_archive_state_transitions() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git"]));
|
|
let r = engine.build(&manifest).unwrap();
|
|
let env_id = r.identity.env_id.to_string();
|
|
|
|
// Built → Frozen
|
|
engine.freeze(&env_id).unwrap();
|
|
let meta = engine.inspect(&env_id).unwrap();
|
|
assert_eq!(meta.state, EnvState::Frozen);
|
|
|
|
// Frozen → Archived
|
|
engine.archive(&env_id).unwrap();
|
|
let meta = engine.inspect(&env_id).unwrap();
|
|
assert_eq!(meta.state, EnvState::Archived);
|
|
}
|
|
|
|
#[test]
|
|
fn rename_environment_works() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git"]));
|
|
let r = engine.build(&manifest).unwrap();
|
|
let env_id = r.identity.env_id.to_string();
|
|
|
|
// Rename
|
|
engine.rename(&env_id, "my-dev-env").unwrap();
|
|
let meta = engine.inspect(&env_id).unwrap();
|
|
assert_eq!(meta.name, Some("my-dev-env".to_owned()));
|
|
|
|
// Rename again
|
|
engine.rename(&env_id, "new-name").unwrap();
|
|
let meta = engine.inspect(&env_id).unwrap();
|
|
assert_eq!(meta.name, Some("new-name".to_owned()));
|
|
}
|
|
|
|
#[test]
|
|
fn verify_store_reports_all_clean_after_fresh_build() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git", "vim"]));
|
|
let _r = engine.build(&manifest).unwrap();
|
|
|
|
let layout = StoreLayout::new(store.path());
|
|
let report = karapace_store::verify_store_integrity(&layout).unwrap();
|
|
|
|
assert!(report.checked > 0, "must check at least some objects");
|
|
assert_eq!(report.passed, report.checked);
|
|
assert!(report.failed.is_empty());
|
|
assert!(report.layers_checked > 0, "must check at least some layers");
|
|
assert_eq!(report.layers_passed, report.layers_checked);
|
|
assert!(
|
|
report.metadata_checked > 0,
|
|
"must check at least some metadata"
|
|
);
|
|
assert_eq!(report.metadata_passed, report.metadata_checked);
|
|
}
|
|
|
|
#[test]
|
|
fn rebuild_preserves_new_and_cleans_old() {
|
|
let store = tempfile::tempdir().unwrap();
|
|
let project = tempfile::tempdir().unwrap();
|
|
let engine = Engine::new(store.path());
|
|
|
|
// Initial build
|
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git"]));
|
|
let r1 = engine.build(&manifest).unwrap();
|
|
let old_id = r1.identity.env_id.to_string();
|
|
|
|
// Rebuild with different packages → different env_id
|
|
let manifest2 = write_manifest(project.path(), &mock_manifest(&["git", "vim"]));
|
|
let r2 = engine.rebuild(&manifest2).unwrap();
|
|
let new_id = r2.identity.env_id.to_string();
|
|
|
|
assert_ne!(
|
|
old_id, new_id,
|
|
"different packages must produce different env_id"
|
|
);
|
|
|
|
// New env must be inspectable
|
|
let meta = engine.inspect(&new_id).unwrap();
|
|
assert_eq!(meta.state, EnvState::Built);
|
|
|
|
// Old env must be gone (destroyed by rebuild)
|
|
assert!(engine.inspect(&old_id).is_err());
|
|
}
|