argand-site-registry/crates/argand-site-registry/tests/failures.rs

664 lines
23 KiB
Rust

// By Nic Weyand!
//! Source poisoning, repeatability and metadata regression cases.
#[allow(dead_code)] // Same source-shaped helpers as the native lifecycle suite.
mod common;
use argand_site_registry::{
download::CachedSource,
model::{Compression, Format, Source},
query::Registry,
store::{self, ImportLimits},
};
use serde_json::json;
use std::{fs, io::Write, process::Command};
#[test]
fn version_one_store_migrates_without_losing_review_history() -> anyhow::Result<()> {
let root = tempfile::tempdir()?;
let path = root.path().join("v1.sqlite");
let db = rusqlite::Connection::open(&path)?;
db.execute_batch(include_str!("../migrations/001.sql"))?;
db.execute("INSERT INTO reviews VALUES(1,?1,'revoke','legacy','reason','evidence','2026-01-01T00:00:00Z','2026-01-01T00:00:00Z','unspecified','','')", ["0".repeat(64)])?;
drop(db);
let migrated = store::open(&path)?;
assert_eq!(
migrated.query_row("PRAGMA user_version", [], |row| row.get::<_, i64>(0))?,
5
);
assert_eq!(
migrated.query_row("SELECT rules FROM registry_metadata", [], |row| row
.get::<_, String>(0))?,
store::RULE_VERSION
);
assert_eq!(
migrated.query_row("SELECT count(*) FROM reviews", [], |row| row
.get::<_, i64>(0))?,
1
);
assert_eq!(
migrated.query_row("SELECT count(*) FROM review_auth", [], |row| row
.get::<_, i64>(0))?,
0
);
Ok(())
}
#[test]
fn every_prior_writer_schema_migrates_to_v04() -> anyhow::Result<()> {
let root = tempfile::tempdir()?;
for version in 1..=4 {
let path = root.path().join(format!("v{version}.sqlite"));
let db = rusqlite::Connection::open(&path)?;
db.execute_batch(include_str!("../migrations/001.sql"))?;
if version >= 2 {
db.execute_batch(include_str!("../migrations/002.sql"))?;
}
if version >= 3 {
db.execute_batch(include_str!("../migrations/003.sql"))?;
}
if version >= 4 {
db.execute_batch(include_str!("../migrations/004.sql"))?;
}
drop(db);
let migrated = store::open(&path)?;
assert_eq!(
migrated.query_row("PRAGMA user_version", [], |row| row.get::<_, i64>(0))?,
5
);
for table in ["votes", "vote_auth", "observation_batches", "observations"] {
let exists: bool = migrated.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type='table' AND name=?1)",
[table],
|row| row.get(0),
)?;
assert!(exists, "{table} missing after schema {version} migration");
}
}
Ok(())
}
#[test]
fn externally_signed_unauthenticated_reviews_are_rejected() -> anyhow::Result<()> {
let root = tempfile::tempdir()?;
let db = common::fixture(root.path())?;
let initial = common::build(&db, root.path(), "initial")?;
common::approve(&db, &initial, "FB", "https://facebook.com/", "primary", "")?;
let generation = common::build(&db, root.path(), "generation")?;
let key = root.path().join("key");
assert!(
Command::new("ssh-keygen")
.args(["-q", "-t", "ed25519", "-N", "", "-f"])
.arg(&key)
.status()?
.success()
);
fs::write(
root.path().join("allowed"),
format!("fixture {}", fs::read_to_string(key.with_extension("pub"))?),
)?;
assert!(
argand_site_registry::release::sign(
&root.path().join("generation"),
&key,
&generation.identity,
&root.path().join("allowed")
)
.is_err()
);
assert!(
Command::new("ssh-keygen")
.args(["-Y", "sign", "-n", "argand-site-registry", "-f"])
.arg(&key)
.arg(root.path().join("generation/COMPLETE.json"))
.status()?
.success()
);
assert!(
argand_site_registry::release::verify_signed(
&root.path().join("generation"),
&root.path().join("allowed"),
"fixture",
&root.path().join("allowed")
)
.is_err()
);
Ok(())
}
#[test]
fn unsigned_sqlite_sidecars_and_generation_symlinks_are_rejected() -> anyhow::Result<()> {
let root = tempfile::tempdir()?;
let db = common::fixture(root.path())?;
let generation = common::build(&db, root.path(), "sealed")?;
let scratch = root.path().join("scratch.sqlite");
fs::copy(root.path().join("sealed/registry.sqlite"), &scratch)?;
let attacker = rusqlite::Connection::open(&scratch)?;
attacker.execute_batch("PRAGMA journal_mode=WAL; PRAGMA wal_autocheckpoint=0;")?;
attacker.execute(
"UPDATE properties SET url='https://unsigned.example.org/' WHERE url='https://facebook.com/'",
[],
)?;
fs::copy(
scratch.with_extension("sqlite-wal"),
root.path().join("sealed/registry.sqlite-wal"),
)?;
let error = Registry::open(&root.path().join("sealed"), &generation.identity)
.err()
.ok_or_else(|| anyhow::anyhow!("unsigned sidecar was accepted"))?;
assert!(error.to_string().contains("unexpected generation entry"));
drop(attacker);
#[cfg(target_os = "linux")]
{
use std::os::unix::fs::symlink;
let link = root.path().join("generation-link");
symlink(root.path().join("sealed"), &link)?;
assert!(Registry::open(&link, &generation.identity).is_err());
}
Ok(())
}
#[test]
fn opened_registry_uses_private_authenticated_database_bytes() -> anyhow::Result<()> {
let root = tempfile::tempdir()?;
let db = common::fixture(root.path())?;
let registry = common::build(&db, root.path(), "private-snapshot")?;
let mutable = rusqlite::Connection::open(root.path().join("private-snapshot/registry.sqlite"))?;
mutable.execute(
"UPDATE properties SET url='https://changed.invalid/' WHERE url='https://facebook.com/'",
[],
)?;
assert_eq!(
registry.lookup("FB", 1)?.candidates[0].url,
"https://facebook.com/"
);
Ok(())
}
#[test]
fn import_limits_rollback_all_partial_source_rows() -> anyhow::Result<()> {
let root = tempfile::tempdir()?;
let mut db = store::open(&root.path().join("limited.sqlite"))?;
let compressed = common::gzip(common::CRUX.as_bytes())?;
let input = root.path().join("limited.csv.gz");
fs::write(&input, &compressed)?;
let mut manifest = common::manifest(Source::Crux, Format::CruxCsv, &compressed)?;
manifest.compression = Compression::Gzip;
assert!(
store::import_with_limits(
&mut db,
&manifest,
&input,
ImportLimits {
maximum_expanded_bytes: 16,
maximum_records: 100,
maximum_database_growth_bytes: 1024 * 1024,
},
)
.is_err()
);
assert_eq!(
db.query_row("SELECT count(*) FROM sources", [], |row| row
.get::<_, i64>(0))?,
0
);
Ok(())
}
#[test]
fn retired_reviewer_keys_verify_history_only_with_a_validity_epoch() -> anyhow::Result<()> {
let root = tempfile::tempdir()?;
let db = common::fixture(root.path())?;
let generation = common::build(&db, root.path(), "reviewer-epoch")?;
let fingerprint = generation.lookup("FB", 1)?.candidates[0]
.fingerprint
.clone();
let key = root.path().join("reviewer");
assert!(
Command::new("ssh-keygen")
.args(["-q", "-t", "ed25519", "-N", "", "-f"])
.arg(&key)
.status()?
.success()
);
let decision = root.path().join("decision.json");
fs::write(
&decision,
serde_json::to_vec(&json!({
"fingerprint":fingerprint,"decision":"revoke","reviewer":"retired",
"reason":"synthetic key epoch test","evidence":"synthetic:key-epoch",
"reviewed_at":"2026-09-12T12:00:00Z","expires_at":"2026-09-12T12:00:00Z",
"role":"unspecified","locale":"","country":""
}))?,
)?;
assert!(
Command::new("ssh-keygen")
.args(["-Y", "sign", "-n", "argand-site-registry-review", "-f"])
.arg(&key)
.arg(&decision)
.status()?
.success()
);
let public = fs::read_to_string(key.with_extension("pub"))?;
let allowed = root.path().join("allowed");
fs::write(&allowed, format!("retired {public}"))?;
let (review, authentication) = argand_site_registry::review::authenticate(
&decision,
&decision.with_extension("json.sig"),
&allowed,
"retired",
)?;
argand_site_registry::review::record_authenticated(&db, &generation, &review, &authentication)?;
fs::write(
&allowed,
format!("retired valid-before=\"20260913000000Z\" {public}"),
)?;
assert!(argand_site_registry::review::verify_all(&db, &allowed).is_ok());
fs::write(
&allowed,
format!("retired valid-before=\"20260912000000Z\" {public}"),
)?;
assert!(argand_site_registry::review::verify_all(&db, &allowed).is_err());
Ok(())
}
#[test]
fn activation_accepts_a_pinned_v1_previous_generation() -> anyhow::Result<()> {
let root = tempfile::tempdir()?;
let db = common::fixture(root.path())?;
let old = common::build(&db, root.path(), "legacy-current")?;
drop(old);
let receipt_path = root.path().join("legacy-current/COMPLETE.json");
let mut receipt: serde_json::Value = argand_site_registry::read_json(&receipt_path)?;
receipt["schema"] = json!("argand.site-registry/v1");
receipt["rules"] = json!("argand.site-rules/v1");
fs::write(&receipt_path, serde_json::to_vec_pretty(&receipt)?)?;
let old_pin = argand_site_registry::file_digest(&receipt_path)?;
let current = root.path().join("current.json");
fs::write(
&current,
serde_json::to_vec_pretty(&json!({
"schema":"argand.site-current/v1",
"generation":root.path().join("legacy-current").canonicalize()?,
"receipt_sha256":old_pin,
"signer":"legacy-publisher",
"revocation_sequence":0
}))?,
)?;
let new = common::build(&db, root.path(), "current-rules")?;
let key = root.path().join("publisher");
assert!(
Command::new("ssh-keygen")
.args(["-q", "-t", "ed25519", "-N", "", "-f"])
.arg(&key)
.status()?
.success()
);
let allowed = root.path().join("allowed-publisher");
fs::write(
&allowed,
format!(
"publisher {}",
fs::read_to_string(key.with_extension("pub"))?
),
)?;
argand_site_registry::release::sign(
&root.path().join("current-rules"),
&key,
&new.identity,
&allowed,
)?;
argand_site_registry::release::activate(
&root.path().join("current-rules"),
&current,
&allowed,
"publisher",
&allowed,
)?;
let activated: serde_json::Value = argand_site_registry::read_json(&current)?;
assert_eq!(activated["receipt_sha256"], new.identity);
Ok(())
}
#[test]
fn removed_entity_websites_retire_the_previous_selection() -> anyhow::Result<()> {
let root = tempfile::tempdir()?;
let mut db = common::fixture(root.path())?;
let before = common::build(&db, root.path(), "before-retirement")?;
common::approve(&db, &before, "FB", "https://facebook.com/", "primary", "")?;
let bytes = br#"{"entities":{"Q355":{"id":"Q355","missing":""}}}"#;
let mut source = common::manifest(Source::Wikidata, Format::WikidataEntities, bytes)?;
source.retrieved_at += chrono::Duration::days(1);
let input = root.path().join("retirement.json");
fs::write(&input, bytes)?;
store::import(&mut db, &source, &input)?;
let retired = common::build(&db, root.path(), "retired")?;
assert_eq!(retired.lookup("FB", 1)?.total_entities, 0);
assert!(
retired
.resolve("FB", None, None, common::timestamp()?)?
.is_none()
);
assert_eq!(before.lookup("FB", 1)?.total_entities, 1);
Ok(())
}
#[test]
fn import_order_does_not_change_generation_and_psl_refresh_preserves_review() -> anyhow::Result<()>
{
let root = tempfile::tempdir()?;
let a = common::fixture(root.path())?;
let mut b = store::open(&root.path().join("other.sqlite"))?;
for (source, format, bytes) in [
(Source::Curlie, Format::CurlieTarGz, common::curlie()?),
(
Source::Crux,
Format::CruxCsv,
common::CRUX.as_bytes().to_vec(),
),
(
Source::Majestic,
Format::MajesticCsv,
common::MAJESTIC.as_bytes().to_vec(),
),
(
Source::Wikidata,
Format::WikidataEntities,
serde_json::to_vec(&common::wikidata())?,
),
(
Source::Psl,
Format::PslText,
common::PSL.as_bytes().to_vec(),
),
] {
common::import(&mut b, root.path(), source, format, &bytes)?;
}
let first = common::build(&a, root.path(), "a")?;
let second = common::build(&b, root.path(), "b")?;
assert_eq!(first.identity, second.identity);
common::approve(&b, &second, "FB", "https://facebook.com/", "primary", "")?;
let input = root.path().join("psl");
let comment_only = format!("{}// Comment-only refresh\n", common::PSL);
fs::write(&input, &comment_only)?;
let mut manifest = common::manifest(Source::Psl, Format::PslText, comment_only.as_bytes())?;
manifest.retrieved_at += chrono::Duration::days(1);
store::import(&mut b, &manifest, &input)?;
let refreshed = common::build(&b, root.path(), "refresh")?;
assert!(
refreshed
.resolve("FB", None, None, common::timestamp()?)?
.is_some()
);
assert_eq!(
second.lookup("FB", 1)?.candidates[0].fingerprint,
refreshed.lookup("FB", 1)?.candidates[0].fingerprint
);
Ok(())
}
#[test]
fn psl_semantic_change_invalidates_affected_review_only() -> anyhow::Result<()> {
let root = tempfile::tempdir()?;
let mut db = common::fixture(root.path())?;
let original = common::build(&db, root.path(), "original")?;
common::approve(
&db,
&original,
"Atlas",
"https://atlas.example.co.uk/",
"regional",
"GB",
)?;
let original_fingerprint = original
.lookup("Atlas", 10)?
.candidates
.into_iter()
.find(|candidate| candidate.url == "https://atlas.example.co.uk/")
.ok_or_else(|| anyhow::anyhow!("fixture regional property missing"))?
.fingerprint;
let changed = common::PSL.replace("co.uk\n", "");
let input = root.path().join("changed-psl");
fs::write(&input, &changed)?;
let mut manifest = common::manifest(Source::Psl, Format::PslText, changed.as_bytes())?;
manifest.retrieved_at += chrono::Duration::days(1);
store::import(&mut db, &manifest, &input)?;
let rebuilt = common::build(&db, root.path(), "changed")?;
let changed_candidate = rebuilt
.lookup("Atlas", 10)?
.candidates
.into_iter()
.find(|candidate| candidate.url == "https://atlas.example.co.uk/")
.ok_or_else(|| anyhow::anyhow!("changed property missing"))?;
assert_ne!(original_fingerprint, changed_candidate.fingerprint);
assert_eq!(
changed_candidate.web_property["domain"]["registrable_domain"],
"co.uk"
);
assert!(
rebuilt
.resolve("Atlas", None, Some("GB"), common::timestamp()?)?
.is_none()
);
Ok(())
}
#[test]
fn typed_diff_reports_review_only_changes() -> anyhow::Result<()> {
let root = tempfile::tempdir()?;
let db = common::fixture(root.path())?;
let before = common::build(&db, root.path(), "before")?;
common::approve(&db, &before, "FB", "https://facebook.com/", "primary", "")?;
let after = common::build(&db, root.path(), "after")?;
let mut output = Vec::new();
argand_site_registry::release::diff(&before, &after, &mut output)?;
let rows = String::from_utf8(output)?
.lines()
.map(serde_json::from_str::<serde_json::Value>)
.collect::<Result<Vec<_>, _>>()?;
assert!(rows.iter().any(|row| {
row["type"] == "change" && row["subject"] == "review" && row["change"] == "added"
}));
assert_eq!(rows[0]["descriptions_included"], false);
assert!(rows[0]["attribution"]["curlie"].is_object());
assert_eq!(rows.last().and_then(|row| row["changes"].as_u64()), Some(1));
Ok(())
}
#[test]
fn malformed_archives_and_incomplete_refresh_never_replace_sources() -> anyhow::Result<()> {
let root = tempfile::tempdir()?;
let mut db = common::fixture(root.path())?;
let mut archive = tar::Builder::new(flate2::write::GzEncoder::new(
Vec::new(),
flate2::Compression::default(),
));
let mut header = tar::Header::new_gnu();
header.set_entry_type(tar::EntryType::Symlink);
header.set_size(0);
header.set_mode(0o777);
archive.append_link(&mut header, "curlie-rdf/evil-c.tsv", "/etc/passwd")?;
let link = archive.into_inner()?.finish()?;
let mut truncated = common::curlie()?;
truncated.truncate(truncated.len() - 5);
for bytes in [&link, &truncated] {
assert!(
common::import(
&mut db,
root.path(),
Source::Curlie,
Format::CurlieTarGz,
bytes
)
.is_err()
);
}
let registry = common::build(&db, root.path(), "complete")?;
assert_eq!(registry.receipt.sources.len(), 5);
assert_eq!(
registry
.lookup("Facebook directory listing", 1)?
.total_entities,
1
);
fs::write(root.path().join("complete/ATTRIBUTION.json"), b"{}")?;
assert!(Registry::open(&root.path().join("complete"), &registry.identity).is_err());
Ok(())
}
#[test]
fn temporal_deprecated_and_nonvalue_assertions_keep_evidence_without_admission()
-> anyhow::Result<()> {
let root = tempfile::tempdir()?;
let mut db = store::open(&root.path().join("data.sqlite"))?;
common::import(
&mut db,
root.path(),
Source::Psl,
Format::PslText,
common::PSL.as_bytes(),
)?;
let mut entity = common::entity(
"Q100",
"Historical",
&[],
&[
"https://old.example.com",
"https://ancient.example.com",
"https://unknown.example.com",
],
);
entity["claims"]["P856"][0]["qualifiers"] =
json!({"P582":[{"datavalue":{"value":{"time":"+2001-01-01T00:00:00Z"}}}]});
entity["claims"]["P856"][1]["rank"] = json!("deprecated");
entity["claims"]["P856"][2]["mainsnak"] = json!({"property":"P856","snaktype":"novalue"});
let bytes = serde_json::to_vec(&json!({"entities":{"Q100":entity}}))?;
common::import(
&mut db,
root.path(),
Source::Wikidata,
Format::WikidataEntities,
&bytes,
)?;
let registry = common::build(&db, root.path(), "generation")?;
assert_eq!(registry.receipt.rejected, 1);
assert!(
registry
.lookup("Historical", 100)?
.candidates
.iter()
.all(|c| !c.eligible)
);
assert!(
common::approve(
&db,
&registry,
"Historical",
"https://old.example.com/",
"primary",
""
)
.is_err()
);
Ok(())
}
#[test]
fn bzip2_multistream_and_property_scope_metadata() -> anyhow::Result<()> {
let root = tempfile::tempdir()?;
let mut db = store::open(&root.path().join("data.sqlite"))?;
let data = serde_json::to_vec(&common::wikidata())?;
let mut bytes = Vec::new();
for half in data.chunks(data.len().div_ceil(2)) {
let mut bz = bzip2::write::BzEncoder::new(Vec::new(), bzip2::Compression::fast());
bz.write_all(half)?;
bytes.extend(bz.finish()?);
}
let input = root.path().join("entities.bz2");
fs::write(&input, &bytes)?;
let mut manifest = common::manifest(Source::Wikidata, Format::WikidataEntities, &bytes)?;
manifest.compression = Compression::Bzip2;
store::import(&mut db, &manifest, &input)?;
common::import(
&mut db,
root.path(),
Source::Psl,
Format::PslText,
common::PSL.as_bytes(),
)?;
let registry = common::build(&db, root.path(), "generation")?;
let candidates = registry.lookup("Atlas", 10)?.candidates;
let regional = candidates
.iter()
.find(|c| c.url.contains("co.uk"))
.ok_or_else(|| anyhow::anyhow!("regional missing"))?;
assert_eq!(regional.property_scopes[0].jurisdiction_entities, ["Q145"]);
assert_eq!(regional.property_scopes[0].language_entities, ["Q1860"]);
assert_eq!(regional.property_scopes[0].country, None);
assert!(
regional
.entity
.names
.iter()
.any(|n| n["value"]["text"] == "Atlas" && n["source"]["license"] == "CC0-1.0")
);
Ok(())
}
#[tokio::test]
async fn repeated_update_reuses_generation_and_failure_preserves_it() -> anyhow::Result<()> {
let root = tempfile::tempdir()?;
let mut inputs = Vec::new();
for (source, format, bytes) in [
(
Source::Psl,
Format::PslText,
common::PSL.as_bytes().to_vec(),
),
(
Source::Wikidata,
Format::WikidataEntities,
serde_json::to_vec(&common::wikidata())?,
),
] {
let input = root.path().join(source.key());
let manifest = input.with_extension("json");
fs::write(&input, &bytes)?;
fs::write(
&manifest,
serde_json::to_vec(&common::manifest(source, format, &bytes)?)?,
)?;
inputs.push(CachedSource { input, manifest });
}
let review_policy = root.path().join("policy.json");
fs::write(
&review_policy,
serde_json::to_vec(&argand_site_registry::policy::ReviewPolicy::legacy_compatible())?,
)?;
let config = argand_site_registry::update::Config {
cache: root.path().join("cache"),
database: root.path().join("data.sqlite"),
generations: root.path().join("generations"),
downloads: vec![],
inputs,
crux: vec![],
review_policy: Some(review_policy),
reviewer_trust: None,
auto_supersede_typed_snapshots: false,
};
let first = argand_site_registry::update::run(&config).await?;
let second = argand_site_registry::update::run(&config).await?;
assert_eq!(first, second);
let pin = argand_site_registry::file_digest(&first.join("COMPLETE.json"))?;
fs::write(&config.inputs[1].input, "truncated")?;
assert!(argand_site_registry::update::run(&config).await.is_err());
assert_eq!(
Registry::open(&first, &pin)?
.lookup("FB", 1)?
.total_entities,
1
);
Ok(())
}