use std::error::Error;
use std::fs;
use crate::db::Database;
use super::error::VacuumError;
use super::report::SweepBlocker;
use super::test_support::{
build_store, committed_root, expect_refusal, node_path, snapshot_tree, stats, store_dir,
};
#[test]
fn store_without_wal_is_forensic_refusal() -> Result<(), Box<dyn Error>> {
let temp = tempfile::tempdir()?;
build_store(temp.path(), 1, &[b"data"], 2)?;
fs::remove_file(temp.path().join("shard-0/shard.wal"))?;
let before = snapshot_tree(temp.path())?;
let error = expect_refusal(stats(temp.path()))?;
assert!(
matches!(
&error,
VacuumError::StoreWithoutWal {
shard_id: 0,
store_present: true,
..
}
),
"got {error:?}"
);
let text = error.to_string();
assert!(text.contains("forensic"), "present-store text names damage");
assert_eq!(
before,
snapshot_tree(temp.path())?,
"refusal changed nothing"
);
Ok(())
}
#[test]
fn crash_transient_names_remedy_and_remedy_works() -> Result<(), Box<dyn Error>> {
let temp = tempfile::tempdir()?;
build_store(temp.path(), 4, &[b"solo"], 2)?;
stats(temp.path())?;
let target = (0..4)
.find(|shard_id| !temp.path().join(format!("shard-{shard_id}")).exists())
.ok_or("one shard must be unmaterialised")?;
fs::create_dir(temp.path().join(format!("shard-{target}")))?;
let error = expect_refusal(stats(temp.path()))?;
assert!(
matches!(
&error,
VacuumError::StoreWithoutWal {
store_present: false,
..
}
),
"got {error:?}"
);
let text = error.to_string();
assert!(
text.contains("reopen") && text.contains("re-run"),
"the refusal must NAME its remedy (fail-closed, not fail-stuck): {text}"
);
let db = Database::open(temp.path())?;
let key = (0..10_000_u64)
.map(|candidate| format!("cure:{candidate}").into_bytes())
.find(|key| db.shard_for(key) == target)
.ok_or("a key routing to the target shard must exist")?;
db.append(key, vec![b"cure".to_vec()], 0)?;
drop(db);
stats(temp.path())?;
Ok(())
}
#[test]
fn corrupt_wal_fails_closed() -> Result<(), Box<dyn Error>> {
let temp = tempfile::tempdir()?;
build_store(temp.path(), 1, &[b"data"], 2)?;
let wal_path = temp.path().join("shard-0/shard.wal");
let mut bytes = fs::read(&wal_path)?;
assert!(bytes.len() > 12, "the WAL holds at least one frame");
bytes[8] ^= 0xFF;
fs::write(&wal_path, bytes)?;
let error = expect_refusal(stats(temp.path()))?;
assert!(
matches!(&error, VacuumError::CorruptWal { shard_id: 0, .. }),
"got {error:?}"
);
Ok(())
}
#[test]
fn config_unreadable_refuses_both_shapes() -> Result<(), Box<dyn Error>> {
let temp = tempfile::tempdir()?;
build_store(temp.path(), 1, &[b"data"], 1)?;
let config_path = temp.path().join("config.json");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&config_path, fs::Permissions::from_mode(0o000))?;
let error = expect_refusal(stats(temp.path()))?;
assert!(
matches!(&error, VacuumError::ConfigUnreadable { .. }),
"got {error:?}"
);
fs::set_permissions(&config_path, fs::Permissions::from_mode(0o644))?;
}
fs::remove_file(&config_path)?;
let before = snapshot_tree(temp.path())?;
let error = expect_refusal(stats(temp.path()))?;
assert!(
matches!(&error, VacuumError::ConfigUnreadable { .. }),
"got {error:?}"
);
let text = error.to_string();
assert!(
text.contains("restore") && text.contains("re-run"),
"the refusal names what the operator must decide: {text}"
);
assert_eq!(
before,
snapshot_tree(temp.path())?,
"refusal changed nothing"
);
Ok(())
}
#[test]
fn node_hash_mismatch_refuses_and_preserves_evidence() -> Result<(), Box<dyn Error>> {
let temp = tempfile::tempdir()?;
build_store(temp.path(), 1, &[b"key-a", b"key-b"], 3)?;
let root = committed_root(temp.path(), 0).ok_or("shard 0 committed")?;
let store = store_dir(temp.path(), 0);
let root_path = node_path(&store, root);
let donor = find_other_node_file(&store, &root_path)?;
fs::write(&root_path, fs::read(donor)?)?;
let planted = fs::read(&root_path)?;
let error = expect_refusal(stats(temp.path()))?;
match &error {
VacuumError::NodeHashMismatch { path, .. } => {
assert_eq!(path, &root_path, "the refusal names the file");
}
other => return Err(format!("expected NodeHashMismatch, got {other:?}").into()),
}
assert_eq!(
fs::read(&root_path)?,
planted,
"the evidence file must survive byte-identical"
);
Ok(())
}
#[test]
fn wal_without_store_is_unexpected_layout() -> Result<(), Box<dyn Error>> {
let temp = tempfile::tempdir()?;
build_store(temp.path(), 1, &[b"data"], 1)?;
fs::remove_dir_all(store_dir(temp.path(), 0))?;
let error = expect_refusal(stats(temp.path()))?;
assert!(
matches!(&error, VacuumError::UnexpectedLayout { .. }),
"got {error:?}"
);
Ok(())
}
#[test]
fn malformed_store_entries_reported_stats_completes() -> Result<(), Box<dyn Error>> {
let temp = tempfile::tempdir()?;
build_store(temp.path(), 1, &[b"data"], 2)?;
let store = store_dir(temp.path(), 0);
fs::write(store.join("not-a-fanout-dir"), b"foreign")?;
let prefix_dir = fs::read_dir(&store)?
.filter_map(Result::ok)
.map(|entry| entry.path())
.find(|path| path.is_dir())
.ok_or("store has a fanout dir")?;
fs::write(prefix_dir.join("tooshort"), b"bad name")?;
fs::write(prefix_dir.join(".node-orphan.tmp"), b"install debris")?;
#[cfg(unix)]
std::os::unix::fs::symlink(store.join("not-a-fanout-dir"), prefix_dir.join("a-symlink"))?;
let report = stats(temp.path())?;
let shard = &report.shards[0];
assert!(
shard.malformed.len() >= 2,
"foreign + bad-name (+ symlink) reported: {:?}",
shard.malformed
);
assert_eq!(shard.temp_debris_files, 1, "debris counted separately");
assert!(
report.sweep_blockers.iter().any(|blocker| matches!(
blocker,
SweepBlocker::MalformedStoreEntries { shard_id: 0, .. }
)),
"malformed entries block that store's sweep"
);
assert_eq!(
report.totals.enumerated_nodes,
report.totals.marked_nodes + report.totals.unmarked_nodes
);
Ok(())
}
#[test]
fn foreign_shard_dir_entry_reported() -> Result<(), Box<dyn Error>> {
let temp = tempfile::tempdir()?;
build_store(temp.path(), 1, &[b"data"], 1)?;
fs::write(temp.path().join("shard-0/unexpected.bin"), b"???")?;
let report = stats(temp.path())?;
assert!(
report.shards[0]
.malformed
.iter()
.any(|entry| entry.path.ends_with("unexpected.bin")),
"shard-level foreign entry must be reported"
);
Ok(())
}
#[test]
fn live_writer_lock_refuses() -> Result<(), Box<dyn Error>> {
let temp = tempfile::tempdir()?;
build_store(temp.path(), 1, &[b"data"], 1)?;
let db = Database::open(temp.path())?;
let error = expect_refusal(stats(temp.path()))?;
assert!(
matches!(&error, VacuumError::Locked { .. }),
"got {error:?}"
);
drop(db);
stats(temp.path())?;
Ok(())
}
fn find_other_node_file(
store: &std::path::Path,
excluded: &std::path::Path,
) -> Result<std::path::PathBuf, Box<dyn Error>> {
for prefix in fs::read_dir(store)? {
let prefix = prefix?.path();
if !prefix.is_dir() {
continue;
}
for entry in fs::read_dir(&prefix)? {
let path = entry?.path();
if path != excluded && path.is_file() {
return Ok(path);
}
}
}
Err("store must hold more than one node".into())
}