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]>
408 lines
17 KiB
Rust
408 lines
17 KiB
Rust
//! 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");
|
||
}
|