The label a record carries is the top of the order an origin claim gives its steps (capture < convert < assist < generate), so one vocabulary serves both; every derivation this revision pins is a convert step and raises no label. Co-Authored-By: Claude Opus 5.5 <[email protected]>
204 lines
10 KiB
Rust
204 lines
10 KiB
Rust
//! `wai.edge.derivation` (extensions/deriving-edge.md): the committed corpus
|
|
//! `edge-conformance/vectors.json` regenerates byte for byte from its fixed seeds, every
|
|
//! vector in it gets the outcome it states, §9 lists exactly the reasons the reference
|
|
//! reports and §4 exactly its derivations; and the reference edge, `wai_edge`, run twice over
|
|
//! a publisher's clip, makes a chain of two hops that verifies back to the clip, and that is
|
|
//! refused once a byte of it changes. The generator itself asserts that the corpus gives every
|
|
//! reason.
|
|
#![cfg(feature = "derive_edge")]
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
|
|
use serde_json::{json, Value};
|
|
use wai::derive_edge::{corpus, Derivation, Reason};
|
|
use wai::jwp_delivery::GroupBuilder;
|
|
use wai::jwp_hash::content_hash;
|
|
|
|
fn wai_dir() -> PathBuf {
|
|
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
|
|
}
|
|
|
|
fn committed() -> BTreeMap<String, String> {
|
|
let dir = wai_dir().join("edge-conformance");
|
|
std::fs::read_dir(&dir)
|
|
.expect("the committed corpus")
|
|
.flatten()
|
|
.filter(|e| e.path().is_file())
|
|
.map(|e| (e.file_name().to_string_lossy().into_owned(), e.path()))
|
|
.filter(|(n, _)| n != "README.md" && n != "edge_verify.py")
|
|
.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_edge_vectors gen`");
|
|
}
|
|
|
|
#[test]
|
|
fn every_vector_gets_its_stated_outcome() {
|
|
let fails = corpus::verify(&committed());
|
|
assert!(fails.is_empty(), "{}", fails.join("\n"));
|
|
}
|
|
|
|
fn section(text: &str, heading: &str) -> String {
|
|
let at = text.find(heading).unwrap_or_else(|| panic!("no {heading}"));
|
|
let end = text[at + 1..].find("\n## ").map_or(text.len(), |e| at + 1 + e);
|
|
text[at..end].to_string()
|
|
}
|
|
|
|
fn first_cells(section: &str) -> Vec<String> {
|
|
section.lines().filter(|l| l.starts_with("| `")).filter_map(|l| l.split('`').nth(1).map(String::from)).collect()
|
|
}
|
|
|
|
/// §9 lists exactly the codes `Reason::ALL` has, each once, in order; §4's table lists
|
|
/// exactly the derivations, in order.
|
|
#[test]
|
|
fn the_extension_lists_the_reasons_and_the_derivations() {
|
|
let text = std::fs::read_to_string(wai_dir().join("extensions/deriving-edge.md")).expect("the extension");
|
|
let want: Vec<String> = Reason::ALL.iter().map(|r| r.code().to_string()).collect();
|
|
assert_eq!(first_cells(§ion(&text, "\n## 9. Reasons")), want, "§9's table and Reason::ALL differ");
|
|
let s4 = section(&text, "\n## 4. The derivations");
|
|
let table: String = s4.lines().take_while(|l| !l.starts_with("### ")).collect::<Vec<_>>().join("\n");
|
|
let want: Vec<String> = Derivation::ALL.iter().map(|d| d.id().to_string()).collect();
|
|
assert_eq!(first_cells(&table), want, "§4's table and Derivation::ALL differ");
|
|
}
|
|
|
|
// ───────────────────────────── the reference edge ─────────────────────────────
|
|
|
|
fn hex(b: &[u8]) -> String {
|
|
b.iter().map(|x| format!("{x:02x}")).collect()
|
|
}
|
|
|
|
fn sha256(b: &[u8]) -> [u8; 32] {
|
|
use sha2::Digest;
|
|
sha2::Sha256::digest(b).into()
|
|
}
|
|
|
|
fn seed(name: &str) -> [u8; 32] {
|
|
sha256(format!("wai-edge-test:{name}").as_bytes())
|
|
}
|
|
|
|
fn wai1(manifest: &str, payload: &[u8]) -> Vec<u8> {
|
|
let mut out = b"WAI1".to_vec();
|
|
out.extend_from_slice(&(manifest.len() as u32).to_le_bytes());
|
|
out.extend_from_slice(manifest.as_bytes());
|
|
out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
|
|
out.extend_from_slice(payload);
|
|
out
|
|
}
|
|
|
|
/// The C2PA Manifest Store the test binds to the clip: a byte string standing for one, which
|
|
/// the edge names by its SHA-256 and does not parse.
|
|
const STORE: &[u8] = b"C2PA Manifest Store stand-in bound to the clip";
|
|
|
|
/// A publisher's bundle: one `wai.video.int_motion` clip of two frames, in a group it signed,
|
|
/// with a C2PA Manifest Store bound to it.
|
|
fn source_bundle(dir: &Path) -> (PathBuf, String, [u8; 32]) {
|
|
let frames: Vec<Vec<u8>> = (0..2u32).map(|f| (0..6 * 4 * 3u32).map(|i| ((i * 11 + f * 50) % 256) as u8).collect()).collect();
|
|
let clip = wai::int_video_enc::encode(&frames, 4, 6, &wai::int_video_enc::EncParams { block: 2, search: 1, mv_precision: 1, q: 1 }).unwrap();
|
|
let manifest = r#"{"wai":"1.0","media":"video","intent":"replicate","model_requirement":{"capability":"wai.video.int_motion"},"conditioning":{"kind":"int_motion"},"target":{"frames":2,"h":4,"w":6}}"#;
|
|
let env = wai1(manifest, &clip.wiv1);
|
|
let key = ed25519_dalek::SigningKey::from_bytes(&seed("publisher"));
|
|
let mut b = GroupBuilder::default();
|
|
b.add(content_hash(&env), 0, "origin", None);
|
|
let (g, objs) = b.finish(&key, "did:web:source.example#pub", 1, 0, "2026-10-20T00:00:00Z", "2026-10-20T00:00:00Z", None, false).unwrap();
|
|
let bundle = json!({
|
|
"records": [],
|
|
"receipts": [g.to_json(), objs[0].to_json()],
|
|
"objects": { hex(&content_hash(&env)): hex(&env) },
|
|
"claims": [],
|
|
"c2pa": { hex(&content_hash(&env)): [hex(STORE)] },
|
|
});
|
|
let path = dir.join("source.json");
|
|
std::fs::write(&path, serde_json::to_string(&bundle).unwrap()).unwrap();
|
|
(path, hex(&key.verifying_key().to_bytes()), content_hash(&env))
|
|
}
|
|
|
|
fn edge(args: &[&str]) -> (bool, Value) {
|
|
let out = Command::new(env!("CARGO_BIN_EXE_wai_edge")).args(args).output().expect("wai_edge runs");
|
|
let text = String::from_utf8_lossy(&out.stdout);
|
|
let v = serde_json::from_str(text.trim()).unwrap_or_else(|_| panic!("wai_edge {args:?}: {text} {}", String::from_utf8_lossy(&out.stderr)));
|
|
(out.status.success(), v)
|
|
}
|
|
|
|
#[test]
|
|
fn the_reference_edge_makes_a_chain_that_verifies() {
|
|
let dir = std::env::temp_dir().join(format!("wai-edge-test-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let (source, publisher, clip) = source_bundle(&dir);
|
|
let s = |p: &Path| p.to_string_lossy().into_owned();
|
|
for (name, n) in [("a", "edge-a"), ("b", "edge-b")] {
|
|
std::fs::write(dir.join(format!("{name}.key")), hex(&seed(n))).unwrap();
|
|
}
|
|
// Hop 1: edge A rebuilds frame 1 as a pinned still.
|
|
let hop1 = dir.join("hop1.json");
|
|
let (ok, made) = edge(&[
|
|
"derive", "--bundle", &s(&source), "--out", &s(&hop1), "--key", &s(&dir.join("a.key")),
|
|
"--signer-id", "did:web:edge-a.example#derive", "--derivation", "wai.derive.identity",
|
|
"--output", "wai.image.png", "--input", &format!("{}@1", hex(&clip)),
|
|
"--track", "10", "--group", "0", "--time", "2026-10-20T12:00:00Z", "--source", &publisher,
|
|
"--personal", &hex(&sha256(STORE)), "--synthesis", &format!("{}=generate", hex(&clip)),
|
|
]);
|
|
assert!(ok, "{made}");
|
|
let still = made["output"].as_str().unwrap().to_owned();
|
|
// Hop 2: edge B crops it, with a metered figure.
|
|
let hop2 = dir.join("hop2.json");
|
|
let (ok, made) = edge(&[
|
|
"derive", "--bundle", &s(&hop1), "--out", &s(&hop2), "--key", &s(&dir.join("b.key")),
|
|
"--signer-id", "did:web:edge-b.example#derive", "--derivation", "wai.derive.crop",
|
|
"--params", r#"{"x":1,"y":1,"w":4,"h":2}"#, "--output", "wai.image.png", "--input", &still,
|
|
"--energy", r#"{"joules_micro":2500,"energy_provenance":"ModelBased","energy_uncertainty":null,"energy_counter_filtering":null,"energy_calibration_ref":null}"#,
|
|
"--track", "20", "--group", "0", "--time", "2026-10-20T12:00:01Z", "--source", &publisher,
|
|
]);
|
|
assert!(ok, "{made}");
|
|
let crop = made["output"].as_str().unwrap().to_owned();
|
|
let (ok, v) = edge(&["verify", "--bundle", &s(&hop2), "--subject", &crop, "--source", &publisher, "--require-recompute"]);
|
|
assert!(ok, "{v}");
|
|
let hops = v["facts"]["hops"].as_array().unwrap();
|
|
assert_eq!(hops.len(), 2);
|
|
assert!(hops.iter().all(|h| h["checked"] == "recomputed" && h["check"] == "replicate"), "{v}");
|
|
assert_eq!(v["facts"]["sources"][0]["object"], hex(&clip));
|
|
assert_eq!(v["facts"]["energy"], json!([{"class": "ModelBased", "joules_micro": 2500}]));
|
|
assert_eq!(v["facts"]["unmetered"], 1);
|
|
// The store bound to the clip, carried through both hops in the compartment hop 1 stated,
|
|
// and the clip's label with it.
|
|
assert_eq!(
|
|
v["facts"]["provenance"],
|
|
json!([{"input": 0, "kind": "c2pa", "digest": hex(&sha256(STORE)), "compartment": "personal"}])
|
|
);
|
|
assert!(hops.iter().all(|h| h["synthesis"] == "generate"), "{v}");
|
|
// A sink that does not take the publisher as a source finds a hop missing.
|
|
let (ok, v) = edge(&["verify", "--bundle", &s(&hop2), "--subject", &crop, "--source", &hex(&[7; 32])]);
|
|
assert!(!ok);
|
|
assert_eq!(v["reason"], "chain_skips_hop");
|
|
// A byte of the still changed: the bundle no longer verifies.
|
|
let mut b: Value = serde_json::from_str(&std::fs::read_to_string(&hop2).unwrap()).unwrap();
|
|
let env = b["objects"][&still].as_str().unwrap().to_owned();
|
|
let flipped = format!("{}{}", &env[..env.len() - 1], if env.ends_with('0') { "1" } else { "0" });
|
|
b["objects"][&still] = json!(flipped);
|
|
let tampered = dir.join("tampered.json");
|
|
std::fs::write(&tampered, serde_json::to_string(&b).unwrap()).unwrap();
|
|
let (ok, v) = edge(&["verify", "--bundle", &s(&tampered), "--subject", &crop, "--source", &publisher]);
|
|
assert!(!ok);
|
|
assert_eq!(v["reason"], "output_hash_mismatch", "{v}");
|
|
// The edge does not derive from an input whose chain does not verify.
|
|
let out = Command::new(env!("CARGO_BIN_EXE_wai_edge"))
|
|
.args([
|
|
"derive", "--bundle", &s(&tampered), "--out", &s(&dir.join("never.json")), "--key", &s(&dir.join("a.key")),
|
|
"--signer-id", "did:web:edge-a.example#derive", "--derivation", "wai.derive.mirror",
|
|
"--params", r#"{"flip":"left-right"}"#, "--output", "wai.image.png", "--input", &still,
|
|
"--track", "10", "--group", "1", "--time", "2026-10-20T12:00:02Z", "--source", &publisher,
|
|
])
|
|
.output()
|
|
.unwrap();
|
|
assert!(!out.status.success());
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|