open-standards/map/map-rs/crates/map-schema/src/lib.rs
Transaction Science fef51e4ecd
Some checks failed
standards workspaces build + test / jouleclaw-rs (push) Has been cancelled
standards workspaces build + test / aac-rs (push) Has been cancelled
standards workspaces build + test / arl-rs (push) Has been cancelled
standards workspaces build + test / eoc-rs (push) Has been cancelled
standards workspaces build + test / jcp-rs (push) Has been cancelled
standards workspaces build + test / joule-code-rs (push) Has been cancelled
standards workspaces build + test / joule-ui-rs (push) Has been cancelled
standards workspaces build + test / joulecontract-rs (push) Has been cancelled
standards workspaces build + test / jouledesk-rs (push) Has been cancelled
standards workspaces build + test / joulehook-rs (push) Has been cancelled
standards workspaces build + test / map-rs (push) Has been cancelled
standards workspaces build + test / mesh-rs (push) Has been cancelled
standards workspaces build + test / openpay (push) Has been cancelled
standards workspaces build + test / proof-rs (push) Has been cancelled
standards workspaces build + test / sandbox-rs (push) Has been cancelled
standards workspaces build + test / sense-rs (push) Has been cancelled
standards workspaces build + test / smart-byte-rs (push) Has been cancelled
standards workspaces build + test / macOS energy backend (jcp-energy) (push) Has been cancelled
no-vanity-metrics / Scan committed Markdown for vanity metrics (push) Has been cancelled
map: an unbounded band width reads back from JSON, so the published tileset round-trips
AltSchedule::default() — the surface-only schedule, the common case — has width_m
= +inf. JSON has no infinity: serde_json wrote null and f64 refused it coming back,
so tileset_vectors.json could not be read into the reference types at all
("invalid type: null, expected f64") — the first thing a third party certifying
against the pack would try.

width_m now has one JSON form: +inf <-> null, in human-readable formats only. The
canonical CBOR every hash and signature covers still carries the float, so no
content id or signature moves and the published pack is byte-identical. NaN and
-inf, which mean nothing and have no JSON form, are refused rather than written
as a null that would read back as +inf. SPEC §2 states the rule.

The pack test that pinned the defect now asserts the round trip: the published
manifest reads back, verifies, re-serializes to the pack, equals the rebuilt
manifest, and the revision chains onto it. Falsified: without the adapter the
original error returns; writing null into CBOR too breaks the canonical-form test
and the published signatures.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 14:33:21 -04:00

722 lines
28 KiB
Rust

//! Map schema — the on-the-wire types of the Map open standard.
//!
//! This crate is the genuinely-new primitive of the standard: the **4D cell
//! key** that addresses a region of the world by position, altitude, *time*,
//! and detail (SPEC §2); the deterministic **content addressing** every other
//! structure cross-references through (SPEC §3); the **tile** cargo (SPEC §1.1);
//! the range-requestable **tileset directory** (SPEC §4); and the signed
//! **manifest + revision chain** that makes a tileset a file that works forever
//! (SPEC §5).
//!
//! Everything that is hashed or signed is serialized through [`canonical_cbor`]
//! — the family's deterministic CBOR profile — so a [`ContentId`] is a stable
//! join key across implementations.
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
// ─────────────────────────────────────────────────────────────────────────
// Errors
// ─────────────────────────────────────────────────────────────────────────
#[derive(Debug, thiserror::Error)]
pub enum SchemaError {
#[error("cbor serialization failed: {0}")]
Cbor(String),
#[error("directory is not sorted by S2 (Hilbert) key")]
Unsorted,
#[error("signature verification failed")]
BadSignature,
#[error("revision does not chain onto the expected root")]
BrokenChain,
#[error("content hash mismatch: payload does not match directory")]
HashMismatch,
}
// ─────────────────────────────────────────────────────────────────────────
// Canonical serialization + content addressing (SPEC §3)
// ─────────────────────────────────────────────────────────────────────────
/// Serialize a value with the family deterministic CBOR profile
/// (definite-length, fields in declaration order). The input to every hash and
/// signature in the standard.
pub fn canonical_cbor<T: Serialize>(value: &T) -> Result<Vec<u8>, SchemaError> {
let mut buf = Vec::new();
ciborium::into_writer(value, &mut buf).map_err(|e| SchemaError::Cbor(e.to_string()))?;
Ok(buf)
}
/// A 32-byte BLAKE3 content address. The only cross-reference Map uses
/// internally (SPEC §3). Hex in human-readable formats, raw bytes otherwise.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ContentId(pub [u8; 32]);
impl ContentId {
/// `blake3(canonical_cbor(value))` — the content address of any serializable.
pub fn of<T: Serialize>(value: &T) -> Result<Self, SchemaError> {
Ok(Self::of_bytes(&canonical_cbor(value)?))
}
/// `blake3(bytes)` — the content address of a raw payload.
pub fn of_bytes(bytes: &[u8]) -> Self {
ContentId(*blake3::hash(bytes).as_bytes())
}
pub fn to_hex(self) -> String {
hex::encode(self.0)
}
}
impl std::fmt::Debug for ContentId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "ContentId({})", &self.to_hex()[..16])
}
}
impl Serialize for ContentId {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
if s.is_human_readable() {
s.serialize_str(&self.to_hex())
} else {
s.serialize_bytes(&self.0)
}
}
}
impl<'de> Deserialize<'de> for ContentId {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
use serde::de::Error;
if d.is_human_readable() {
let h = String::deserialize(d)?;
let v = hex::decode(&h).map_err(Error::custom)?;
let arr: [u8; 32] = v.try_into().map_err(|_| Error::custom("expected 32 bytes"))?;
Ok(ContentId(arr))
} else {
let v = <Vec<u8>>::deserialize(d)?;
let arr: [u8; 32] = v.try_into().map_err(|_| Error::custom("expected 32 bytes"))?;
Ok(ContentId(arr))
}
}
}
// ─────────────────────────────────────────────────────────────────────────
// The 4D cell key (SPEC §2)
// ─────────────────────────────────────────────────────────────────────────
/// Horizontal position + extent: an S2 cell id. S2 is a hierarchical,
/// Hilbert-ordered decomposition of the sphere, so adjacent cells are adjacent
/// in the integer key space — the property that makes a tileset
/// range-requestable (SPEC §4). The full S2 geometry lives in `map-index`; the
/// schema treats the id as an opaque, orderable token.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct S2CellId(pub u64);
impl S2CellId {
/// The S2 level (0..=30) is the horizontal LOD, recovered from the position
/// of the lowest set bit of the trailing "1" sentinel. Returns `None` for
/// the invalid all-zero id.
pub fn level(self) -> Option<u8> {
if self.0 == 0 {
return None;
}
let tz = self.0.trailing_zeros();
Some((30u32.saturating_sub(tz / 2)) as u8)
}
}
/// Signed altitude band index against a declared [`AltSchedule`]. Band 0 is the
/// datum surface; negative bands are sub-surface (bathymetry); positive bands
/// are altitude (atmosphere, orbit).
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct AltBand(pub i16);
/// The band schedule a tileset declares in its manifest: each band is
/// `width_m` metres thick, measured from `base_m` above the datum.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct AltSchedule {
pub base_m: f64,
/// Band thickness in metres, or `+∞` for a single unbounded surface band. JSON has
/// no infinity, so a human-readable form writes `+∞` as `null` and reads `null`
/// back as `+∞`; the canonical CBOR every hash and signature covers carries the
/// float itself (SPEC §2).
#[serde(with = "unbounded_width")]
pub width_m: f64,
}
/// `AltSchedule::width_m` in human-readable formats: `+∞` ⇄ `null`. Binary formats —
/// the canonical CBOR — carry the `f64` unchanged, so no content id or signature
/// moves. A width that is NaN or `−∞` means nothing and has no JSON form, so it is
/// refused rather than written as a `null` that would read back as `+∞`.
mod unbounded_width {
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(w: &f64, s: S) -> Result<S::Ok, S::Error> {
if s.is_human_readable() && !w.is_finite() {
if *w == f64::INFINITY {
return s.serialize_none();
}
return Err(serde::ser::Error::custom("width_m must be finite or +infinity"));
}
s.serialize_f64(*w)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
if d.is_human_readable() {
Ok(Option::<f64>::deserialize(d)?.unwrap_or(f64::INFINITY))
} else {
f64::deserialize(d)
}
}
}
impl Default for AltSchedule {
/// A single surface band of effectively unbounded thickness — the schedule
/// a 2.5D (surface-only) tileset uses.
fn default() -> Self {
AltSchedule { base_m: 0.0, width_m: f64::INFINITY }
}
}
/// A half-open time interval anchored to TAI, in milliseconds. Bucket 0
/// (`start_tai_ms == 0 && width_ms == 0`) is reserved for **atemporal** tiles
/// (a coastline, a border). The temporal axis is first-class (SPEC §2).
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct TimeBucket {
pub start_tai_ms: i64,
/// Bucket width in milliseconds. `u64` so coarse archival buckets (a year, a
/// century) are representable; `u32` would cap the temporal axis at ~49 days.
pub width_ms: u64,
}
impl TimeBucket {
pub const ATEMPORAL: TimeBucket = TimeBucket { start_tai_ms: 0, width_ms: 0 };
pub fn is_atemporal(self) -> bool {
self == Self::ATEMPORAL
}
pub fn contains(self, tai_ms: i64) -> bool {
!self.is_atemporal()
&& tai_ms >= self.start_tai_ms
&& tai_ms < self.start_tai_ms + self.width_ms as i64
}
}
/// Within-tier detail level, independent of the S2 (horizontal) level.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct Lod(pub u8);
/// The 4D address of a tile (SPEC §2). Two keys are equal iff all four axes
/// match. The field/ordering here defines the canonical sort that the tileset
/// directory MUST follow.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct CellKey {
pub s2: S2CellId,
pub alt: AltBand,
pub time: TimeBucket,
pub lod: Lod,
}
// ─────────────────────────────────────────────────────────────────────────
// Tier (shared with the cascade + receipt) (SPEC §1.2)
// ─────────────────────────────────────────────────────────────────────────
/// The energy-ordered fidelity tier that a payload belongs to / a render
/// request closed at. Wire tags are `F0`..`F4`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum Tier {
/// F0 — content-addressed cache hit (picojoules).
Cache,
/// F1 — terrain DEM + coastlines + borders; the universal floor (microjoules).
Base,
/// F2 — raster + scalar/vector field tiles (sub-millijoules).
Field,
/// F3 — vector features + live entities (millijoules).
Feature,
/// F4 — 3D Gaussian splats; the escape hatch (joules).
Photoreal,
}
impl Tier {
pub const ALL: [Tier; 5] = [Tier::Cache, Tier::Base, Tier::Field, Tier::Feature, Tier::Photoreal];
pub fn wire_tag(self) -> &'static str {
match self {
Tier::Cache => "F0",
Tier::Base => "F1",
Tier::Field => "F2",
Tier::Feature => "F3",
Tier::Photoreal => "F4",
}
}
/// Rank in the energy ordering; higher = more joules.
pub fn rank(self) -> u8 {
match self {
Tier::Cache => 0,
Tier::Base => 1,
Tier::Field => 2,
Tier::Feature => 3,
Tier::Photoreal => 4,
}
}
}
// ─────────────────────────────────────────────────────────────────────────
// The tile cargo (SPEC §1.1)
// ─────────────────────────────────────────────────────────────────────────
/// A content-addressed unit of place. The `data` is the opaque encoded cargo
/// (terrain/field/feature/splat bytes — the codec is `map-tiles`' concern); the
/// `tier` names which fidelity it serves; `codec` names how to decode it.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Tile {
pub key: CellKey,
pub tier: Tier,
pub codec: String,
#[serde(with = "serde_bytes_compat")]
pub data: Vec<u8>,
}
impl Tile {
pub fn content_id(&self) -> Result<ContentId, SchemaError> {
ContentId::of(self)
}
}
/// `Vec<u8>` that serializes as a CBOR byte string (not an array of ints) while
/// staying hex in human-readable formats.
mod serde_bytes_compat {
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(v: &[u8], s: S) -> Result<S::Ok, S::Error> {
if s.is_human_readable() {
s.serialize_str(&hex::encode(v))
} else {
s.serialize_bytes(v)
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> {
use serde::de::Error;
if d.is_human_readable() {
let h = String::deserialize(d)?;
hex::decode(&h).map_err(Error::custom)
} else {
<Vec<u8>>::deserialize(d)
}
}
}
// ─────────────────────────────────────────────────────────────────────────
// The tileset directory (SPEC §4)
// ─────────────────────────────────────────────────────────────────────────
/// One row of the tileset directory: a cell key, where its payload lives in the
/// blob, and what it hashes to. The Merkle leaf commits to `(key, content_id)`
/// only — *not* the byte offset — so re-laying-out the file does not change the
/// manifest root.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct DirEntry {
pub key: CellKey,
pub offset: u64,
pub len: u64,
pub content_id: ContentId,
}
impl DirEntry {
fn leaf_hash(&self) -> Result<ContentId, SchemaError> {
ContentId::of(&(self.key, self.content_id))
}
}
/// The range-requestable directory of a tileset (SPEC §4). MUST be sorted by
/// the canonical [`CellKey`] order (S2/Hilbert first) so a viewport maps to a
/// near-contiguous byte range.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Directory {
pub entries: Vec<DirEntry>,
}
impl Directory {
pub fn new(mut entries: Vec<DirEntry>) -> Self {
entries.sort_by_key(|e| e.key);
Directory { entries }
}
pub fn is_sorted(&self) -> bool {
self.entries.windows(2).all(|w| w[0].key <= w[1].key)
}
/// Look up the storage location of a cell.
pub fn locate(&self, key: &CellKey) -> Option<&DirEntry> {
self.entries
.binary_search_by(|e| e.key.cmp(key))
.ok()
.map(|i| &self.entries[i])
}
/// The byte ranges to fetch for a set of viewport cells, coalesced into
/// contiguous spans. This is the range-request planner a reader uses.
pub fn ranges_for(&self, keys: &[CellKey]) -> Vec<(u64, u64)> {
let mut spans: Vec<(u64, u64)> = keys
.iter()
.filter_map(|k| self.locate(k))
.map(|e| (e.offset, e.offset + e.len))
.collect();
spans.sort_unstable();
let mut out: Vec<(u64, u64)> = Vec::new();
for (start, end) in spans {
match out.last_mut() {
Some(last) if start <= last.1 => last.1 = last.1.max(end),
_ => out.push((start, end)),
}
}
out
}
/// The BLAKE3 Merkle root over the sorted `(key, content_id)` leaves. The
/// value a manifest commits to (SPEC §5). MUST be called on a sorted
/// directory.
pub fn root(&self) -> Result<ContentId, SchemaError> {
if !self.is_sorted() {
return Err(SchemaError::Unsorted);
}
let leaves: Vec<ContentId> =
self.entries.iter().map(DirEntry::leaf_hash).collect::<Result<_, _>>()?;
Ok(merkle_root(&leaves))
}
}
/// Domain-separated binary Merkle root. Empty set hashes the empty leaf.
fn merkle_root(leaves: &[ContentId]) -> ContentId {
if leaves.is_empty() {
return ContentId::of_bytes(b"map:empty-directory");
}
let mut level: Vec<ContentId> = leaves.to_vec();
while level.len() > 1 {
let mut next = Vec::with_capacity(level.len().div_ceil(2));
for pair in level.chunks(2) {
let mut h = blake3::Hasher::new();
h.update(b"map:node");
h.update(&pair[0].0);
// Odd node is duplicated (standard binary-Merkle convention).
h.update(&pair.get(1).unwrap_or(&pair[0]).0);
next.push(ContentId(*h.finalize().as_bytes()));
}
level = next;
}
level[0]
}
// ─────────────────────────────────────────────────────────────────────────
// Manifest + revision chain (SPEC §5)
// ─────────────────────────────────────────────────────────────────────────
/// The signed root of a tileset. Binds the directory root to a publisher key,
/// the standard version, the datum/alt schedule, and the cell-key bounds.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ManifestBody {
pub spec_version: String,
pub datum: String,
pub alt_schedule: AltSchedule,
pub dir_root: ContentId,
pub bounds: Option<(CellKey, CellKey)>,
/// Required, non-empty, for any air-navigation tileset (SPEC §9).
pub disclaimer: Option<String>,
#[serde(with = "verifying_key_hex")]
pub publisher: VerifyingKey,
}
/// A manifest body plus the publisher's Ed25519 signature over its canonical
/// CBOR.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Manifest {
pub body: ManifestBody,
#[serde(with = "signature_hex")]
pub signature: Signature,
}
impl ManifestBody {
pub fn sign(self, key: &SigningKey) -> Result<Manifest, SchemaError> {
let msg = canonical_cbor(&self)?;
let signature = key.sign(&msg);
Ok(Manifest { body: self, signature })
}
}
impl Manifest {
/// The content id of the manifest body — the "root" a revision chains onto.
pub fn root(&self) -> Result<ContentId, SchemaError> {
ContentId::of(&self.body)
}
pub fn verify(&self) -> Result<(), SchemaError> {
let msg = canonical_cbor(&self.body)?;
self.body
.publisher
.verify(&msg, &self.signature)
.map_err(|_| SchemaError::BadSignature)
}
}
/// A signed, dated transition `prev_root → new_root` (SPEC §5). Revisions are
/// append-only; a new forecast hour or AIRAC cycle is a revision, never an
/// in-place edit.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RevisionBody {
pub prev_root: ContentId,
pub new_root: ContentId,
pub timestamp_tai_ms: i64,
#[serde(with = "verifying_key_hex")]
pub publisher: VerifyingKey,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Revision {
pub body: RevisionBody,
#[serde(with = "signature_hex")]
pub signature: Signature,
}
impl RevisionBody {
pub fn sign(self, key: &SigningKey) -> Result<Revision, SchemaError> {
let msg = canonical_cbor(&self)?;
let signature = key.sign(&msg);
Ok(Revision { body: self, signature })
}
}
impl Revision {
pub fn verify(&self) -> Result<(), SchemaError> {
let msg = canonical_cbor(&self.body)?;
self.body
.publisher
.verify(&msg, &self.signature)
.map_err(|_| SchemaError::BadSignature)
}
/// Verify the signature *and* that it chains onto `expected_prev`.
pub fn verify_onto(&self, expected_prev: &ContentId) -> Result<(), SchemaError> {
self.verify()?;
if &self.body.prev_root != expected_prev {
return Err(SchemaError::BrokenChain);
}
Ok(())
}
}
/// Walk a revision chain from a trusted root, returning the head root. Each
/// revision MUST verify and MUST chain onto the prior root (SPEC §5).
pub fn verify_chain(trusted_root: ContentId, chain: &[Revision]) -> Result<ContentId, SchemaError> {
let mut head = trusted_root;
for rev in chain {
rev.verify_onto(&head)?;
head = rev.body.new_root;
}
Ok(head)
}
// ─── Ed25519 serde helpers (hex strings, both formats) ──────────────────────
mod verifying_key_hex {
use ed25519_dalek::VerifyingKey;
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(k: &VerifyingKey, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&hex::encode(k.to_bytes()))
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<VerifyingKey, D::Error> {
use serde::de::Error;
let h = String::deserialize(d)?;
let v = hex::decode(&h).map_err(Error::custom)?;
let arr: [u8; 32] = v.try_into().map_err(|_| Error::custom("expected 32-byte key"))?;
VerifyingKey::from_bytes(&arr).map_err(Error::custom)
}
}
mod signature_hex {
use ed25519_dalek::Signature;
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(sig: &Signature, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&hex::encode(sig.to_bytes()))
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Signature, D::Error> {
use serde::de::Error;
let h = String::deserialize(d)?;
let v = hex::decode(&h).map_err(Error::custom)?;
let arr: [u8; 64] = v.try_into().map_err(|_| Error::custom("expected 64-byte sig"))?;
Ok(Signature::from_bytes(&arr))
}
}
// ─────────────────────────────────────────────────────────────────────────
// Tests
// ─────────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::SigningKey;
fn key() -> SigningKey {
// Deterministic test key (NOT for production).
SigningKey::from_bytes(&[7u8; 32])
}
fn sample_key(s2: u64, lod: u8) -> CellKey {
CellKey {
s2: S2CellId(s2),
alt: AltBand(0),
time: TimeBucket::ATEMPORAL,
lod: Lod(lod),
}
}
#[test]
fn content_id_is_deterministic() {
let k = sample_key(0x1234_0000_0000_0001, 3);
assert_eq!(ContentId::of(&k).unwrap(), ContentId::of(&k).unwrap());
}
#[test]
fn tile_content_id_changes_with_data() {
let mk = |d: &[u8]| Tile {
key: sample_key(1, 0),
tier: Tier::Base,
codec: "terrain-quantized-mesh".into(),
data: d.to_vec(),
};
assert_ne!(
mk(b"alpha").content_id().unwrap(),
mk(b"beta").content_id().unwrap()
);
}
#[test]
fn directory_sorts_and_coalesces_ranges() {
let e = |s2, off| DirEntry {
key: sample_key(s2, 0),
offset: off,
len: 100,
content_id: ContentId::of_bytes(&[s2 as u8]),
};
// inserted out of order
let dir = Directory::new(vec![e(3, 200), e(1, 0), e(2, 100)]);
assert!(dir.is_sorted());
// cells 1 and 2 are contiguous on disk -> one span; cell 3 also contiguous
let ranges = dir.ranges_for(&[sample_key(1, 0), sample_key(2, 0)]);
assert_eq!(ranges, vec![(0, 200)]);
}
#[test]
fn directory_root_ignores_offsets() {
let mk = |off| {
Directory::new(vec![DirEntry {
key: sample_key(1, 0),
offset: off,
len: 10,
content_id: ContentId::of_bytes(b"x"),
}])
};
assert_eq!(mk(0).root().unwrap(), mk(999).root().unwrap());
}
#[test]
fn manifest_signs_and_verifies() {
let sk = key();
let dir = Directory::new(vec![DirEntry {
key: sample_key(1, 0),
offset: 0,
len: 10,
content_id: ContentId::of_bytes(b"x"),
}]);
let body = ManifestBody {
spec_version: "0.1.0".into(),
datum: "WGS84".into(),
alt_schedule: AltSchedule::default(),
dir_root: dir.root().unwrap(),
bounds: None,
disclaimer: None,
publisher: sk.verifying_key(),
};
let m = body.sign(&sk).unwrap();
assert!(m.verify().is_ok());
}
#[test]
fn tampered_manifest_fails() {
let sk = key();
let body = ManifestBody {
spec_version: "0.1.0".into(),
datum: "WGS84".into(),
alt_schedule: AltSchedule::default(),
dir_root: ContentId::of_bytes(b"root"),
bounds: None,
disclaimer: None,
publisher: sk.verifying_key(),
};
let mut m = body.sign(&sk).unwrap();
m.body.dir_root = ContentId::of_bytes(b"tampered");
assert!(matches!(m.verify(), Err(SchemaError::BadSignature)));
}
#[test]
fn revision_chain_verifies_and_detects_break() {
let sk = key();
let r0 = ContentId::of_bytes(b"root-0");
let r1 = ContentId::of_bytes(b"root-1");
let r2 = ContentId::of_bytes(b"root-2");
let rev1 = RevisionBody { prev_root: r0, new_root: r1, timestamp_tai_ms: 1, publisher: sk.verifying_key() }
.sign(&sk)
.unwrap();
let rev2 = RevisionBody { prev_root: r1, new_root: r2, timestamp_tai_ms: 2, publisher: sk.verifying_key() }
.sign(&sk)
.unwrap();
assert_eq!(verify_chain(r0, &[rev1.clone(), rev2.clone()]).unwrap(), r2);
// a chain that does not start at r0 must break
assert!(matches!(
verify_chain(ContentId::of_bytes(b"other"), &[rev1, rev2]),
Err(SchemaError::BrokenChain)
));
}
#[test]
fn s2_level_extraction() {
// level-30 leaf cell: sentinel bit at position 0
assert_eq!(S2CellId(0b1).level(), Some(30));
assert_eq!(S2CellId(0).level(), None);
}
#[test]
fn time_bucket_membership() {
let b = TimeBucket { start_tai_ms: 1000, width_ms: 3_600_000 };
assert!(b.contains(1000));
assert!(!b.contains(999));
assert!(!TimeBucket::ATEMPORAL.contains(0));
}
#[test]
fn an_unbounded_band_width_round_trips_through_json_as_null() {
let unbounded = AltSchedule::default();
let json = serde_json::to_value(unbounded).unwrap();
assert!(json["width_m"].is_null(), "+inf has no JSON number, so it is written as null");
let back: AltSchedule = serde_json::from_value(json).unwrap();
assert_eq!(back.width_m, f64::INFINITY, "null must read back as an unbounded band");
assert_eq!(back, unbounded);
// A finite width is an ordinary number both ways.
let banded = AltSchedule { base_m: -100.0, width_m: 50.0 };
let json = serde_json::to_value(banded).unwrap();
assert_eq!(json["width_m"], serde_json::json!(50.0));
assert_eq!(serde_json::from_value::<AltSchedule>(json).unwrap(), banded);
}
#[test]
fn a_meaningless_band_width_is_refused_not_written_as_null() {
// NaN or -inf would otherwise become a null that reads back as +inf.
for w in [f64::NAN, f64::NEG_INFINITY] {
let s = AltSchedule { base_m: 0.0, width_m: w };
assert!(serde_json::to_value(s).is_err(), "width {w} must not serialize to JSON");
}
}
#[test]
fn the_canonical_form_still_carries_the_float() {
// CBOR is the signing form: +inf stays +inf there, so no signature moves.
let bytes = canonical_cbor(&AltSchedule::default()).unwrap();
let back: AltSchedule = ciborium::from_reader(bytes.as_slice()).unwrap();
assert_eq!(back.width_m, f64::INFINITY);
assert!(!bytes.contains(&0xf6), "CBOR null must not stand in for the float");
}
}