mod common;
use common::{commit, git, repo_key};
use serde::Deserialize;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Output;
const OWNER: &str = "did:web:owner.phase4.test";
const KNOT_HOST: &str = "knot.phase4.test";
const MASTER_KEY_ENV: &str = "KNOT_STATIC_PHASE0_KEY";
const MASTER_KEY: [u8; 32] = [9; 32];
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 {
#[serde(default)]
repos: Vec<RepoEntry>,
}
#[derive(Deserialize, Debug, Clone)]
struct RepoEntry {
#[serde(default)]
git_export_hash: Option<String>,
}
impl Manifest {
fn load(generation: &Path) -> Self {
let bytes = std::fs::read(generation.join("manifest.json")).expect("generation manifest");
serde_json::from_slice(&bytes).expect("parse manifest")
}
}
fn export_mount(generation: &Path, repo_did: &str) -> PathBuf {
let digest = sha256_hex(repo_did.as_bytes());
generation.join("www/_git").join(&digest[..32])
}
struct Env {
_root: tempfile::TempDir,
config: PathBuf,
output: PathBuf,
layout_repo: PathBuf,
repo_did: String,
}
#[test]
fn test_git_never_spawns_background_maintenance() {
let dir = tempfile::tempdir().unwrap();
git(dir.path(), ["init", "-q"]);
assert_eq!(
git(dir.path(), ["config", "--get", "maintenance.auto"]),
"false"
);
}
#[test]
fn repo_list_says_what_it_read_when_it_finds_nothing() {
let root = tempfile::tempdir().unwrap();
let scan = root.path().join("state");
std::fs::create_dir_all(&scan).unwrap();
std::fs::write(
root.path().join("config.toml"),
format!(
r#"[knot]
hostname = "{KNOT_HOST}"
owner = "{OWNER}"
[repo]
scan_path = "{}"
[secrets]
sealed_key_file = "{}"
master_key_env = "KNOT_STATIC_PHASE0_KEY"
[atproto]
plc_directory = "https://127.0.0.1:9"
[output]
root = "{}"
"#,
scan.display(),
root.path().join("keys.sealed").display(),
root.path().join("output").display(),
),
)
.unwrap();
let config = root.path().join("config.toml");
knot_static(&config, ["init"]);
let listed = knot_static(&config, ["repo", "list"]);
assert!(listed.stdout.is_empty());
let stderr = String::from_utf8_lossy(&listed.stderr);
assert!(
stderr.contains("no hosted repos") && stderr.contains("0 registry object"),
"{stderr}"
);
}
fn setup_env(commits: usize) -> Env {
let root = tempfile::tempdir().expect("tempdir");
let scan = root.path().join("state");
let output = root.path().join("output");
std::fs::create_dir_all(&scan).unwrap();
let source = root.path().join("source-sample");
std::fs::create_dir_all(&source).unwrap();
git(&source, ["init", "-q", "-b", "main"]);
for i in 0..commits {
commit(
&source,
&format!("dir{i}/file{i}.txt"),
&format!("contents {i}\n"),
&format!("commit {i}"),
);
}
git(&source, ["branch", "feature"]);
std::fs::write(
root.path().join("config.toml"),
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_PHASE0_KEY"
[atproto]
plc_directory = "https://127.0.0.1:9"
[output]
root = "{}"
[activate]
nginx_binary = "true"
"#,
scan.display(),
root.path().join("keys.sealed").display(),
output.display(),
),
)
.unwrap();
let config = root.path().join("config.toml");
knot_static(&config, ["init"]);
// plc submission can't resolve offline, but the local repo still gets registered.
let imported = knot_static_raw(
&config,
[
"repo",
"import",
"--owner",
OWNER,
"--rkey",
"sample",
"--name",
"sample",
"--default-branch",
"main",
source.to_str().unwrap(),
],
);
let imported_stderr = String::from_utf8_lossy(&imported.stderr);
assert!(
imported_stderr.contains("submit plc operation"),
"import stopped before the PLC submission: {imported_stderr}"
);
let listed = knot_static(&config, ["repo", "list"]);
let repo_did = String::from_utf8_lossy(&listed.stdout)
.split_whitespace()
.next()
.expect("repo DID")
.to_string();
// pushes into its checked-out branch are allowed because the compiler reads refs from it.
let layout_repo = find_layout_repo(&scan, &repo_did).expect("layout repo copy");
git(
&layout_repo,
["config", "receive.denyCurrentBranch", "ignore"],
);
Env {
_root: root,
config,
output,
layout_repo,
repo_did,
}
}
fn find_layout_repo(scan: &Path, repo_did: &str) -> Option<PathBuf> {
let identity = repo_did.replace([':', '.'], "");
let mut found = None;
visit_dirs(scan, &mut |dir| {
if found.is_some() {
return;
}
if dir.join("HEAD").is_file() && dir.join("config").is_file() {
let concatenated: String = dir
.strip_prefix(scan)
.unwrap_or(dir)
.to_string_lossy()
.chars()
.filter(|c| *c != '/' && *c != '\\')
.collect();
if identity.contains(&concatenated) || concatenated.contains(&identity) {
found = Some(dir.to_path_buf());
}
}
});
found
}
fn visit_dirs(dir: &Path, callback: &mut impl FnMut(&Path)) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
callback(&path);
visit_dirs(&path, callback);
}
}
}
fn push_commit(env: &Env, marker: &str, message: &str) -> String {
let wt = env._root.path().join("push-worktree");
if !wt.exists() {
std::fs::create_dir_all(&wt).unwrap();
git(&wt, ["clone", "-q", env.layout_repo.to_str().unwrap(), "."]);
}
std::fs::write(wt.join(marker), "new\n").unwrap();
git(&wt, ["add", "-A"]);
git(&wt, ["commit", "-q", "-m", message]);
git(
&wt,
["push", "-q", env.layout_repo.to_str().unwrap(), "HEAD:main"],
);
git(&wt, ["rev-parse", "HEAD"])
}
fn generations(output: &Path) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = std::fs::read_dir(output.join("generations"))
.expect("generations root")
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir() && !p.file_name().unwrap().to_str().unwrap().starts_with('.'))
.collect();
dirs.sort_by_key(|p| std::fs::metadata(p).unwrap().modified().unwrap());
dirs
}
fn latest_generation(output: &Path) -> PathBuf {
generations(output).pop().expect("at least one generation")
}
fn repo_dir(output: &Path, repo_did: &str) -> PathBuf {
output.join("repos").join(repo_key(repo_did))
}
fn releases(output: &Path, repo_did: &str) -> Vec<PathBuf> {
let root = repo_dir(output, repo_did).join("releases");
let mut dirs: Vec<PathBuf> = std::fs::read_dir(&root)
.unwrap_or_else(|e| panic!("releases root {}: {e}", root.display()))
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir() && !p.file_name().unwrap().to_str().unwrap().starts_with('.'))
.collect();
dirs.sort();
dirs
}
#[derive(Deserialize, Debug)]
struct ReleaseMarker {
files: Vec<ReleaseFile>,
}
#[derive(Deserialize, Debug)]
struct ReleaseFile {
path: String,
bytes: u64,
}
fn release_is_valid(release_dir: &Path) -> bool {
let Ok(bytes) = std::fs::read(release_dir.join("release.json")) else {
return false;
};
let Ok(marker) = serde_json::from_slice::<ReleaseMarker>(&bytes) else {
return false;
};
marker.files.iter().all(|file| {
std::fs::symlink_metadata(release_dir.join(&file.path))
.map(|meta| meta.is_file() && meta.len() == file.bytes)
.unwrap_or(false)
})
}
fn snapshot_release_bytes(release_dir: &Path) -> BTreeMap<String, (u64, String)> {
let bytes = std::fs::read(release_dir.join("release.json")).expect("release marker");
let marker: ReleaseMarker = serde_json::from_slice(&bytes).expect("parse release marker");
marker
.files
.iter()
.map(|file| {
let contents = std::fs::read(release_dir.join(&file.path)).expect("release file");
(
file.path.clone(),
(contents.len() as u64, sha256_hex(&contents)),
)
})
.collect()
}
fn sha256_hex(bytes: &[u8]) -> String {
use sha2::Digest as _;
knot_types::lowercase_hex(&sha2::Sha256::digest(bytes))
}
fn cache_kinds(output: &Path, repo_did: &str) -> Vec<String> {
let cache = repo_dir(output, repo_did).join("cache");
let Ok(entries) = std::fs::read_dir(&cache) else {
return Vec::new();
};
let mut kinds: Vec<String> = entries
.flatten()
.filter(|e| e.path().is_dir())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
kinds.sort();
kinds
}
fn walk_files(root: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
fn visit(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
visit(&path, out);
} else {
out.push(path);
}
}
}
visit(root, &mut out);
out
}
#[test]
fn cache_builds_then_reuses_and_survives_eviction() {
let env = setup_env(3);
knot_static(&env.config, ["generate"]);
assert_eq!(
cache_kinds(&env.output, &env.repo_did),
["atproto-cobs", "export"]
);
let first_releases = releases(&env.output, &env.repo_did);
assert_eq!(first_releases.len(), 1, "one snapshot state, one release");
assert!(
release_is_valid(&first_releases[0]),
"published release must be complete and valid"
);
knot_static(&env.config, ["generate"]);
assert_eq!(generations(&env.output).len(), 1, "no new generation");
push_commit(&env, "pushed.txt", "new main commit");
knot_static(&env.config, ["generate"]);
let now_releases = releases(&env.output, &env.repo_did);
assert_eq!(
now_releases.len(),
2,
"a new snapshot state publishes a new release"
);
let export_entries = || -> usize {
std::fs::read_dir(repo_dir(&env.output, &env.repo_did).join("cache/export"))
.expect("export cache kind")
.flatten()
.filter(|e| e.file_name().to_string_lossy().ends_with(".d"))
.count()
};
assert_eq!(
export_entries(),
2,
"one cache entry per export fingerprint"
);
let before = snapshot_release_bytes(&first_releases[0]);
assert!(!before.is_empty());
let cache_root = repo_dir(&env.output, &env.repo_did).join("cache");
std::fs::remove_dir_all(&cache_root).expect("evict cache");
push_commit(&env, "pushed2.txt", "post-eviction commit");
knot_static(&env.config, ["generate"]);
assert_eq!(
export_entries(),
1,
"an evicted cache rebuilds only the new export"
);
for release in &now_releases {
assert!(
release_is_valid(release),
"release {} must stay valid after cache eviction",
release.display()
);
}
assert_eq!(
before,
snapshot_release_bytes(&first_releases[0]),
"cache eviction must not touch release-owned bytes"
);
let manifest = Manifest::load(&latest_generation(&env.output));
assert!(manifest.repos.iter().all(|r| r.git_export_hash.is_some()));
}
#[test]
fn damaged_cache_entries_are_never_trusted_and_are_rebuilt() {
let env = setup_env(2);
knot_static(&env.config, ["generate"]);
let export_cache = repo_dir(&env.output, &env.repo_did).join("cache/export");
let damaged = walk_files(&export_cache)
.into_iter()
.find(|p| p.to_string_lossy().contains("/objects/") && !p.ends_with(".complete.json"))
.expect("a cached export object");
// object files are read-only, remove the file before rewriting it.
std::fs::remove_file(&damaged).unwrap();
std::fs::write(&damaged, b"").unwrap();
push_commit(&env, "corrupt-pushed.txt", "force rebuild after corruption");
knot_static(&env.config, ["generate"]);
let export = export_mount(&latest_generation(&env.output), &env.repo_did);
git(&export, ["fsck", "--no-dangling"]);
assert!(git(&export, ["log", "--oneline", "main"]).contains("force rebuild after corruption"));
}
#[test]
fn export_owns_its_files_preserves_packs_and_reuses_releases() {
// 150 commits push the fetched pack past git's unpack limit, which keeps it a pack.
let env = setup_env(150);
knot_static(&env.config, ["generate"]);
let generation = latest_generation(&env.output);
let manifest = Manifest::load(&generation);
let export = export_mount(&generation, &env.repo_did);
let pack_files: Vec<_> = std::fs::read_dir(export.join("objects/pack"))
.expect("pack dir")
.flatten()
.filter(|e| e.path().extension().is_some_and(|ext| ext == "pack"))
.collect();
assert!(
!pack_files.is_empty(),
"the pack file itself must be present"
);
assert!(export.join("HEAD").is_file());
assert!(export.join("export.fingerprint").is_file());
assert!(export.join("smart/advertisement").is_file());
assert!(export.join("smart/upload-pack").is_file());
assert!(
!export.join("objects/info/alternates").exists(),
"exports must never borrow objects from elsewhere"
);
let fingerprint = std::fs::read_to_string(export.join("export.fingerprint")).unwrap();
assert_eq!(
fingerprint.trim(),
manifest.repos[0]
.git_export_hash
.as_deref()
.expect("export hash"),
"exported source identity comes from the snapshot"
);
let first_releases = releases(&env.output, &env.repo_did);
assert_eq!(first_releases.len(), 1);
assert!(release_is_valid(&first_releases[0]));
assert_eq!(
std::fs::canonicalize(&export).unwrap(),
std::fs::canonicalize(first_releases[0].join("export")).unwrap(),
"generation export must be the sealed release's export"
);
// the owner isn't in the export fingerprint, which keeps the release id the same.
let config = std::fs::read_to_string(&env.config).unwrap();
std::fs::write(
&env.config,
config.replace(OWNER, "did:web:owner.renamed.test"),
)
.unwrap();
knot_static(&env.config, ["generate"]);
assert_eq!(generations(&env.output).len(), 2, "config change rebuilds");
let now_releases = releases(&env.output, &env.repo_did);
assert_eq!(
now_releases.len(),
1,
"an unchanged snapshot keeps its single release"
);
assert!(release_is_valid(&now_releases[0]));
let export_entries: Vec<_> =
std::fs::read_dir(repo_dir(&env.output, &env.repo_did).join("cache/export"))
.expect("export cache kind")
.flatten()
.filter(|e| e.file_name().to_string_lossy().ends_with(".d"))
.collect();
assert_eq!(
export_entries.len(),
1,
"one export fingerprint, one cache entry"
);
}