mod common;
use common::{commit, git, repo_token};
use serde::Deserialize;
use sha2::{Digest as _, Sha256};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Output;
const OWNER: &str = "did:web:owner.phase0.test";
const KNOT_HOST: &str = "knot.phase0.test";
#[derive(Deserialize, Debug)]
struct Manifest {
repos: Vec<RepoEntry>,
#[serde(default)]
stats: BuildStats,
}
#[derive(Deserialize, Debug, Default, PartialEq)]
struct BuildStats {
#[serde(default)]
repos: usize,
#[serde(default)]
public_refs: usize,
#[serde(default)]
git_export_bytes: usize,
#[serde(default)]
build_seconds: Option<f64>,
#[serde(default)]
timings: Option<PhaseTimings>,
}
#[derive(Deserialize, Debug, PartialEq)]
struct PhaseTimings {
planning_seconds: Option<f64>,
git_export_seconds: Option<f64>,
}
#[derive(Deserialize, Debug, Clone)]
struct RepoEntry {
repo_did: String,
}
impl Manifest {
fn load(generation: &Path) -> Self {
let manifest_bytes =
std::fs::read(generation.join("manifest.json")).expect("generation manifest");
serde_json::from_slice(&manifest_bytes).expect("parse generation manifest")
}
}
const MASTER_KEY_ENV: &str = "KNOT_STATIC_PHASE0_KEY";
const MASTER_KEY: [u8; 32] = [7; 32];
fn knot_static(
config: &Path,
args: impl IntoIterator<Item = impl AsRef<std::ffi::OsStr>>,
) -> Output {
common::knot_static_raw(config, MASTER_KEY_ENV, &MASTER_KEY, args, &[])
}
fn create_test_repo(source: &Path, rkey: &str) -> String {
std::fs::create_dir_all(source).unwrap();
git(source, ["init", "-q", "-b", "main"]);
commit(
source,
"README.md",
&format!("# {rkey}\nhello world\n"),
"initial commit",
);
commit(
source,
"src/main.rs",
"fn main() { println!(\"ok\"); }\n",
"add code",
);
git(source, ["branch", "feature", "HEAD"]);
git(source, ["tag", "v1.0", "HEAD"]);
git(source, ["rev-parse", "HEAD"])
}
struct TestEnv {
_root: tempfile::TempDir,
config: PathBuf,
output: PathBuf,
scan: PathBuf,
repo_source: PathBuf,
tip_oid: String,
repo_did: String,
}
fn setup_test_env() -> TestEnv {
let root = tempfile::tempdir().expect("tempdir");
let scan = root.path().join("state");
let output = root.path().join("output");
let repo_source = root.path().join("source");
let config = root.path().join("config.toml");
std::fs::create_dir_all(&scan).unwrap();
let tip_oid = create_test_repo(&repo_source, "sample");
std::fs::write(
&config,
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 init = knot_static(&config, ["init"]);
assert!(
init.status.success(),
"init failed: {}",
String::from_utf8_lossy(&init.stderr)
);
let _ = knot_static(
&config,
[
"repo",
"import",
"--owner",
OWNER,
"--rkey",
"sample",
"--name",
"sample",
"--default-branch",
"main",
repo_source.to_str().unwrap(),
],
);
let listed = knot_static(&config, ["repo", "list"]);
assert!(listed.status.success(), "repo list failed");
let repo_did = String::from_utf8(listed.stdout)
.unwrap()
.split_whitespace()
.next()
.expect("repo DID")
.to_string();
TestEnv {
_root: root,
config,
output,
scan,
repo_source,
tip_oid,
repo_did,
}
}
fn sha256_hex(bytes: &[u8]) -> String {
knot_types::lowercase_hex(&Sha256::digest(bytes))
}
fn get_only_generation_dir(output: &Path) -> PathBuf {
let gens = output.join("generations");
let mut dirs: Vec<PathBuf> = std::fs::read_dir(&gens)
.expect("read generations")
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.is_dir() && !p.file_name().unwrap().to_str().unwrap().starts_with('.'))
.collect();
assert_eq!(
dirs.len(),
1,
"expected exactly 1 generation directory, found {:?}",
dirs
);
dirs.pop().unwrap()
}
#[test]
fn test_build_timing_and_work_counters() {
let env = setup_test_env();
let gen_res = knot_static(&env.config, ["generate"]);
assert!(
gen_res.status.success(),
"generate failed: {}",
String::from_utf8_lossy(&gen_res.stderr)
);
let gen_dir = get_only_generation_dir(&env.output);
let manifest = Manifest::load(&gen_dir);
assert_eq!(manifest.stats.repos, 1);
assert!(
manifest.stats.public_refs >= 2,
"must have public refs (main, feature, v1.0)"
);
assert!(
manifest.stats.git_export_bytes > 0,
"must record git export bytes"
);
assert!(manifest.stats.build_seconds.unwrap() >= 0.0);
let timings = manifest
.stats
.timings
.as_ref()
.expect("phase timings must be present");
assert!(timings.planning_seconds.unwrap() >= 0.0);
assert!(timings.git_export_seconds.unwrap() >= 0.0);
let stderr = String::from_utf8_lossy(&gen_res.stderr);
assert!(
stderr.contains("generated ") || stderr.contains("reused "),
"stderr must report completion status line: {stderr}"
);
}
fn find_hosted_repo(scan: &Path, tip: &str) -> PathBuf {
fn visit(dir: &Path, tip: &str, out: &mut Option<PathBuf>) {
if out.is_some() {
return;
}
if dir.join("HEAD").is_file() && dir.join("config").is_file() {
if let Ok(oid) = std::fs::read_to_string(dir.join("refs/heads/main")) {
if oid.trim() == tip {
*out = Some(dir.to_path_buf());
return;
}
}
}
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
if entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false) {
visit(&entry.path(), tip, out);
}
}
}
let mut out = None;
visit(scan, tip, &mut out);
out.expect("hosted layout repository with the imported tip")
}
#[test]
fn test_old_output_immutability() {
let env = setup_test_env();
let gen_res = knot_static(&env.config, ["generate"]);
assert!(gen_res.status.success());
let gen_dir = get_only_generation_dir(&env.output);
let mut file_snapshots: HashMap<PathBuf, (u64, String)> = HashMap::new();
for entry in walkdir(gen_dir.as_path()) {
if entry.is_file() {
let rel = entry.strip_prefix(&gen_dir).unwrap().to_path_buf();
let bytes = std::fs::read(&entry).unwrap();
let hex = sha256_hex(&bytes);
file_snapshots.insert(rel, (bytes.len() as u64, hex));
}
}
assert!(!file_snapshots.is_empty(), "generation must have files");
let second_gen = knot_static(&env.config, ["generate"]);
assert!(second_gen.status.success());
for (rel_path, (orig_len, orig_hash)) in &file_snapshots {
let current_path = gen_dir.join(rel_path);
assert!(
current_path.exists(),
"file {} disappeared from generation 1",
rel_path.display()
);
let current_bytes = std::fs::read(¤t_path).unwrap();
assert_eq!(
current_bytes.len() as u64,
*orig_len,
"file length changed: {}",
rel_path.display()
);
let current_hash = sha256_hex(¤t_bytes);
assert_eq!(
current_hash,
*orig_hash,
"file contents mutated: {}",
rel_path.display()
);
}
}
#[test]
fn test_stale_and_invalid_activation_defenses() {
let env = setup_test_env();
let gen_res = knot_static(&env.config, ["generate"]);
assert!(gen_res.status.success());
let gen_dir = get_only_generation_dir(&env.output);
let gen_id = gen_dir.file_name().unwrap().to_str().unwrap().to_string();
let invalid_id_act = knot_static(&env.config, ["activate", "not-a-valid-hex-id"]);
assert!(
!invalid_id_act.status.success(),
"must reject non-hex generation ID"
);
assert!(
String::from_utf8_lossy(&invalid_id_act.stderr).contains("64-character hexadecimal id")
);
let fake_id = "0000000000000000000000000000000000000000000000000000000000000000";
let fake_act = knot_static(&env.config, ["activate", fake_id]);
assert!(
!fake_act.status.success(),
"must reject non-existent generation ID"
);
assert!(String::from_utf8_lossy(&fake_act.stderr).contains("does not exist"));
let act_valid = knot_static(&env.config, ["activate", &gen_id]);
assert!(
act_valid.status.success(),
"valid activation failed: {}",
String::from_utf8_lossy(&act_valid.stderr)
);
let current_link = env.output.join("current");
assert!(current_link.exists(), "current symlink must exist");
let target = std::fs::read_link(¤t_link).unwrap();
assert!(target.to_str().unwrap().contains(&gen_id));
let bad_gen = env
.output
.join("generations/1111111111111111111111111111111111111111111111111111111111111111");
copy_dir_all(&gen_dir, &bad_gen);
let bad_manifest_path = bad_gen.join("manifest.json");
let mut manifest_val: serde_json::Value =
serde_json::from_slice(&std::fs::read(&bad_manifest_path).unwrap()).unwrap();
manifest_val["generation_id"] =
serde_json::json!("1111111111111111111111111111111111111111111111111111111111111111");
manifest_val["hostname"] = serde_json::json!("tampered.example");
std::fs::remove_file(&bad_manifest_path).unwrap();
std::fs::write(
&bad_manifest_path,
serde_json::to_vec_pretty(&manifest_val).unwrap(),
)
.unwrap();
let tampered_act = knot_static(
&env.config,
[
"activate",
"1111111111111111111111111111111111111111111111111111111111111111",
],
);
assert!(
!tampered_act.status.success(),
"must reject tampered generation"
);
assert!(
String::from_utf8_lossy(&tampered_act.stderr)
.contains("does not match computed semantic hash")
);
let target_after = std::fs::read_link(¤t_link).unwrap();
assert!(
target_after.to_str().unwrap().contains(&gen_id),
"current symlink must not move on failed activation"
);
// the git export is a mount of the sealed release.
// deleting a file through it deletes release-owned bytes.
let token = repo_token(&env.repo_did);
let target_file = gen_dir.join("www/_git").join(&token).join("HEAD");
let backup = std::fs::read(&target_file).expect("release-owned file before deletion");
std::fs::remove_file(&target_file).unwrap();
let missing_file_act = knot_static(&env.config, ["activate", &gen_id]);
assert!(
!missing_file_act.status.success(),
"must reject generation with a missing release-owned file"
);
let missing_file_stderr = String::from_utf8_lossy(&missing_file_act.stderr);
assert!(
missing_file_stderr.contains("release file") && missing_file_stderr.contains("is missing"),
"stderr was: {missing_file_stderr}"
);
assert_eq!(std::fs::read_link(¤t_link).unwrap(), target);
std::fs::write(&target_file, backup).unwrap();
let active_conf_path = env.output.join("active.conf");
let active_conf_before = std::fs::read(&active_conf_path).expect("read active.conf");
let bad_nginx_config = env.config.parent().unwrap().join("config_bad_nginx.toml");
let config_content = std::fs::read_to_string(&env.config).unwrap();
let bad_config_content =
config_content.replace("nginx_binary = \"true\"", "nginx_binary = \"false\"");
std::fs::write(&bad_nginx_config, bad_config_content).unwrap();
let failed_nginx_act = knot_static(&bad_nginx_config, ["activate", &gen_id]);
assert!(
!failed_nginx_act.status.success(),
"activation must fail when nginx -t fails"
);
assert!(
String::from_utf8_lossy(&failed_nginx_act.stderr)
.contains("nginx configuration test failed (-t)")
);
let active_conf_after =
std::fs::read(&active_conf_path).expect("read active.conf after failed activation");
assert_eq!(
active_conf_after, active_conf_before,
"active.conf must roll back on failed nginx validation"
);
assert_eq!(
std::fs::read_link(¤t_link).unwrap(),
target,
"current link must not move on failed nginx validation"
);
}
#[test]
fn test_multi_repo_whole_knot_preservation() {
let env = setup_test_env();
let repo_b_source = env._root.path().join("source_b");
let _tip_b = create_test_repo(&repo_b_source, "second_repo");
let _ = knot_static(
&env.config,
[
"repo",
"import",
"--owner",
OWNER,
"--rkey",
"second_repo",
"--name",
"second_repo",
"--default-branch",
"main",
repo_b_source.to_str().unwrap(),
],
);
let listed = knot_static(&env.config, ["repo", "list"]);
let dids: Vec<String> = String::from_utf8(listed.stdout)
.unwrap()
.lines()
.filter_map(|l| l.split_whitespace().next().map(String::from))
.collect();
assert_eq!(dids.len(), 2, "knot must host 2 repos");
let repo_a_did = &dids[0];
let repo_b_did = &dids[1];
let full_gen = knot_static(&env.config, ["generate"]);
assert!(
full_gen.status.success(),
"full generate failed: {}",
String::from_utf8_lossy(&full_gen.stderr)
);
let gen_dir = get_only_generation_dir(&env.output);
let manifest = Manifest::load(&gen_dir);
assert_eq!(
manifest.repos.len(),
2,
"full generate must include both repos"
);
let repo_dids: Vec<&str> = manifest.repos.iter().map(|r| r.repo_did.as_str()).collect();
assert!(repo_dids.contains(&repo_a_did.as_str()));
assert!(repo_dids.contains(&repo_b_did.as_str()));
let git_exports_dir = gen_dir.join("www/_git");
for did in [repo_a_did, repo_b_did] {
let export = git_exports_dir.join(repo_token(did));
assert!(export.is_dir(), "git export for {did} must exist");
assert!(export.join("export.fingerprint").is_file());
}
let listing = std::fs::read_to_string(gen_dir.join("www/_atproto/list/50-0.json")).unwrap();
assert!(
listing.contains(repo_a_did.as_str()) && listing.contains(repo_b_did.as_str()),
"listRepos must include both repos: {listing}"
);
}
fn walkdir(dir: &Path) -> Vec<PathBuf> {
let mut files = Vec::new();
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
files.extend(walkdir(&path));
} else {
files.push(path);
}
}
}
files
}
fn copy_dir_all(src: &Path, dst: &Path) {
std::fs::create_dir_all(dst).unwrap();
for entry in std::fs::read_dir(src).unwrap().flatten() {
let path = entry.path();
let target = dst.join(entry.file_name());
if path.is_dir() {
copy_dir_all(&path, &target);
} else {
std::fs::copy(&path, &target).unwrap();
}
}
}
fn atproto_latest(generation: &Path, repo_did: &str) -> serde_json::Value {
serde_json::from_slice(
&std::fs::read(
generation
.join("www/_atproto")
.join(repo_token(repo_did))
.join("latest.json"),
)
.expect("generation carries the repo's getLatestCommit body"),
)
.unwrap()
}
fn generation_dirs(output: &Path) -> std::collections::BTreeSet<PathBuf> {
std::fs::read_dir(output.join("generations"))
.unwrap()
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| {
path.is_dir() && !path.file_name().unwrap().to_str().unwrap().starts_with('.')
})
.collect()
}
#[test]
fn test_atproto_rev_moves_only_when_the_repo_does() {
let env = setup_test_env();
let generate = |env: &TestEnv| {
let result = knot_static(&env.config, ["generate"]);
assert!(
result.status.success(),
"generate failed: {}",
String::from_utf8_lossy(&result.stderr)
);
};
let newest = |env: &TestEnv, before: &std::collections::BTreeSet<PathBuf>| {
let added: Vec<PathBuf> = generation_dirs(&env.output)
.difference(before)
.cloned()
.collect();
assert_eq!(added.len(), 1, "exactly one new generation: {added:?}");
added[0].clone()
};
let none = std::collections::BTreeSet::new();
generate(&env);
let first_dir = newest(&env, &none);
let first = atproto_latest(&first_dir, &env.repo_did);
let seen = generation_dirs(&env.output);
generate(&env);
assert_eq!(generation_dirs(&env.output), seen);
let hosted = find_hosted_repo(&env.scan, &env.tip_oid);
commit(&env.repo_source, "more.txt", "more\n", "more");
git(
&hosted,
["fetch", "-q", env.repo_source.to_str().unwrap(), "main"],
);
git(&hosted, ["update-ref", "refs/heads/main", "FETCH_HEAD"]);
generate(&env);
let second_dir = newest(&env, &seen);
let second = atproto_latest(&second_dir, &env.repo_did);
assert!(second["rev"].as_str() > first["rev"].as_str());
assert_ne!(second["cid"], first["cid"]);
let seen = generation_dirs(&env.output);
git(&hosted, ["update-ref", "refs/heads/main", &env.tip_oid]);
generate(&env);
let third_dir = newest(&env, &seen);
assert_ne!(third_dir, first_dir);
let third = atproto_latest(&third_dir, &env.repo_did);
assert!(third["rev"].as_str() > second["rev"].as_str());
}
#[test]
fn test_a_cob_only_change_moves_the_rev() {
let env = setup_test_env();
let generate = |env: &TestEnv| {
let result = knot_static(&env.config, ["generate"]);
assert!(
result.status.success(),
"generate failed: {}",
String::from_utf8_lossy(&result.stderr)
);
};
let none = std::collections::BTreeSet::new();
generate(&env);
let first_dir = generation_dirs(&env.output)
.difference(&none)
.next()
.cloned()
.unwrap();
let first = atproto_latest(&first_dir, &env.repo_did);
let activate = |dir: &Path| {
let id = dir.file_name().unwrap().to_str().unwrap();
let result = knot_static(&env.config, ["activate", id]);
assert!(
result.status.success(),
"activate failed: {}",
String::from_utf8_lossy(&result.stderr)
);
};
activate(&first_dir);
let hosted = knot_git::Repo::open(find_hosted_repo(&env.scan, &env.tip_oid)).unwrap();
let signer = knot_runtime::K256Signer::generate(&knot_runtime::SeededEntropy::new(7));
let did = knot_types::RepoDid::new(&env.repo_did).unwrap();
let alice = knot_types::AccountDid::new("did:plc:alice").unwrap();
common::cobs::Cobs {
repo: &hosted,
did: &did,
signer: &signer,
}
.open_ticket("3lubrptx57d22", &alice, "first issue");
let seen = generation_dirs(&env.output);
generate(&env);
let added: Vec<PathBuf> = generation_dirs(&env.output)
.difference(&seen)
.cloned()
.collect();
assert_eq!(
added.len(),
1,
"the cob alone built a generation: {added:?}"
);
activate(&added[0]);
let second = atproto_latest(&added[0], &env.repo_did);
assert!(second["rev"].as_str() > first["rev"].as_str());
let described: serde_json::Value = serde_json::from_slice(
&std::fs::read(
added[0]
.join("www/_atproto")
.join(repo_token(&env.repo_did))
.join("describe.json"),
)
.unwrap(),
)
.unwrap();
assert!(
described["collections"]
.as_array()
.unwrap()
.iter()
.any(|collection| collection == "org.tangled.track.ticket")
);
let seen = generation_dirs(&env.output);
generate(&env);
assert_eq!(generation_dirs(&env.output), seen, "a no-op builds nothing");
}
#[test]
fn activate_takes_a_unique_prefix_of_a_generation_id() {
let env = setup_test_env();
assert!(knot_static(&env.config, ["generate"]).status.success());
let dir = get_only_generation_dir(&env.output);
let id = dir.file_name().unwrap().to_str().unwrap().to_string();
let unknown = knot_static(
&env.config,
["activate", if id.starts_with('0') { "1" } else { "0" }],
);
assert!(!unknown.status.success());
assert!(String::from_utf8_lossy(&unknown.stderr).contains("no generation starts with"));
for twin in ["a", "b"] {
std::fs::create_dir(
env.output
.join("generations")
.join(format!("cafe{}", twin.repeat(60))),
)
.unwrap();
}
let ambiguous = knot_static(&env.config, ["activate", "cafe"]);
let stderr = String::from_utf8_lossy(&ambiguous.stderr);
assert!(!ambiguous.status.success());
assert!(
stderr.contains("matches 2 generations") && stderr.contains("cafeaaaaaaaaaaaa"),
"{stderr}"
);
let activated = knot_static(&env.config, ["activate", &id[..12]]);
assert!(
activated.status.success(),
"{}",
String::from_utf8_lossy(&activated.stderr)
);
let active = std::fs::read_to_string(env.output.join("active.json")).unwrap();
assert!(active.contains(&id), "{active}");
}
#[test]
fn generate_writes_static_index_html_with_manifest_description() {
let env = setup_test_env();
let other_source = env._root.path().join("source-beta");
create_test_repo(&other_source, "beta");
let _ = knot_static(
&env.config,
[
"repo",
"import",
"--owner",
"did:web:alice.phase0.test",
"--rkey",
"beta",
"--name",
"beta",
"--default-branch",
"main",
other_source.to_str().unwrap(),
],
);
assert!(knot_static(&env.config, ["generate"]).status.success());
let first_dir = get_only_generation_dir(&env.output);
let first_id = first_dir.file_name().unwrap().to_str().unwrap().to_string();
assert!(
knot_static(&env.config, ["activate", &first_id])
.status
.success()
);
let first_html = std::fs::read_to_string(first_dir.join("www/index.html")).unwrap();
assert!(
first_html.contains("<h1 class=\"host\">knot<span>.</span>phase0<span>.</span>test</h1>"),
"{first_html}"
);
assert!(
first_html.contains("<link rel=\"icon\" href=\"/favicon.svg\" type=\"image/svg+xml\">"),
"{first_html}"
);
assert!(
first_html.contains("<link rel=\"icon\" href=\"/favicon.ico\" sizes=\"32x32\">"),
"{first_html}"
);
assert!(
first_html.contains("<link rel=\"apple-touch-icon\" href=\"/apple-touch-icon.png\">"),
"{first_html}"
);
assert_eq!(
std::fs::read(first_dir.join("www/favicon.svg")).unwrap(),
include_bytes!("../src/favicon/favicon.svg")
);
assert_eq!(
std::fs::read(first_dir.join("www/favicon-32.png")).unwrap(),
include_bytes!("../src/favicon/favicon-32.png")
);
assert_eq!(
std::fs::read(first_dir.join("www/apple-touch-icon.png")).unwrap(),
include_bytes!("../src/favicon/apple-touch-icon.png")
);
let og = std::fs::read(first_dir.join("www/og.png")).unwrap();
assert!(og.starts_with(b"\x89PNG\r\n\x1a\n"));
assert_eq!(&og[12..24], b"IHDR\0\0\x04\xb0\0\0\x02\x76");
assert!(
first_html.contains("<a href=\"https://tangled.org/ptr.pet/yumak\">static knot</a>"),
"{first_html}"
);
assert!(
first_html.contains(&format!(
"<meta property=\"og:title\" content=\"{KNOT_HOST}\">"
)),
"{first_html}"
);
assert!(
first_html.contains(&format!(
"<meta property=\"og:url\" content=\"https://{KNOT_HOST}/\">"
)),
"{first_html}"
);
assert!(
first_html.contains(&format!(
"<meta property=\"og:image\" content=\"https://{KNOT_HOST}/og.png\">"
)),
"{first_html}"
);
assert!(
first_html.contains("<meta property=\"og:description\" content=\"a knot for tangled\">"),
"{first_html}"
);
assert!(
first_html.contains(&format!(
"title=\"https://{KNOT_HOST}/{0}\" aria-label=\"copy git url\" data-clone=\"https://{KNOT_HOST}/{0}\"",
env.repo_did
)),
"{first_html}"
);
assert!(!first_html.contains("<div class=\"desc\">"), "{first_html}");
let alice_pos = first_html
.find("https://tangled.org/did:web:alice.phase0.test/beta")
.unwrap();
let owner_pos = first_html
.find(&format!("https://tangled.org/{OWNER}/sample"))
.unwrap();
assert!(
alice_pos < owner_pos,
"owners sorted in index.html: {first_html}"
);
let settings = knot_static(
&env.config,
[
"repo",
"settings",
"sample",
"--description",
"<script>x</script>\" onmouseover=",
],
);
assert!(
settings.status.success(),
"repo settings failed: {}",
String::from_utf8_lossy(&settings.stderr)
);
let active: serde_json::Value =
serde_json::from_slice(&std::fs::read(env.output.join("active.json")).unwrap()).unwrap();
let second_id = active["generation"].as_str().unwrap();
assert_ne!(second_id, first_id);
let second_html = std::fs::read_to_string(
env.output
.join("generations")
.join(second_id)
.join("www/index.html"),
)
.unwrap();
assert!(!second_html.contains("<script>x</script>"), "{second_html}");
assert!(
second_html.contains(
"<div class=\"desc\"><script>x</script>" onmouseover=</div>"
),
"{second_html}"
);
let tried: serde_json::Value =
serde_json::from_slice(&std::fs::read(env.output.join("owner-handles.json")).unwrap())
.unwrap();
assert!(tried["entries"].get(OWNER).is_none(), "{tried}");
assert!(tried["unverified"].get(OWNER).is_some(), "{tried}");
std::fs::write(
env.output.join("owner-handles.json"),
serde_json::to_vec_pretty(&serde_json::json!({
"version": 1,
"entries": {
OWNER: {
"handle": "ptr.pet",
"verified_at_unix": 1_700_000_000u64,
}
}
}))
.unwrap(),
)
.unwrap();
let settings = knot_static(
&env.config,
[
"repo",
"settings",
"sample",
"--description",
"cached handle",
],
);
assert!(settings.status.success());
let active: serde_json::Value =
serde_json::from_slice(&std::fs::read(env.output.join("active.json")).unwrap()).unwrap();
let third_id = active["generation"].as_str().unwrap();
let third_html = std::fs::read_to_string(
env.output
.join("generations")
.join(third_id)
.join("www/index.html"),
)
.unwrap();
assert!(
third_html.contains("https://tangled.org/ptr.pet/sample"),
"generate with cache shows handle:
{third_html}"
);
let _ = knot_static(&env.config, ["post-activate"]);
let settings = knot_static(
&env.config,
[
"repo",
"settings",
"sample",
"--description",
"after failed refresh",
],
);
assert!(settings.status.success());
let active: serde_json::Value =
serde_json::from_slice(&std::fs::read(env.output.join("active.json")).unwrap()).unwrap();
let fourth_id = active["generation"].as_str().unwrap();
let fourth_html = std::fs::read_to_string(
env.output
.join("generations")
.join(fourth_id)
.join("www/index.html"),
)
.unwrap();
assert!(
fourth_html.contains("https://tangled.org/ptr.pet/sample"),
"cached handle kept after failed refresh:
{fourth_html}"
);
}
fn active_generation(output: &Path) -> String {
let active: serde_json::Value =
serde_json::from_slice(&std::fs::read(output.join("active.json")).unwrap()).unwrap();
active["generation"].as_str().unwrap().to_string()
}
#[test]
fn a_profile_change_alone_gets_one_generation_from_post_activate() {
let env = setup_test_env();
assert!(knot_static(&env.config, ["generate"]).status.success());
let first = get_only_generation_dir(&env.output);
let first_id = first.file_name().unwrap().to_str().unwrap().to_string();
assert!(
knot_static(&env.config, ["activate", &first_id])
.status
.success()
);
let _ = knot_static(&env.config, ["post-activate"]);
assert_eq!(active_generation(&env.output), first_id, "nothing changed");
std::fs::write(
env.output.join("owner-handles.json"),
serde_json::to_vec(&serde_json::json!({
"version": 1,
"entries": {
OWNER: {
"handle": "ptr.pet",
"verified_at_unix": 1_700_000_000u64,
"profile": { "description": "hey gemini!" },
}
}
}))
.unwrap(),
)
.unwrap();
let _ = knot_static(&env.config, ["post-activate"]);
let second_id = active_generation(&env.output);
assert_ne!(
second_id, first_id,
"the profile is published without a push"
);
let social = env
.output
.join("generations")
.join(&second_id)
.join("www/_atproto/social");
let answered = walkdir(&social).into_iter().any(|file| {
std::fs::read_to_string(file)
.is_ok_and(|body| body.contains(r#""description":"hey gemini!""#))
});
assert!(answered, "getProfile answers the cached profile");
let before = generation_dirs(&env.output);
let _ = knot_static(&env.config, ["post-activate"]);
assert_eq!(active_generation(&env.output), second_id);
assert_eq!(
generation_dirs(&env.output),
before,
"a second sync builds nothing"
);
}
#[test]
fn sizes_made_after_a_generation_are_served_by_the_next_sync() {
let env = setup_test_env();
assert!(knot_static(&env.config, ["generate"]).status.success());
let first = get_only_generation_dir(&env.output);
let first_id = first.file_name().unwrap().to_str().unwrap().to_string();
assert!(
knot_static(&env.config, ["activate", &first_id])
.status
.success()
);
let cid = "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku";
let avatars = env.output.join("avatars");
std::fs::create_dir_all(&avatars).unwrap();
std::fs::write(avatars.join(format!("{cid}.png")), b"\x89PNG an avatar").unwrap();
std::fs::write(
env.output.join("owner-handles.json"),
serde_json::to_vec(&serde_json::json!({
"version": 1,
"entries": {
OWNER: {
"handle": "ptr.pet",
"verified_at_unix": 1_700_000_000u64,
"avatar_cid": cid,
"avatar_ext": "png",
}
}
}))
.unwrap(),
)
.unwrap();
let served_at = |size: u32| {
let maps = env
.output
.join("generations")
.join(active_generation(&env.output))
.join("nginx/maps/63_social.conf");
let maps = std::fs::read_to_string(maps).unwrap();
let row = format!("/avatar/{OWNER}|{size}");
maps.lines()
.find_map(|line| {
let mut fields = line.split_whitespace();
(fields.next()? == format!("\"{row}\"") || line.trim_start().starts_with(&row))
.then(|| {
line.split_whitespace()
.last()
.unwrap()
.trim_end_matches(';')
.trim_matches('"')
.to_string()
})
})
.unwrap_or_else(|| panic!("no {row} in\n{maps}"))
};
let _ = knot_static(&env.config, ["post-activate"]);
assert_eq!(served_at(42), format!("{cid}.png"), "no sizes yet");
for size in [42, 96, 192] {
std::fs::write(avatars.join(format!("{cid}-{size}.webp")), b"RIFF").unwrap();
}
let before = active_generation(&env.output);
let _ = knot_static(&env.config, ["post-activate"]);
assert_ne!(
active_generation(&env.output),
before,
"the sizes are published"
);
assert_eq!(served_at(42), format!("{cid}-42.webp"));
assert_eq!(served_at(96), format!("{cid}-96.webp"));
}
#[test]
fn a_write_waiting_to_activate_keeps_its_candidate_through_another_writes_gc() {
let env = setup_test_env();
let mut config = std::fs::read_to_string(&env.config).unwrap();
config.push_str("keep_generations = 1\n");
std::fs::write(&env.config, config).unwrap();
let beta = env._root.path().join("source-beta");
create_test_repo(&beta, "beta");
let _ = knot_static(
&env.config,
[
"repo",
"import",
"--owner",
OWNER,
"--rkey",
"beta",
"--name",
"beta",
"--default-branch",
"main",
beta.to_str().unwrap(),
],
);
let describe = |repo: &str, text: &str| {
knot_static(
&env.config,
["repo", "settings", repo, "--description", text],
)
};
for text in ["one", "two"] {
assert!(describe("sample", text).status.success());
}
let handshake = env._root.path().join("paused");
let first = common::knot_static_cmd(
&env.config,
MASTER_KEY_ENV,
&MASTER_KEY,
["repo", "settings", "sample", "--description", "first"],
)
.env("KNOT_FAILPOINT", "before_activation")
.env("KNOT_FAILPOINT_FILE", &handshake)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
while !handshake.exists() {
assert!(
std::time::Instant::now() < deadline,
"the first write never built"
);
std::thread::sleep(std::time::Duration::from_millis(20));
}
// the gc keeps builds made in the second of the current activation
std::thread::sleep(std::time::Duration::from_millis(1100));
let second = describe("beta", "second");
assert!(
second.status.success(),
"{}",
String::from_utf8_lossy(&second.stderr)
);
std::fs::remove_file(&handshake).unwrap();
let first = first.wait_with_output().unwrap();
assert!(
first.status.success(),
"{}",
String::from_utf8_lossy(&first.stderr)
);
let index = std::fs::read_to_string(
env.output
.join("generations")
.join(active_generation(&env.output))
.join("www/index.html"),
)
.unwrap();
assert!(
index.contains("first") && index.contains("second"),
"{index}"
);
}
#[test]
fn a_generation_another_binary_built_is_built_again_once() {
let env = setup_test_env();
let unchanged = || {
let generated = knot_static(&env.config, ["generate"]);
assert!(generated.status.success());
String::from_utf8_lossy(&generated.stderr).contains("unchanged, nothing to do")
};
assert!(!unchanged());
let first = get_only_generation_dir(&env.output);
let first_id = first.file_name().unwrap().to_str().unwrap().to_string();
assert!(
knot_static(&env.config, ["activate", &first_id])
.status
.success()
);
assert!(unchanged(), "the same binary takes the shortcut");
let manifest = first.join("manifest.json");
let rewrite = |value: &serde_json::Value| {
let mut permissions = std::fs::metadata(&manifest).unwrap().permissions();
permissions.set_readonly(false);
std::fs::set_permissions(&manifest, permissions).unwrap();
std::fs::write(&manifest, serde_json::to_vec(value).unwrap()).unwrap();
};
let mut value: serde_json::Value =
serde_json::from_slice(&std::fs::read(&manifest).unwrap()).unwrap();
let built_by = value["built_by"].take();
assert!(
built_by.as_str().is_some_and(|by| !by.is_empty()),
"{value}"
);
value["built_by"] = "an older binary".into();
rewrite(&value);
assert!(
!unchanged(),
"a generation from another binary is built again"
);
value["built_by"] = built_by;
rewrite(&value);
assert!(unchanged(), "and only once");
}
#[test]
fn a_handles_publish_that_failed_waits_for_a_change_before_it_rebuilds_again() {
let env = setup_test_env();
assert!(knot_static(&env.config, ["generate"]).status.success());
let first = get_only_generation_dir(&env.output);
let first_id = first.file_name().unwrap().to_str().unwrap().to_string();
assert!(
knot_static(&env.config, ["activate", &first_id])
.status
.success()
);
let config = std::fs::read_to_string(&env.config).unwrap();
std::fs::write(
&env.config,
config.replace(
"nginx_binary = \"true\"\n",
"nginx_binary = \"true\"\nreload_command = \"exit 1\"\n",
),
)
.unwrap();
let profile = |description: &str| {
std::fs::write(
env.output.join("owner-handles.json"),
serde_json::to_vec(&serde_json::json!({
"version": 1,
"entries": {
OWNER: {
"handle": "ptr.pet",
"verified_at_unix": 1_700_000_000u64,
"profile": { "description": description },
}
}
}))
.unwrap(),
)
.unwrap();
};
let tried = || {
let drained = knot_static(&env.config, ["post-activate"]);
String::from_utf8_lossy(&drained.stderr).contains("publish the refreshed handles")
};
profile("one");
assert!(tried(), "the first drain tries and its activation fails");
assert!(!tried(), "the next drain doesn't rebuild the same thing");
profile("two");
assert!(tried(), "a different store tries again");
assert_eq!(active_generation(&env.output), first_id);
}
#[test]
fn nginx_checks_the_git_read_maps_only_when_they_changed() {
let env = setup_test_env();
let tested = env.output.join("nginx-tested");
let nginx = env.output.join("nginx-recorder");
std::fs::create_dir_all(&env.output).unwrap();
std::fs::write(
&nginx,
format!("#!/bin/sh\necho \"$3\" >> {}\n", tested.display()),
)
.unwrap();
std::fs::set_permissions(&nginx, std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap();
let config = std::fs::read_to_string(&env.config).unwrap();
std::fs::write(
&env.config,
config.replace(
"nginx_binary = \"true\"",
&format!("nginx_binary = \"{}\"", nginx.display()),
),
)
.unwrap();
let last_tested = || {
let log = std::fs::read_to_string(&tested).unwrap();
log.lines().last().unwrap().to_string()
};
assert!(knot_static(&env.config, ["generate"]).status.success());
let first = get_only_generation_dir(&env.output);
let first_id = first.file_name().unwrap().to_str().unwrap().to_string();
assert!(
knot_static(&env.config, ["activate", &first_id])
.status
.success()
);
assert!(last_tested().ends_with("/active.conf"), "{}", last_tested());
std::fs::write(
env.output.join("owner-handles.json"),
serde_json::to_vec(&serde_json::json!({
"version": 1,
"entries": {
OWNER: {
"handle": "ptr.pet",
"verified_at_unix": 1_700_000_000u64,
"profile": { "description": "checked" },
}
}
}))
.unwrap(),
)
.unwrap();
let _ = knot_static(&env.config, ["post-activate"]);
assert_ne!(active_generation(&env.output), first_id);
assert!(
last_tested().ends_with("/runtime/nginx-check.conf"),
"the same git read maps aren't tested again: {}",
last_tested()
);
assert!(
knot_static(&env.config, ["activate", &first_id, "--rollback"])
.status
.success()
);
assert!(
last_tested().ends_with("/active.conf"),
"a rollback tests it all"
);
}
#[test]
fn a_superseded_activation_leaves_gc_to_the_newer_one() {
let env = setup_test_env();
assert!(knot_static(&env.config, ["generate"]).status.success());
let first = get_only_generation_dir(&env.output);
let first_id = first.file_name().unwrap().to_str().unwrap().to_string();
assert!(
knot_static(&env.config, ["activate", &first_id])
.status
.success()
);
let abandoned = env.output.join("generations").join(".building-abandoned");
std::fs::create_dir_all(&abandoned).unwrap();
let older = "0".repeat(64);
let after = |generation: &str| {
let ran = knot_static(&env.config, ["after-activation", generation, "--at", "1"]);
assert!(
ran.status.success(),
"{}",
String::from_utf8_lossy(&ran.stderr)
);
};
after(&older);
assert!(
abandoned.exists(),
"a superseded activation collects nothing"
);
after(&first_id);
assert!(!abandoned.exists(), "the current one collects");
}
#[test]
fn a_write_finding_a_gc_queued_leaves_the_collecting_to_it() {
let env = setup_test_env();
assert!(knot_static(&env.config, ["generate"]).status.success());
let first = get_only_generation_dir(&env.output);
let first_id = first.file_name().unwrap().to_str().unwrap().to_string();
assert!(
knot_static(&env.config, ["activate", &first_id])
.status
.success()
);
let abandoned = env.output.join("generations").join(".building-abandoned");
std::fs::create_dir_all(&abandoned).unwrap();
let after = || {
let ran = knot_static(&env.config, ["after-activation", &first_id, "--at", "1"]);
assert!(
ran.status.success(),
"{}",
String::from_utf8_lossy(&ran.stderr)
);
};
std::fs::create_dir_all(env.output.join("runtime")).unwrap();
let queued = std::fs::File::create(env.output.join("runtime/gc.queued")).unwrap();
queued.lock().unwrap();
after();
assert!(abandoned.exists(), "the queued gc collects, not this one");
drop(queued);
after();
assert!(!abandoned.exists());
}
#[test]
fn a_generation_links_the_files_it_shares_with_the_active_one() {
use std::os::unix::fs::MetadataExt as _;
let env = setup_test_env();
assert!(knot_static(&env.config, ["generate"]).status.success());
let first = get_only_generation_dir(&env.output);
let first_id = first.file_name().unwrap().to_str().unwrap().to_string();
assert!(
knot_static(&env.config, ["activate", &first_id])
.status
.success()
);
std::fs::write(
env.output.join("owner-handles.json"),
serde_json::to_vec(&serde_json::json!({
"version": 1,
"entries": {
OWNER: {
"handle": "ptr.pet",
"verified_at_unix": 1_700_000_000u64,
"profile": { "description": "shared" },
}
}
}))
.unwrap(),
)
.unwrap();
let _ = knot_static(&env.config, ["post-activate"]);
let second = env
.output
.join("generations")
.join(active_generation(&env.output));
assert_ne!(second, first);
let (mut shared, mut changed) = (0, 0);
for file in walkdir(&second) {
let meta = std::fs::symlink_metadata(&file).unwrap();
let theirs = first.join(file.strip_prefix(&second).unwrap());
if !meta.is_file() || !theirs.is_file() {
continue;
}
let same = std::fs::read(&file).unwrap() == std::fs::read(&theirs).unwrap();
let linked = meta.ino() == std::fs::metadata(&theirs).unwrap().ino();
assert_eq!(same, linked, "{}", file.display());
if linked {
shared += 1;
} else {
changed += 1;
}
}
assert!(
shared > 0 && changed > 0,
"{shared} shared, {changed} changed"
);
}
#[test]
fn index_fonts_in_the_config_build_a_page_that_loads_them() {
let env = setup_test_env();
assert!(knot_static(&env.config, ["generate"]).status.success());
let plain_dir = get_only_generation_dir(&env.output);
let plain = std::fs::read_to_string(plain_dir.join("www/index.html")).unwrap();
assert!(!plain.contains("@font-face") && !plain.contains("rel=\"preload\""));
let mut config = std::fs::read_to_string(&env.config).unwrap();
config.push_str(
"\n[index.fonts]\nsans = \"Outfit\"\n\
[[index.fonts.faces]]\nfamily = \"Outfit\"\nurl = \"/fonts/outfit-400.woff2\"\n\
[[index.fonts.faces]]\nfamily = \"Outfit\"\nweight = 700\nurl = \"/fonts/outfit-700.woff2\"\n",
);
std::fs::write(&env.config, config).unwrap();
let seen = generation_dirs(&env.output);
assert!(knot_static(&env.config, ["generate"]).status.success());
let fonts_dir = generation_dirs(&env.output)
.into_iter()
.find(|dir| !seen.contains(dir))
.expect("the fonts are a new generation");
let html = std::fs::read_to_string(fonts_dir.join("www/index.html")).unwrap();
for face in ["/fonts/outfit-400.woff2", "/fonts/outfit-700.woff2"] {
assert!(
html.contains(&format!(
"<link rel=\"preload\" href=\"{face}\" as=\"font\" type=\"font/woff2\" crossorigin>"
)),
"{html}"
);
}
assert_eq!(html.matches("@font-face").count(), 2, "{html}");
assert!(
html.contains("body{font-family:\"Outfit\",ui-sans-serif"),
"{html}"
);
}