//! 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(value: &T) -> Result, 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(value: &T) -> Result { 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(&self, s: S) -> Result { 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: D) -> Result { 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 = >::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 { 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(w: &f64, s: S) -> Result { 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 { if d.is_human_readable() { Ok(Option::::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, } impl Tile { pub fn content_id(&self) -> Result { ContentId::of(self) } } /// `Vec` 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(v: &[u8], s: S) -> Result { 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, D::Error> { use serde::de::Error; if d.is_human_readable() { let h = String::deserialize(d)?; hex::decode(&h).map_err(Error::custom) } else { >::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::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, } impl Directory { pub fn new(mut entries: Vec) -> 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 { if !self.is_sorted() { return Err(SchemaError::Unsorted); } let leaves: Vec = self.entries.iter().map(DirEntry::leaf_hash).collect::>()?; 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 = 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, #[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 { 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::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 { 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 { 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(k: &VerifyingKey, s: S) -> Result { s.serialize_str(&hex::encode(k.to_bytes())) } pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result { 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(sig: &Signature, s: S) -> Result { s.serialize_str(&hex::encode(sig.to_bytes())) } pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result { 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::(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"); } }