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
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]>
722 lines
28 KiB
Rust
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");
|
|
}
|
|
}
|