open-standards/wai/wai-pack/tests/review.rs
Transaction Science 39375763c7 wai-pack: an override never relaxes royalty_free_only; empty and unreadable directories refused; pack only into an empty --out; classic in any case; deep alterations never panic
Round 3 of the package review.

A. §3.2: an override only forces royalty_free_only on. Overrides.royalty_free_only is a
   bool (force on); Policy::effective ORs it in. Both verifiers read policy.json's
   royalty_free_only as that override, so a policy.json of false over a policy.toml of
   true is policy-json-mismatch. pack_verify.py parses policy.toml, applies the
   override, then checks §3.6 under the effective policy (§11.3 step 4), no longer
   refusing at the parse. Corpus case policy-json-relaxes-royalty-free (the review's
   pk-pol-rfo-false: a patent-pool floor relabelled through the objects and index,
   sealed with policy.json false). golden::reseal_as seals a policy.json given as bytes.
   The policy corpus's override case is now eq-defaults-a.toml forced on (example.toml
   forced off was a relaxation); packs.rs takes the line out of the policy instead.
B. pack_verify.py's strict walk raises on a directory it cannot read (os.walk onerror)
   and refuses it, as read_strict does.
C. Both verifiers refuse a directory below the package that holds nothing
   (unexpected-file); §11.1 says "no other file or directory". Corpus cases
   empty-directory, empty-dot-directory (.git/refs) and unreadable-directory (mode 0,
   or empty where the platform has no modes). Package cases are now written out and
   verified as directories by both verifiers (golden::package_case_verdict).
D. wai-pack pack refuses an --out that exists and is not empty (pack-out-not-empty,
   pack::out_dir_ready), before any work; §11.4 and §12. refusals.rs runs the command
   line: a stale claim left alone, a file in the way, an empty and a new directory
   packed and verified.
E. A class named classic in any case is pack-id (eq_ignore_ascii_case); corpus case
   class-named-classic-in-capitals.
F. review.rs deep_alterations_never_panic: 300 seeded rounds of floor-unit, framing,
   base-payload and index-field alterations through golden::replace_object and a
   re-seal; never a panic; anything accepted is in a stated equivalence class (AV1
   frame data, producer statements, equivalent WIV1 encodings) or the golden itself.

Floors: review 9, refusals 5.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 09:09:09 -04:00

408 lines
17 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! What a package's verifier must refuse, each case a package altered and, where the
//! alteration would otherwise be caught by the receipt or `LISTING` alone, re-sealed with
//! the package key (what a dishonest packager holding the key could publish): the
//! package bound to its policy, malformed packages refused and never panicked on, the
//! floor's key frames and members, hidden files, the receipt's canonical bytes, and a
//! seeded run of random alterations that must never panic.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use wai::stage_index::StageIndex;
use wai_pack::golden;
type Files = BTreeMap<String, Vec<u8>>;
fn corpus() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../pack-conformance")
}
fn golden_files() -> Files {
wai::pin::read_dir_set(&corpus().join("packs/video-base-01/package")).unwrap()
}
fn key() -> ed25519_dalek::SigningKey {
let t = std::fs::read_to_string(corpus().join("packs/video-base-01/key.hex")).unwrap();
let t = t.trim();
let mut seed = [0u8; 32];
for (i, s) in seed.iter_mut().enumerate() {
*s = u8::from_str_radix(&t[2 * i..2 * i + 2], 16).unwrap();
}
ed25519_dalek::SigningKey::from_bytes(&seed)
}
fn codes(f: &Files) -> Vec<String> {
match wai_pack::verify::verify_files(f) {
Ok(_) => vec!["ok".into()],
Err(v) => v.into_iter().map(|r| r.code).collect(),
}
}
fn first(f: &Files) -> String {
codes(f).into_iter().next().unwrap()
}
fn index(f: &Files) -> StageIndex {
StageIndex::parse(&f["index-v.wsix"]).unwrap()
}
fn set_index(f: &mut Files, ix: &StageIndex) {
f.insert("index-v.wsix".into(), ix.to_bytes());
}
fn resealed(mut f: Files) -> Files {
golden::reseal(&mut f, &key()).unwrap();
f
}
/// The package mutations of the corpus (golden.rs), by name.
fn mutations() -> BTreeMap<&'static str, golden::PackageMutation> {
golden::package_mutations(&golden_files(), &key()).into_iter().map(|m| (m.name, m)).collect()
}
/// Each named case, written out as a directory and read by the verifier's own walk, is
/// refused with the reason the corpus gives it, first.
fn holds(cases: &[(&str, &str)]) {
let m = mutations();
for (name, reason) in cases {
let c = m.get(name).unwrap_or_else(|| panic!("no case {name}"));
assert_eq!(c.reason, *reason, "{name}: the corpus's reason");
assert_eq!(golden::package_case_verdict(&c.files, &c.empty_dirs, &c.unreadable_dirs).unwrap(), *reason, "{name}");
}
}
#[test]
fn the_golden_verifies_and_reseals_to_itself() {
let g = golden_files();
assert_eq!(first(&g), "ok");
assert_eq!(resealed(g.clone()), g, "the re-sealer reproduces the golden byte for byte");
}
/// Item 1 of the review: a package is bound to the policy its receipt signs.
#[test]
fn a_package_is_bound_to_its_policy() {
holds(&[
// A version-2 receipt over no component at all, signed with the package key.
("no-component", "policy-component-mismatch"),
("no-component-live", "policy-component-mismatch"),
// The index's class records (with its class plan, and a rate alone), an entry's
// capability, the string table's order and a class plan, each not the policy's.
("index-class-records", "index-policy-mismatch"),
("index-class-rate", "index-policy-mismatch"),
("index-capability", "index-policy-mismatch"),
("index-string-order", "index-policy-mismatch"),
("index-class-plan", "index-policy-mismatch"),
// A class named classic, in any case; under royalty_free_only (the policy's, and
// the packer's override as policy.json states it), a patent-pool floor, and a
// policy.json that turns it off; the policy's component of another medium.
("class-named-classic", "pack-id"),
("class-named-classic-in-capitals", "pack-id"),
("policy-json-relaxes-royalty-free", "policy-json-mismatch"),
("patent-pool-floor", "license-refused:wai.video.avc:patent-pool"),
("patent-pool-floor-by-override", "license-refused:wai.video.avc:patent-pool"),
("component-medium", "policy-component-mismatch"),
]);
}
/// Item 2: a malformed package is refused, never panicked on.
#[test]
fn malformed_packages_are_refused() {
holds(&[("floor-object-missing", "object-missing"), ("live-window-past-2-53", "catalog-mismatch")]);
// A corrupted floor object is refused for its hash, and for nothing it would have led
// to.
let m = mutations();
let c = codes(&m["floor-object-corrupt"].files);
assert!(c.iter().all(|c| c == "object-hash"), "{c:?}");
// An on-demand index whose first piece does not start at 0: a packager cannot seal it.
let g = golden_files();
let mut f = g.clone();
let mut ix = index(&f);
for p in ix.pieces.iter_mut() {
p.start += 10;
}
set_index(&mut f, &ix);
golden::relist(&mut f);
assert!(golden::reseal(&mut f, &key()).is_err(), "a packager cannot seal it either");
// A fragment's decode time past 2^64 − 1.
let (_, b) = golden::floor_entry(&g, 0);
let (_, _, units) = golden::floor_parts(&b);
let tus: Vec<wai_pack::av1::TemporalUnit> = units.iter().map(|u| wai_pack::av1::temporal_units(u).unwrap().remove(0)).collect();
let seq = wai_pack::av1::SequenceHeader::parse(tus[0].sequence_header().unwrap().payload).unwrap();
let fl = wai_pack::floor::Floor { seq, seq_obu: tus[0].sequence_header().unwrap().bytes.to_vec(), pieces: vec![wai_pack::floor::FloorPiece { units: units.clone(), key: vec![true; units.len()] }] };
assert!(wai_pack::pack::cmaf_files("v", &fl, &[u64::MAX / 2], 3, 25).is_err());
}
/// Item 3: a floor fragment opens on a shown key frame, and holds no other unless the
/// policy allows; every sequence header is the first's.
#[test]
fn a_floor_piece_opens_on_a_key_frame() {
holds(&[
("floor-opens-on-inter", "floor-keyframe-mismatch"),
("floor-extra-key-frame", "floor-keyframe-mismatch"),
("floor-extra-key-frame-allowed", "ok"),
("floor-sequence-header", "floor-av1-malformed"),
]);
}
/// Item 6: a supplied floor's members are §7.2's; it presents its piece's base, and its
/// CMAF files are the ones its units give.
#[test]
fn a_floor_object_is_bound_to_its_profile() {
holds(&[
("floor-framing-header", "floor-object-mismatch"),
("floor-renderer-settings", "floor-object-mismatch"),
("floor-target", "floor-object-mismatch"),
("floor-motif", "floor-motif-mismatch"),
("floor-cmaf", "floor-cmaf-mismatch"),
]);
}
/// Item 7: a directory read strictly, dot-names and `*.sha256` files included; and a
/// directory that holds nothing or cannot be read.
#[test]
fn hidden_files_are_refused() {
holds(&[
("dot-file", "unexpected-file"),
("dot-directory", "unexpected-file"),
("dot-object", "unexpected-file"),
("sha256-file", "unexpected-file"),
("empty-directory", "unexpected-file"),
("empty-dot-directory", "unexpected-file"),
("unreadable-directory", "unexpected-file"),
]);
// The strict walk reads hidden files rather than skipping them, and refuses a
// directory that holds nothing.
let dir = std::env::temp_dir().join(format!("wai-pack-hidden-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let m = mutations();
for (name, extra) in [("dot-file", ".hidden"), ("dot-directory", ".cache/blob"), ("dot-object", "objects/.x.wai"), ("sha256-file", "payload.sha256")] {
let d = dir.join(name);
for (p, b) in &m[name].files {
let path = d.join(p);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, b).unwrap();
}
let files = wai_pack::verify::read_strict(&d).unwrap();
assert!(files.contains_key(extra), "{extra} is read");
}
std::fs::create_dir_all(dir.join("dot-file/hls/empty")).unwrap();
assert!(wai_pack::verify::read_strict(&dir.join("dot-file")).is_err(), "a directory that holds nothing");
let _ = std::fs::remove_dir_all(&dir);
}
/// Item 4: the receipt file is its canonical form; a member named twice is refused.
#[test]
fn the_receipt_file_is_canonical() {
holds(&[("receipt-pretty", "receipt-json"), ("receipt-newline", "receipt-json"), ("receipt-duplicate-member", "receipt-json")]);
}
/// Item 2: random alterations of the golden, some re-sealed, never make the verifier
/// panic. Seeded, so a failure reproduces.
#[test]
fn random_alterations_never_panic() {
let g = golden_files();
let paths: Vec<String> = g.keys().cloned().collect();
let mut s = 0x9e37_79b9_7f4a_7c15u64;
let mut next = || {
s ^= s << 13;
s ^= s >> 7;
s ^= s << 17;
s
};
let mut refused = 0;
for round in 0..240 {
let mut f = g.clone();
for _ in 0..1 + next() % 3 {
let p = &paths[(next() % paths.len() as u64) as usize];
let Some(b) = f.get_mut(p) else { continue };
match next() % 6 {
0 if !b.is_empty() => {
let i = (next() % b.len() as u64) as usize;
b[i] ^= 1 << (next() % 8);
}
1 => {
let n = (next() % (b.len() as u64 + 1)) as usize;
b.truncate(n);
}
2 => {
b.extend_from_slice(&[0u8; 9]);
}
3 if p.ends_with(".wsix") && b.len() > 200 => {
let i = 160 + (next() % (b.len() as u64 - 160)) as usize;
b[i] = (next() & 0xff) as u8;
}
4 => {
f.remove(p);
}
_ => {
let i = (next() % b.len().max(1) as u64) as usize;
if i < b.len() {
b[i] = b'"';
}
}
}
}
if round % 2 == 0 {
golden::relist(&mut f);
}
if round % 3 == 0 {
let _ = golden::reseal(&mut f, &key());
}
let r = std::panic::catch_unwind(|| wai_pack::verify::verify_files(&f));
assert!(r.is_ok(), "round {round} panicked");
if r.unwrap().is_err() {
refused += 1;
}
}
// A re-sealed alteration of a derived file can be the golden again; most are refused.
assert!(refused > 120, "{refused} of 240 refused");
}
/// Round 3, item F: alterations of the floor, a base and the index that keep the package
/// consistent (the index following the new bytes through `golden::replace_object`, the
/// package re-sealed with its key) reach the verifier's floor, prefix and catalog checks,
/// and never make it panic. Seeded and bounded, so a failure reproduces.
///
/// What a verifier without the source accepts, by design; an altered package it accepts
/// must be in one of these classes (or be the golden again):
/// - the AV1 frame data a floor's units carry: the verifier checks each unit's framing,
/// the sequence headers and where the key frames are, not the pictures (it decodes no
/// AV1), so other bytes inside a unit, and inter units moved or repeated within a
/// piece, are accepted;
/// - producer-statement fields only the source decides: a piece's kind and flags (§4.3,
/// §4.6) and an entry's `sse` (§6.6), where the rules, moments and class plan they lead
/// to are unchanged;
/// - equivalent WIV1 encodings: a base payload whose bytes differ but which decodes to
/// the same frames, so its prefix digest holds (§6.5).
///
/// A floor object's framing header, a unit cut short and a unit with a second temporal
/// unit appended are never accepted.
#[test]
fn deep_alterations_never_panic() {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Alteration {
UnitBytes,
UnitOrder,
Framing,
UnitCut,
UnitExtended,
BasePayload,
IndexField,
}
use Alteration::*;
let g = golden_files();
let key = key();
let mut s = 0x2545_f491_4f6c_dd1du64;
let mut next = move || {
s ^= s << 13;
s ^= s >> 7;
s ^= s << 17;
s
};
let (mut accepted, mut unsealed) = (0u32, 0u32);
let mut refused: BTreeMap<String, u32> = BTreeMap::new();
for round in 0..300 {
let mut f = g.clone();
let ix = index(&f);
let p = (next() % ix.pieces.len() as u64) as usize;
let alteration = match next() % 5 {
kind @ 0..=2 => {
let (k, b) = golden::floor_entry(&f, p);
let (man, mut hdr, mut units) = golden::floor_parts(&b);
let u = (next() % units.len() as u64) as usize;
let a = match (kind, next() % 6) {
(0, _) => {
let n = units[u].len();
let i = (next() % n as u64) as usize;
units[u][i] ^= 1 << (next() % 8);
UnitBytes
}
(1, 0) => {
let n = units[u].len() as u64;
units[u].truncate((next() % (n + 1)) as usize);
UnitCut
}
(1, 1) => {
let x = units.remove(u);
let j = (next() % (units.len() as u64 + 1)) as usize;
units.insert(j, x);
UnitOrder
}
(1, 2) => {
let x = units[u].clone();
units.insert(u, x);
units.pop();
UnitOrder
}
(1, 3) => {
// A temporal delimiter OBU: a second temporal unit in one unit.
units[u].extend_from_slice(&[0x12, 0x00]);
UnitExtended
}
(1, 4) => {
if units[u].len() > 2 {
units[u][1] = (next() & 0xff) as u8;
}
UnitBytes
}
(1, _) => {
units.swap(0, u);
UnitOrder
}
(_, w) => {
match w % 4 {
0 => hdr.0 = (next() % 70_000) as u32,
1 => hdr.1 = 0,
2 => hdr.2 = u32::MAX,
_ => hdr.3 = 0,
}
Framing
}
};
golden::replace_object(&mut f, k, golden::floor_envelope(&man, hdr, &units));
a
}
3 => {
let k = ix.pieces[p].first_entry as usize;
let mut b = f[&wai_pack::objects::object_path(&ix.entries[k].content_hash)].clone();
let ml = u32::from_le_bytes(b[4..8].try_into().unwrap()) as usize;
let at = 12 + ml + (next() % (b.len() - 12 - ml) as u64) as usize;
b[at] = (next() & 0xff) as u8;
golden::replace_object(&mut f, k, b);
BasePayload
}
_ => {
let mut ix = ix.clone();
let k = (next() % ix.entries.len() as u64) as usize;
match next() % 5 {
0 => ix.pieces[p].kind = (next() % 4) as u8,
1 => ix.pieces[p].flags = (next() % 16) as u8,
2 => ix.entries[k].sse = next(),
3 => ix.pieces[p].moments = (next() % 4) as u32,
_ => ix.pieces[p].rules = (next() % 4) as u32,
}
set_index(&mut f, &ix);
IndexField
}
};
// What a packager cannot seal (an index its own rules refuse) is not a package.
if golden::reseal(&mut f, &key).is_err() {
unsealed += 1;
continue;
}
let verdict = std::panic::catch_unwind(|| wai_pack::verify::verify_files(&f)).unwrap_or_else(|_| panic!("round {round} ({alteration:?}): the verifier panicked"));
if let Err(v) = verdict {
*refused.entry(v[0].code.clone()).or_default() += 1;
} else {
accepted += 1;
assert!(
f == g || matches!(alteration, UnitBytes | UnitOrder | BasePayload | IndexField),
"round {round}: a {alteration:?} alteration accepted, outside the classes a verifier without the source accepts"
);
}
}
// The alterations reach the floor and prefix checks, past the receipt and the index.
for reached in ["floor-object-mismatch", "floor-keyframe-mismatch", "prefix-digest", "index-policy-mismatch"] {
assert!(refused.contains_key(reached), "no round reached {reached}: {refused:?}");
}
assert!(accepted > 0 && unsealed < 100, "{refused:?} refused, {accepted} accepted, {unsealed} not sealable");
}