All checks were successful
standards workspaces build + test / eoc-rs (push) Successful in 1m54s
standards workspaces build + test / jcp-rs (push) Successful in 1m19s
standards workspaces build + test / proof-rs (push) Successful in 1m12s
standards workspaces build + test / jouleclaw-rs (push) Successful in 6m1s
standards workspaces build + test / sandbox-rs (push) Successful in 2m10s
standards workspaces build + test / aac-rs (push) Successful in 1m14s
standards workspaces build + test / smart-byte-rs (push) Successful in 2m34s
standards workspaces build + test / joule-code-rs (push) Successful in 4m20s
standards workspaces build + test / joulecontract-rs (push) Successful in 1m43s
standards workspaces build + test / map-rs (push) Successful in 1m38s
standards workspaces build + test / mesh-rs (push) Successful in 53s
standards workspaces build + test / openpay (push) Successful in 4m35s
standards workspaces build + test / arl-rs (push) Successful in 47m49s
standards workspaces build + test / joule-ui-rs (push) Successful in 56m18s
standards workspaces build + test / jouledesk-rs (push) Successful in 1h3m7s
standards workspaces build + test / joulehook-rs (push) Successful in 12m29s
standards workspaces build + test / sense-rs (push) Successful in 2m27s
CI now runs map's main() harness (#93), but that harness computes both sides of every comparison in process. Nothing reads map/conformance/v1/*.json — the files a third-party implementation is told to certify against. The gap is measured, not assumed. Changing the Merkle domain separator in map-schema from "map:node" to "map:nodeX" -- a wire-format change, since a third-party implementation would then compute different roots for the same tileset -- leaves the harness reporting 15 passed, 0 failed. The new drift test is the only thing that notices. tests/pack.rs takes inputs from the pack and recomputes recorded outputs rather than re-reading them: every published cell key is rebuilt from its scalar fields and re-addressed to its recorded canonical CBOR and content id; the tileset is rebuilt as gen-vectors builds it and compared to the published directory, manifest and revision, with the Merkle root recomputed, both signatures verified, and the revision chaining onto the manifest root; and the sealed JCR-1 receipt is re-derived and compared byte for byte. That receipt check was written expecting a third stale vector -- joulehook's and jouledesk's published JCR-1 envelopes both fell behind when the receipt payload gained a field. map's is current. The prediction was wrong, which is worth recording as much as a hit would have been. Two things the tests document rather than assert blindly: - Directory::root() commits to the sorted (key, content_id) leaves, so editing an entry's offset or len leaves it unchanged. That is by design and sound -- the content_id authenticates whatever bytes a range fetch returns, so a moved range fails at the content check. An earlier draft of this test asserted the opposite and was wrong. The tamper case edits a content_id instead. - The published manifest does NOT survive a JSON round trip. AltSchedule::default() -- the schedule a 2.5D surface-only tileset uses, so the common case -- sets width_m to f64::INFINITY. JSON has no infinity, so serde_json writes null and f64 refuses null coming back. A third party deserializing tileset_vectors.json with the reference types fails on the first step. Signatures and content ids are unaffected: the canonical form is CBOR, which represents infinity. Fixing the JSON presentation is a normative change to a published artifact, so it is left to a maintainer; the test pins the defect so it is visible, and fails when it is fixed. Co-Authored-By: Claude Opus 5 <[email protected]>
241 lines
10 KiB
Rust
241 lines
10 KiB
Rust
//! Consume the published `map` conformance pack.
|
|
//!
|
|
//! The harness in `main.rs` runs its suites against in-process fixtures, and CI now
|
|
//! runs that harness — but nothing reads `map/conformance/v1/*.json`, the files a
|
|
//! third-party implementation is told to certify against. So the published bytes
|
|
//! could drift from the implementation with nothing to notice.
|
|
//!
|
|
//! Inputs come from the pack and recorded outputs are recomputed, not re-read:
|
|
//! a cell key is rebuilt from its scalar fields and re-addressed, a directory is
|
|
//! deserialized and its Merkle root recomputed, signatures are checked, and the
|
|
//! sealed receipt is re-derived and compared byte for byte.
|
|
//!
|
|
//! Note `gen-vectors` writes to a hardcoded path with no output argument, so it
|
|
//! cannot be run to diff against a scratch copy without overwriting the published
|
|
//! pack. This test does that comparison in-process instead.
|
|
|
|
use ed25519_dalek::SigningKey;
|
|
use map_receipt::{Energy, Receipt};
|
|
use map_schema::{
|
|
AltBand, AltSchedule, CellKey, ContentId, DirEntry, Directory, Lod, Manifest, ManifestBody,
|
|
Revision, RevisionBody, S2CellId, Tier, TimeBucket, canonical_cbor, verify_chain,
|
|
};
|
|
use serde_json::Value;
|
|
use std::path::PathBuf;
|
|
|
|
/// The fixed publisher key the pack is generated under.
|
|
const PUBLISHER_SEED: [u8; 32] = [7u8; 32];
|
|
|
|
fn pack(name: &str) -> Value {
|
|
let p = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
.join("../../../conformance/v1")
|
|
.canonicalize()
|
|
.expect("the conformance pack must exist next to the spec")
|
|
.join(name);
|
|
serde_json::from_slice(&std::fs::read(&p).unwrap_or_else(|e| panic!("read {name}: {e}")))
|
|
.unwrap_or_else(|e| panic!("{name} must parse: {e}"))
|
|
}
|
|
|
|
/// Every published cell key re-addresses to its recorded canonical CBOR and content
|
|
/// id, rebuilt from the scalar fields rather than from a fixture.
|
|
#[test]
|
|
fn published_cell_keys_re_address_to_their_recorded_ids() {
|
|
let doc = pack("cell_key_vectors.json");
|
|
let cells = doc["cells"].as_array().expect("pack lists cells");
|
|
assert!(cells.len() >= 3, "a wrong path must not look like a pass");
|
|
|
|
for (i, c) in cells.iter().enumerate() {
|
|
let key = CellKey {
|
|
s2: S2CellId(c["s2"].as_u64().unwrap()),
|
|
alt: AltBand(c["alt"].as_i64().unwrap() as _),
|
|
time: TimeBucket {
|
|
start_tai_ms: c["time"]["start_tai_ms"].as_i64().unwrap() as _,
|
|
width_ms: c["time"]["width_ms"].as_u64().unwrap() as _,
|
|
},
|
|
lod: Lod(c["lod"].as_u64().unwrap() as u8),
|
|
};
|
|
assert_eq!(
|
|
hex::encode(canonical_cbor(&key).unwrap()),
|
|
c["canonical_cbor_hex"].as_str().unwrap(),
|
|
"cell {i}: canonical CBOR differs from the published bytes"
|
|
);
|
|
assert_eq!(
|
|
ContentId::of(&key).unwrap().to_hex(),
|
|
c["content_id"].as_str().unwrap(),
|
|
"cell {i}: content id differs from the published id"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The tileset the pack publishes, rebuilt exactly as `gen-vectors` builds it.
|
|
///
|
|
/// Rebuilding rather than deserializing is forced here: the published manifest does
|
|
/// not survive a JSON round trip (see `the_published_manifest_does_not_round_trip`).
|
|
/// It is also the stronger comparison — it holds the implementation to the published
|
|
/// bytes rather than merely reading them back.
|
|
fn rebuilt_tileset() -> (Directory, Manifest, Revision) {
|
|
let sk = SigningKey::from_bytes(&PUBLISHER_SEED);
|
|
let cell = |s2: u64, lod: u8| CellKey {
|
|
s2: S2CellId(s2),
|
|
alt: AltBand(0),
|
|
time: TimeBucket::ATEMPORAL,
|
|
lod: Lod(lod),
|
|
};
|
|
let directory = Directory::new(vec![
|
|
DirEntry { key: cell(0x10, 0), offset: 0, len: 128, content_id: ContentId::of_bytes(b"a") },
|
|
DirEntry { key: cell(0x20, 0), offset: 128, len: 64, content_id: ContentId::of_bytes(b"b") },
|
|
DirEntry { key: cell(0x30, 0), offset: 256, len: 32, content_id: ContentId::of_bytes(b"c") },
|
|
]);
|
|
let manifest = ManifestBody {
|
|
spec_version: "0.1.0".into(),
|
|
datum: "WGS84".into(),
|
|
alt_schedule: AltSchedule::default(),
|
|
dir_root: directory.root().unwrap(),
|
|
bounds: Some((cell(0x10, 0), cell(0x30, 0))),
|
|
disclaimer: None,
|
|
publisher: sk.verifying_key(),
|
|
}
|
|
.sign(&sk)
|
|
.unwrap();
|
|
let revision = RevisionBody {
|
|
prev_root: manifest.root().unwrap(),
|
|
new_root: ContentId::of_bytes(b"next-cycle-root"),
|
|
timestamp_tai_ms: 1,
|
|
publisher: sk.verifying_key(),
|
|
}
|
|
.sign(&sk)
|
|
.unwrap();
|
|
(directory, manifest, revision)
|
|
}
|
|
|
|
/// The implementation still produces exactly the published tileset, its Merkle root
|
|
/// recomputes, both signatures verify, and the revision chains onto the manifest —
|
|
/// which is what makes the tileset updatable rather than merely signed.
|
|
#[test]
|
|
fn published_tileset_still_matches_and_its_signatures_verify() {
|
|
let doc = pack("tileset_vectors.json");
|
|
let (directory, manifest, revision) = rebuilt_tileset();
|
|
|
|
assert_eq!(serde_json::to_value(&directory).unwrap(), doc["directory"], "directory has drifted");
|
|
assert_eq!(serde_json::to_value(&manifest).unwrap(), doc["manifest"], "manifest has drifted");
|
|
assert_eq!(serde_json::to_value(&revision).unwrap(), doc["revision"], "revision has drifted");
|
|
assert_eq!(directory.root().unwrap().to_hex(), doc["dir_root"].as_str().unwrap());
|
|
|
|
manifest.verify().expect("the published manifest must verify");
|
|
revision.verify().expect("the published revision must verify");
|
|
assert_eq!(manifest.body.dir_root, directory.root().unwrap());
|
|
assert_eq!(revision.body.prev_root, manifest.root().unwrap());
|
|
|
|
let tip = verify_chain(manifest.root().unwrap(), std::slice::from_ref(&revision))
|
|
.expect("the one-link revision chain must verify");
|
|
assert_eq!(tip, revision.body.new_root);
|
|
}
|
|
|
|
/// The checks above are not vacuous.
|
|
///
|
|
/// Note what the root does *not* commit to: `root()` is documented as the Merkle
|
|
/// root over the sorted `(key, content_id)` leaves, so editing an entry's `offset`
|
|
/// or `len` leaves it unchanged by design. That is sound — the `content_id`
|
|
/// authenticates whatever bytes a range fetch returns, so a moved range fails at the
|
|
/// content check rather than at the root. The tamper below therefore edits a
|
|
/// `content_id`, which the root does commit to.
|
|
#[test]
|
|
fn tampering_with_committed_fields_is_rejected() {
|
|
let (mut directory, mut manifest, revision) = rebuilt_tileset();
|
|
|
|
let published_root = directory.root().unwrap();
|
|
directory.entries[0].content_id = ContentId::of_bytes(b"swapped tile");
|
|
assert_ne!(directory.root().unwrap(), published_root, "a swapped tile left the root unchanged");
|
|
|
|
manifest.body.datum = "NOT-WGS84".into();
|
|
assert!(manifest.verify().is_err(), "a rewritten manifest body still verified");
|
|
|
|
let wrong_root = ContentId::of_bytes(b"not the manifest root");
|
|
assert!(
|
|
verify_chain(wrong_root, std::slice::from_ref(&revision)).is_err(),
|
|
"the revision chained onto a root it does not follow"
|
|
);
|
|
}
|
|
|
|
/// A DEFECT in the published pack, pinned so it is visible rather than silent.
|
|
///
|
|
/// `AltSchedule::default()` — the schedule a 2.5D surface-only tileset uses, so the
|
|
/// common case — sets `width_m: f64::INFINITY`. JSON has no infinity, so serde_json
|
|
/// writes `null`, and `f64` refuses `null` coming back. The published
|
|
/// `tileset_vectors.json` therefore cannot be read back into the reference
|
|
/// implementation's own types, which is exactly what a third party certifying
|
|
/// against the pack would try first.
|
|
///
|
|
/// The canonical signing form is CBOR, which represents infinity, so signatures and
|
|
/// content ids are unaffected — this is the JSON presentation only. Fixing it is a
|
|
/// normative change to a published artifact (a sentinel, an `Option`, or a custom
|
|
/// serializer), so it is left to a maintainer. When it is fixed this test fails,
|
|
/// which is the intended signal to replace it with a real round-trip assertion.
|
|
#[test]
|
|
fn the_published_manifest_does_not_round_trip() {
|
|
let doc = pack("tileset_vectors.json");
|
|
assert!(
|
|
doc["manifest"]["body"]["alt_schedule"]["width_m"].is_null(),
|
|
"width_m is no longer null — the infinity round-trip may be fixed"
|
|
);
|
|
let err = serde_json::from_value::<Manifest>(doc["manifest"].clone())
|
|
.expect_err("the manifest round-trips now; replace this test with a real assertion");
|
|
assert!(
|
|
err.to_string().contains("expected f64"),
|
|
"round-trip failed for a different reason than the infinity/null one: {err}"
|
|
);
|
|
|
|
// The revision carries no AltSchedule, so it does round-trip — the defect is
|
|
// confined to the manifest.
|
|
let revision: Revision = serde_json::from_value(doc["revision"].clone())
|
|
.expect("the published revision must round-trip");
|
|
revision.verify().expect("and verify");
|
|
}
|
|
|
|
/// Drift: the reference implementation still seals exactly the published receipt.
|
|
///
|
|
/// This is the check that caught stale vectors in joulehook and jouledesk, whose
|
|
/// published JCR-1 envelopes fell behind when the receipt payload gained a field.
|
|
/// `map` pins a JCR-1 envelope the same way, so it is exposed to the same drift.
|
|
#[test]
|
|
fn the_published_receipt_is_still_what_the_implementation_seals() {
|
|
let doc = pack("receipt_vectors.json");
|
|
let steward = doc["steward"].as_str().expect("pack names the steward");
|
|
let sk = SigningKey::from_bytes(&PUBLISHER_SEED);
|
|
assert_eq!(
|
|
hex::encode(sk.verifying_key().to_bytes()),
|
|
doc["publisher_pubkey"].as_str().unwrap(),
|
|
"the pack's publisher key is not the documented seed's"
|
|
);
|
|
|
|
// Rebuilt exactly as gen-vectors builds it.
|
|
let rcpt = Receipt {
|
|
cell: CellKey {
|
|
s2: S2CellId(0x42),
|
|
alt: AltBand(0),
|
|
time: TimeBucket::ATEMPORAL,
|
|
lod: Lod(0),
|
|
},
|
|
content_id: ContentId::of_bytes(b"tile"),
|
|
tier: Tier::Photoreal,
|
|
energy: Energy::estimate_tier(Tier::Photoreal),
|
|
sequence: 1,
|
|
nonce: [0, 0, 0, 0, 0, 0, 0, 1],
|
|
};
|
|
|
|
assert_eq!(
|
|
rcpt.to_jcr1_wire(steward).unwrap(),
|
|
doc["wire"],
|
|
"the JCR-1 wire projection differs from the published one"
|
|
);
|
|
assert_eq!(
|
|
hex::encode(rcpt.signed_core(steward).unwrap()),
|
|
doc["signed_core_hex"].as_str().unwrap(),
|
|
"the signed core differs from the published bytes"
|
|
);
|
|
assert_eq!(
|
|
hex::encode(rcpt.seal(&sk, steward).unwrap()),
|
|
doc["cose_sign1_envelope_hex"].as_str().unwrap(),
|
|
"the sealed envelope differs from the published bytes — a stale vector?"
|
|
);
|
|
}
|