telemetry-aggregation §6.1's rule that `rejected` counts no report the aggregator aggregated had no test, and impression_aggregate_verify.py did not apply it. The flight flow now sends the helper a report it committed, and the Python flow filters as the Rust does. impression-aggregates §7.3 says what a reader does with a statement of version 1 beside one of version 2, and the corpus adds a receipt and a table of each. The ledger's corpus takes these receipts, so it is regenerated by the commit after this one. Co-Authored-By: Claude Opus 5.5 <[email protected]>
132 lines
8.2 KiB
Rust
132 lines
8.2 KiB
Rust
//! `wai.delivery.impressions` (extensions/impression-aggregates.md): the committed corpus
|
|
//! `live-conformance/impressions/` regenerates byte for byte from its fixed seeds, holds no other
|
|
//! file, every case in it gets the outcome it states, §10 lists exactly the reasons the reference
|
|
//! reports, each standing receipt's result is its contributions' tally and stitched presentation's
|
|
//! own count of the flight's claims (§8.11), and the size bounds of §5.4 and §6.4 decide a
|
|
//! file's outcome at their edge.
|
|
#![cfg(feature = "impression_aggregate")]
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::path::PathBuf;
|
|
use wai::impression_aggregate::{
|
|
accept, corpus, encode_hpke_list, TaskFile, UploadFile, CATALOGUE_MALFORMED, CODES, MAX_TASK_FILE, MAX_UPLOAD, TASK_FILE_MALFORMED, UPLOAD_MALFORMED,
|
|
};
|
|
use wai::telemetry_aggregate::dap::{HpkeCiphertext, HpkeConfig, Report, ReportMetadata};
|
|
|
|
fn wai_dir() -> PathBuf {
|
|
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
|
|
}
|
|
|
|
fn committed() -> BTreeMap<String, String> {
|
|
let dir = wai_dir().join("live-conformance/impressions");
|
|
std::fs::read_dir(&dir)
|
|
.expect("the committed corpus")
|
|
.flatten()
|
|
.map(|e| (e.file_name().to_string_lossy().into_owned(), e.path()))
|
|
.filter(|(n, _)| n != "README.md")
|
|
.map(|(n, p)| (n, std::fs::read_to_string(p).expect("a text file")))
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn the_committed_corpus_regenerates_from_its_seeds() {
|
|
let files = committed();
|
|
let generated: BTreeMap<String, String> = corpus::generate().into_iter().collect();
|
|
let extra: Vec<&String> = files.keys().filter(|k| !generated.contains_key(*k)).collect();
|
|
let missing: Vec<&String> = generated.keys().filter(|k| !files.contains_key(*k)).collect();
|
|
let differ: Vec<&String> = generated.iter().filter(|(k, v)| files.get(*k).is_some_and(|f| f != *v)).map(|(k, _)| k).collect();
|
|
assert!(extra.is_empty() && missing.is_empty() && differ.is_empty(), "extra {extra:?}, missing {missing:?}, differ {differ:?}: run `wai_impression_aggregate_vectors gen`");
|
|
}
|
|
|
|
#[test]
|
|
fn every_case_gets_its_stated_outcome() {
|
|
let fails = corpus::verify(&committed());
|
|
assert!(fails.is_empty(), "{}", fails.join("\n"));
|
|
}
|
|
|
|
/// §8.11: each standing receipt's result is the plaintext tally of its contributions, and the
|
|
/// flight's table gives per creative what stitched presentation's verifier gives.
|
|
#[test]
|
|
fn aggregation_reproduces_the_plaintext_and_stitched_counts() {
|
|
let fails = corpus::equivalence();
|
|
assert!(fails.is_empty(), "{}", fails.join("\n"));
|
|
}
|
|
|
|
/// telemetry-aggregation §6.1, §7.3 here: each aggregator states its own `rejected` and the two
|
|
/// differ on the flight's first hour, the leader counting what its helper rejected as well; a table
|
|
/// carries each, a statement of version 1 states none, and a count changed after its aggregator
|
|
/// signed is refused at that aggregator.
|
|
#[test]
|
|
fn each_aggregator_states_what_it_rejected() {
|
|
let doc: serde_json::Value = serde_json::from_str(&committed()["vectors.json"]).unwrap();
|
|
let case = |kind: &str, name: &str| doc[kind].as_array().unwrap().iter().find(|c| c["name"] == name).unwrap_or_else(|| panic!("no case {name}"))["outcome"].clone();
|
|
let pair = |v: &serde_json::Value| (v["leader"].as_u64(), v["helper"].as_u64());
|
|
let r0 = case("receipts", "r0");
|
|
// b0's seven failures reach the leader; three reach the helper and are refused there.
|
|
assert_eq!(pair(&r0["rejected"]), (Some(7), Some(3)));
|
|
let swapped = case("receipts", "r0_rejected_as_each_states");
|
|
assert_eq!((swapped["outcome"].as_str(), pair(&swapped["rejected"])), (Some("accept"), (Some(0), Some(43))), "nothing holds one count to the other");
|
|
let none = case("receipts", "r0_of_version_1");
|
|
assert_eq!((none["outcome"].as_str(), pair(&none["rejected"])), (Some("accept"), (None, None)));
|
|
let changed = case("receipts", "rejected_count_changed");
|
|
assert_eq!((changed["reason"].as_str(), changed["at"].as_str()), (Some("bad_signature"), Some("helper")));
|
|
let table = case("tables", "flight");
|
|
assert_eq!(pair(&table["receipts"][0]["rejected"]), (Some(7), Some(3)));
|
|
assert_eq!(pair(&table["receipts"][1]["rejected"]), (Some(0), Some(0)));
|
|
assert_eq!(pair(&table["totals"]["rejected"]), (Some(7), Some(3)));
|
|
let unstated = case("tables", "flight_a_statement_of_no_count");
|
|
assert_eq!(pair(&unstated["totals"]["rejected"]), (None, None), "a total of what one statement does not state is none, never a part");
|
|
// A reader tolerates a statement of version 1 beside one of version 2: each count is its own.
|
|
let both = case("receipts", "r0_of_both_versions");
|
|
assert_eq!((both["outcome"].as_str(), pair(&both["rejected"])), (Some("accept"), (Some(7), None)));
|
|
let half = case("tables", "flight_one_statement_of_no_count");
|
|
assert_eq!(pair(&half["receipts"][0]["rejected"]), (Some(7), None));
|
|
assert_eq!(pair(&half["totals"]["rejected"]), (Some(7), None), "the leader's total stands beside a helper's of none");
|
|
// A report the helper committed and was sent again: refused, and none of what it states.
|
|
let flow = doc["flows"].as_array().unwrap().iter().find(|f| f["name"] == "flight").unwrap();
|
|
let replay = flow["steps"].as_array().unwrap().iter().find(|s| s["step"] == "replay").expect("a replay");
|
|
let replayed = format!("{}02{:02x}", replay["id"].as_str().unwrap(), wai::telemetry_aggregate::dap::report_error::REPORT_REPLAYED);
|
|
assert_eq!(replay["resp"].as_str(), Some(replayed.as_str()));
|
|
}
|
|
|
|
/// §10 of the extension lists exactly the codes `CODES` has, each once, in order.
|
|
#[test]
|
|
fn the_extension_lists_the_reasons() {
|
|
let text = std::fs::read_to_string(wai_dir().join("extensions/impression-aggregates.md")).expect("the extension");
|
|
let at = text.find("\n## 10. Reasons").expect("a §10");
|
|
let end = text[at + 1..].find("\n## ").map_or(text.len(), |e| at + 1 + e);
|
|
let listed: Vec<String> =
|
|
text[at..end].lines().filter(|l| l.starts_with("| `")).filter_map(|l| l.split('`').nth(1).map(String::from)).collect();
|
|
let want: Vec<String> = CODES.iter().map(|c| c.to_string()).collect();
|
|
assert_eq!(listed, want, "§10's table and CODES differ");
|
|
}
|
|
|
|
/// The size bounds of §5.4 and §6.4, which no vector carries. A file of exactly the bound that
|
|
/// reads in every other respect is read: a task file passes step 1 and is refused at step 2,
|
|
/// where its catalogue of zeros does not read, and an upload file reads. The same file one byte
|
|
/// longer is refused at step 1, so a reader without the bound, or with another, fails here.
|
|
/// §4.3's bound decides no outcome: no catalogue that meets §4.3 comes near it.
|
|
#[test]
|
|
fn the_size_bounds_decide_at_their_edge() {
|
|
let list = |id: u8| encode_hpke_list(&[HpkeConfig { id, kem: 0x0020, kdf: 0x0001, aead: 0x0001, public_key: vec![9; 32] }]);
|
|
let task_file = |catalogue: usize| {
|
|
let f = TaskFile { task: vec![], catalogue: vec![0; catalogue], leader_hpke: list(1), helper_hpke: list(2), signer: [0; 32], signer_id: "x".into(), sig: [0; 64] };
|
|
f.to_bytes()
|
|
};
|
|
let at = MAX_TASK_FILE - task_file(0).len();
|
|
let (inside, past) = (task_file(at), task_file(at + 1));
|
|
assert_eq!((inside.len(), past.len()), (MAX_TASK_FILE, MAX_TASK_FILE + 1));
|
|
assert_eq!(accept(&inside, &[], &[], None).err(), Some(CATALOGUE_MALFORMED), "a task file at the bound reaches step 2");
|
|
assert_eq!(accept(&past, &[], &[], None).err(), Some(TASK_FILE_MALFORMED), "one byte past it is refused at step 1");
|
|
let ct = HpkeCiphertext { config_id: 1, enc: vec![1], payload: vec![1] };
|
|
let upload = |share: usize| {
|
|
let metadata = ReportMetadata { id: [0; 16], time: 0, public_extensions: vec![] };
|
|
let r = Report { metadata, public_share: vec![0; share], leader_share: ct.clone(), helper_share: ct.clone() };
|
|
UploadFile { task_id: [0; 32], reports: vec![r] }.encode()
|
|
};
|
|
let at = MAX_UPLOAD - upload(0).len();
|
|
let (inside, past) = (upload(at), upload(at + 1));
|
|
assert_eq!((inside.len(), past.len()), (MAX_UPLOAD, MAX_UPLOAD + 1));
|
|
assert_eq!(UploadFile::parse(&inside).map(|f| f.reports.len()).ok(), Some(1), "an upload file at the bound reads");
|
|
assert_eq!(UploadFile::parse(&past).err(), Some(UPLOAD_MALFORMED), "one byte past it is refused");
|
|
}
|