mod common;
use common::{commit, git, repo_key};
use serde::Deserialize;
use std::path::{Path, PathBuf};
use std::process::Output;
const OWNER: &str = "did:web:owner.tombstone.test";
const KNOT_HOST: &str = "knot.tombstone.test";
const MASTER_KEY_ENV: &str = "KNOT_STATIC_TOMBSTONE_KEY";
const MASTER_KEY: [u8; 32] = [9; 32];
const TOMBSTONE_FILE: &str = "tombstone.json";
fn knot_static_raw(
config: &Path,
args: impl IntoIterator<Item = impl AsRef<std::ffi::OsStr>>,
) -> Output {
common::knot_static_raw(config, MASTER_KEY_ENV, &MASTER_KEY, args, &[])
}
fn knot_static(
config: &Path,
args: impl IntoIterator<Item = impl AsRef<std::ffi::OsStr>>,
) -> Output {
common::knot_static(config, MASTER_KEY_ENV, &MASTER_KEY, args)
}
#[derive(Deserialize, Debug)]
struct Manifest {
generation_id: String,
#[serde(default)]
repos: Vec<RepoEntry>,
}
#[derive(Deserialize, Debug)]
struct RepoEntry {
repo_did: String,
#[serde(default)]
release_id: Option<String>,
}
fn manifest_of(generation: &Path) -> Manifest {
let bytes = std::fs::read(generation.join("manifest.json")).expect("generation manifest");
serde_json::from_slice(&bytes).expect("parse generation manifest")
}
fn tombstone_path(output: &Path) -> PathBuf {
output.join(TOMBSTONE_FILE)
}
fn write_tombstone(output: &Path, generation_id: &str) {
std::fs::write(
tombstone_path(output),
format!("{{\"version\":1,\"generation_id\":\"{generation_id}\",\"recorded_at_unix\":1}}"),
)
.expect("write tombstone");
}
struct Env {
_root: tempfile::TempDir,
config: PathBuf,
output: PathBuf,
}
fn write_config(path: &Path, scan: &Path, output: &Path, root: &Path) {
std::fs::write(
path,
format!(
r#"[knot]
hostname = "{KNOT_HOST}"
owner = "{OWNER}"
[repo]
scan_path = "{}"
default_branch = "main"
object_format = "sha1"
[secrets]
sealed_key_file = "{}"
master_key_env = "KNOT_STATIC_TOMBSTONE_KEY"
[atproto]
plc_directory = "https://127.0.0.1:9"
[output]
root = "{}"
[activate]
nginx_binary = "true"
reload_command = "true"
"#,
scan.display(),
root.join("keys.sealed").display(),
output.display(),
),
)
.unwrap();
}
fn newest_generation(output: &Path) -> PathBuf {
let mut dirs: Vec<PathBuf> = std::fs::read_dir(output.join("generations"))
.expect("generations root")
.flatten()
.map(|entry| entry.path())
.filter(|path| path.is_dir() && !path.is_symlink())
.collect();
assert_eq!(dirs.len(), 1, "expected exactly one generation");
dirs.pop().unwrap()
}
fn setup_env() -> Env {
let root = tempfile::tempdir().expect("tempdir");
let scan = root.path().join("state");
let output = root.path().join("output");
let config = root.path().join("config.toml");
std::fs::create_dir_all(&scan).unwrap();
write_config(&config, &scan, &output, root.path());
knot_static(&config, &["init"]);
let source = root.path().join("source-alpha");
std::fs::create_dir_all(&source).unwrap();
git(&source, &["init", "-q", "-b", "main"]);
commit(
&source,
"README.md",
"# alpha\ntombstone\n",
"initial commit",
);
commit(&source, "src/alpha.rs", "fn main() {}\n", "add code");
let _ = knot_static_raw(
&config,
&[
"repo",
"import",
"--owner",
OWNER,
"--rkey",
"alpha",
"--name",
"alpha",
"--default-branch",
"main",
source.to_str().unwrap(),
],
);
Env {
_root: root,
config,
output,
}
}
fn inventory(root: &Path) -> Vec<(String, u64)> {
fn visit(dir: &Path, root: &Path, out: &mut Vec<(String, u64)>) {
for entry in std::fs::read_dir(dir).expect("read dir") {
let path = entry.expect("dir entry").path();
let metadata = std::fs::symlink_metadata(&path).expect("stat");
if metadata.is_dir() {
visit(&path, root, out);
} else if metadata.is_file() {
let relative = path
.strip_prefix(root)
.unwrap()
.to_string_lossy()
.into_owned();
out.push((relative, metadata.len()));
}
}
}
let mut out = Vec::new();
visit(root, root, &mut out);
out.sort();
out
}
/// the truncation breaks the recorded length until a rebuild rematerializes the release.
fn corrupt_one_release_object(output: &Path, manifest: &Manifest) -> PathBuf {
let repo = manifest.repos.first().expect("repo entry");
let release_id = repo.release_id.as_ref().expect("release id");
let release_dir = output
.join("repos")
.join(repo_key(&repo.repo_did))
.join("releases")
.join(release_id);
let victim = walk(&release_dir.join("export/objects"))
.into_iter()
.max_by_key(|path| std::fs::metadata(path).map_or(0, |meta| meta.len()))
.expect("release owns at least one object file");
let bytes = std::fs::read(&victim).expect("read victim");
// object files are read-only, which is why the file has to go first.
std::fs::remove_file(&victim).expect("remove victim");
std::fs::write(&victim, &bytes[..bytes.len() / 2]).expect("truncate victim");
victim
}
fn walk(dir: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
let Ok(entries) = std::fs::read_dir(dir) else {
return out;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
out.extend(walk(&path));
} else {
out.push(path);
}
}
out
}
#[test]
fn activate_arms_and_retires_tombstone_then_generate_is_unchanged() {
let env = setup_env();
knot_static(&env.config, &["generate"]);
let generation = manifest_of(&newest_generation(&env.output)).generation_id;
activate_ok(&env.config, &generation);
assert!(
!tombstone_path(&env.output).exists(),
"successful activation must retire the tombstone"
);
let out = knot_static(&env.config, &["generate"]);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
combined.contains("unchanged"),
"second generate must be a no-op, got: {combined}"
);
}
#[test]
fn activate_refuses_while_tombstone_present() {
let env = setup_env();
knot_static(&env.config, &["generate"]);
let generation = manifest_of(&newest_generation(&env.output)).generation_id;
write_tombstone(&env.output, &generation);
let out = knot_static_raw(&env.config, &["activate", &generation]);
assert!(
!out.status.success(),
"activate must refuse while a tombstone is present"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("tombstone"),
"refusal must explain the tombstone, got: {stderr}"
);
knot_static(&env.config, &["generate"]);
assert!(
!tombstone_path(&env.output).exists(),
"generate must clear the tombstone after a full publication"
);
activate_ok(&env.config, &generation);
}
#[test]
fn tombstone_forces_rebuild_and_repairs_corrupt_release() {
let env = setup_env();
knot_static(&env.config, &["generate"]);
let manifest = manifest_of(&newest_generation(&env.output));
activate_ok(&env.config, &manifest.generation_id);
let victim = corrupt_one_release_object(&env.output, &manifest);
let damaged = inventory(&victim.parent().unwrap());
write_tombstone(&env.output, &manifest.generation_id);
let out = knot_static(&env.config, &["generate"]);
assert!(
out.status.success(),
"generate must self-heal: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
!tombstone_path(&env.output).exists(),
"generate must clear the tombstone after the rebuild"
);
let healed = inventory(&victim.parent().unwrap());
let damaged_paths: Vec<&str> = damaged.iter().map(|(path, _)| path.as_str()).collect();
let healed_paths: Vec<&str> = healed.iter().map(|(path, _)| path.as_str()).collect();
assert_eq!(
damaged_paths, healed_paths,
"file set must survive the repair"
);
let total_before: u64 = damaged.iter().map(|(_, len)| len).sum();
let total_after: u64 = healed.iter().map(|(_, len)| len).sum();
assert!(
total_after > total_before,
"the truncated object must be re-materialized ({total_before} -> {total_after})"
);
let out = knot_static(&env.config, &["generate"]);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
combined.contains("unchanged"),
"post-heal generate must be a no-op, got: {combined}"
);
}
fn activate_ok(config: &Path, generation: &str) -> Output {
knot_static(config, ["activate", generation])
}