//! Cross-language fixtures: every expected value was computed by the Python reference //! implementation (`tests/fixtures/gen_fixtures.py`); Rust must reproduce or verify it. use std::path::PathBuf; use base64::Engine; use ed25519_dalek::{Signer, SigningKey}; use odian_packs::hashing::{hex, sha256_hex}; use odian_packs::manifest::{chunk_count, chunks_digest, signing_payload}; use odian_packs::signing::load_public_key; use odian_packs::tiles::{block_digest, block_of, index_sha256, tile_line, verify_index, TileRow}; use odian_packs::{bundled_public_key, canonical_bytes, safe_relpath, validate, Error, IntegrityKind, VerifiedManifest}; use serde_json::Value; fn fixture_path(name: &str) -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests").join("fixtures").join(name) } fn read(name: &str) -> Vec { std::fs::read(fixture_path(name)).unwrap_or_else(|e| panic!("fixture {name}: {e}")) } fn load(name: &str) -> Value { serde_json::from_slice(&read(name)).unwrap() } fn s<'a>(v: &'a Value, key: &str) -> &'a str { v[key].as_str().unwrap_or_else(|| panic!("{key} is not a string in {v}")) } fn is_signature_error(result: &Result) -> bool { matches!(result, Err(Error::Integrity { kind: IntegrityKind::Signature, .. })) } #[test] fn canonical_bytes_match_python() { let doc = load("canonical.json"); let cases = doc["cases"].as_array().unwrap(); assert!(cases.len() >= 30); for case in cases { let value: Value = serde_json::from_str(s(case, "input")).unwrap(); let got = hex(&canonical_bytes(&value).unwrap()); assert_eq!(got, s(case, "canonical_hex"), "case {}", s(case, "name")); } } #[test] fn canonical_refuses_non_integer_numbers() { let doc = load("canonical.json"); for case in doc["rust_rejects"].as_array().unwrap() { let value: Value = serde_json::from_str(s(case, "input")).unwrap(); assert!( matches!(canonical_bytes(&value), Err(Error::Manifest(_))), "case {} should be refused", s(case, "name") ); } } #[test] fn signed_manifests_verify_like_python() { let doc = load("signing.json"); let public = s(&doc, "public_key"); let cases = doc["cases"].as_array().unwrap(); assert_eq!(cases.len(), 5); for case in cases { let file = s(case, "file"); let result = VerifiedManifest::from_bytes(read(file), public); match s(case, "python") { "ok" => { let verified = result.unwrap_or_else(|e| panic!("{file}: {e}")); assert_eq!(verified.raw(), read(file).as_slice(), "raw bytes are kept verbatim"); assert_eq!(verified.version(), "0.51.0"); } _ => assert!(is_signature_error(&result), "{file}: expected a signature error, got {result:?}"), } } // Signed by a different key. assert_eq!(s(&doc, "signed_with_other_key_verdict"), "error"); let other = VerifiedManifest::from_bytes(read("signed_manifest.utf8.json"), s(&doc, "other_public_key")); assert!(is_signature_error(&other)); } #[test] fn signing_payload_and_signature_match_python() { let doc = load("signing.json"); let signed = load("signed_manifest.utf8.json"); let payload = signing_payload(&signed).unwrap(); assert_eq!(hex(&payload), s(&doc, "payload_hex")); // Ed25519 is deterministic: the same key over the same payload gives Python's signature. let raw: [u8; 32] = (0u8..32).collect::>().try_into().unwrap(); assert_eq!(hex(&raw), s(&doc, "private_key_raw_hex")); let key = SigningKey::from_bytes(&raw); let signature = base64::engine::general_purpose::STANDARD.encode(key.sign(&payload).to_bytes()); assert_eq!(format!("ed25519:{signature}"), s(&doc, "signature")); let public = base64::engine::general_purpose::STANDARD.encode(key.verifying_key().to_bytes()); assert_eq!(format!("ed25519:{public}"), s(&doc, "public_key")); } #[test] fn public_key_text_rules() { let doc = load("signing.json"); let public = s(&doc, "public_key"); assert!(load_public_key(&format!(" {public}\n")).is_ok()); for bad in ["", "rsa:AAAA", "ed25519:AAAA", "ed25519:not base64!", &public.replace("ed25519:", "")] { assert!(load_public_key(bad).is_err(), "{bad:?}"); } } #[test] fn relpaths_match_python() { let doc = load("relpaths.json"); let cases = doc["cases"].as_array().unwrap(); assert!(cases.len() >= 60); for case in cases { let path = s(case, "path"); assert_eq!(safe_relpath(path).is_ok(), s(case, "python") == "ok", "path {path:?}: python {}", s(case, "python_error")); } } #[test] fn validate_matches_python_except_documented_quirks() { let doc = load("validate.json"); let cases = doc["cases"].as_array().unwrap(); let mut quirks = 0; for case in cases { let name = s(case, "name"); let value: Value = serde_json::from_str(s(case, "manifest_json")).unwrap(); // The installer's parse path: canonical form (no floats) first, then validate. let rust = canonical_bytes(&value).and_then(|_| validate(&value)); let expect_ok = s(case, "rust_expect") == "ok"; assert_eq!(rust.is_ok(), expect_ok, "case {name}: rust {rust:?}, python {}", s(case, "python_error")); if !case["quirk"].is_null() { quirks += 1; assert_eq!(s(case, "python"), "ok", "a quirk case is one Python accepts: {name}"); } } // The Python library was brought to parity (anchors, ASCII digits, integers and floats), // so a regenerated fixture must show no case where the two validators disagree. assert!(cases.len() >= 50 && quirks == 0, "{} cases, {quirks} quirks", cases.len()); } #[test] fn hashes_match_python() { let doc = load("hashes.json"); let row = |v: &Value| -> TileRow { (v[0].as_u64().unwrap(), v[1].as_u64().unwrap(), v[2].as_u64().unwrap(), v[3].as_str().unwrap().into(), v[4].as_u64().unwrap(), v[5].as_str().unwrap().into()) }; let rows: Vec = doc["block_digest"]["rows"].as_array().unwrap().iter().map(row).collect(); assert_eq!(block_digest(&rows), s(&doc["block_digest"], "digest")); assert_eq!(block_digest(&[]), s(&doc, "block_digest_empty")); for line in doc["tile_lines"].as_array().unwrap() { let (z, x, y, ext, size, sha) = row(&line["row"]); assert_eq!(tile_line(z, x, y, &ext, size, &sha), s(line, "line")); } for case in doc["block_of"].as_array().unwrap() { let n: Vec = case.as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); assert_eq!(block_of(n[0], n[1], n[2]), (n[3], n[4], n[5])); } let chunks: Vec = serde_json::from_value(doc["chunks_digest"]["chunks"].clone()).unwrap(); assert_eq!(chunks_digest(&chunks), s(&doc["chunks_digest"], "digest")); assert_eq!(chunks_digest::<&str>(&[]), s(&doc, "chunks_digest_empty")); for case in doc["chunk_count"].as_array().unwrap() { let n: Vec = case.as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); assert_eq!(chunk_count(n[0], n[1]), n[2]); } let index = &doc["index"]; let text = s(index, "text"); assert_eq!(index_sha256(&serde_json::from_str(text).unwrap()).unwrap(), s(index, "index_sha256")); let parsed = verify_index(text.as_bytes(), s(index, "index_sha256"), "fixture index").unwrap(); assert_eq!(parsed.blocks.len(), 2); let wrong = verify_index(text.as_bytes(), &"0".repeat(64), "fixture index"); assert!(matches!(wrong, Err(Error::Integrity { kind: IntegrityKind::IndexHash, .. }))); } #[test] fn real_published_manifest_verifies_with_the_bundled_key() { let hashes = load("real/0.51.0.hashes.json"); let raw = read("real/0.51.0.manifest.json"); assert_eq!(raw.len() as u64, hashes["raw_len"].as_u64().unwrap()); assert_eq!(sha256_hex(&raw), s(&hashes, "raw_sha256")); assert_eq!(bundled_public_key(), s(&hashes, "release_public_key")); let verified = VerifiedManifest::from_bytes(raw.clone(), bundled_public_key()).expect("the real release verifies"); assert_eq!(verified.version(), s(&hashes, "version")); let canonical = canonical_bytes(verified.value()).unwrap(); assert_eq!(canonical.len() as u64, hashes["canonical_len"].as_u64().unwrap()); assert_eq!(sha256_hex(&canonical), s(&hashes, "canonical_sha256")); assert_eq!(sha256_hex(&signing_payload(verified.value()).unwrap()), s(&hashes, "payload_sha256")); let m = verified.manifest(); assert_eq!(m.app.files.len() as u64, hashes["app_files"].as_u64().unwrap()); assert!(m.db.is_some() && m.tiles.is_some() && m.src.as_ref().is_some_and(|s| s.enabled)); // One changed character anywhere breaks it. let text = String::from_utf8(raw).unwrap(); let released = verified.manifest().released_at.clone(); let mut bumped = released.clone().into_bytes(); let last_digit = bumped.iter().rposition(u8::is_ascii_digit).unwrap(); bumped[last_digit] = if bumped[last_digit] == b'9' { b'0' } else { bumped[last_digit] + 1 }; let tampered = text.replacen(&released, std::str::from_utf8(&bumped).unwrap(), 1); assert!(is_signature_error(&VerifiedManifest::from_bytes(tampered.into_bytes(), bundled_public_key()))); // And the test key does not vouch for it. let test_key = load("signing.json"); assert!(is_signature_error(&VerifiedManifest::from_bytes(read("real/0.51.0.manifest.json"), s(&test_key, "public_key")))); }