Commit the previously-untracked standard directories and catalog them in the
root README alongside proof:
- aac/ — communication cargo class: portable boards + a ten-level
offline-first cascade (Apache-2.0 code / CC-BY-4.0 spec).
- map/ — geospatial cargo class: range-requestable signed tilesets +
an F0–F4 fidelity cascade (Apache-2.0 code / CC-BY-4.0 spec).
- sandbox/— isolated, metered, attested execution across the heterogeneous
compute fabric (Apache-2.0 code / CC-BY-4.0 spec).
- jouleclaw-otel-authz, jouleclaw-trust — new jouleclaw reference crates.
Root README now lists aac, map, sandbox, and proof in the catalog, the tree,
and the contribution guide.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
347 lines
13 KiB
Rust
347 lines
13 KiB
Rust
//! `map-serve` — a **transport-agnostic** tile-serving core for the Map standard.
|
||
//!
|
||
//! This is the protocol logic of a tile server with **no HTTP framework
|
||
//! dependency**: it parses a tile request, resolves it through the F0–F4
|
||
//! [`map_cascade`] against a caller-supplied store, and builds a content-
|
||
//! addressed [`Response`] carrying the tile bytes, a strong `ETag`, the closing
|
||
//! tier, and a **JCR-1 energy receipt** ([`map_receipt`]). Conditional requests
|
||
//! (`If-None-Match`) short-circuit to `304`.
|
||
//!
|
||
//! Binding it to a real socket is a thin wrapper the caller writes with whatever
|
||
//! transport they like (axum, hyper, a CGI shim, a service worker): construct an
|
||
//! [`HttpReq`] from the incoming request, call [`serve`], and map the returned
|
||
//! [`Response`] back. Everything here is pure and unit-testable without a network.
|
||
//!
|
||
//! ```
|
||
//! use map_serve::{serve, HttpReq, MemoryStore, ServedTile};
|
||
//! use map_schema::{Tier, AltBand, TimeBucket, Lod};
|
||
//! use map_index::cell_key;
|
||
//!
|
||
//! let mut store = MemoryStore::default();
|
||
//! let cell = cell_key(37.0, -122.0, 14, AltBand(0), TimeBucket::ATEMPORAL, Lod(0));
|
||
//! store.insert(&cell, Tier::Base, b"terrain-bytes".to_vec(), "application/octet-stream");
|
||
//!
|
||
//! let req = HttpReq { method: "GET", path: "/tiles/auto/37.0/-122.0/14",
|
||
//! if_none_match: None, sequence: 1, nonce: [0; 8] };
|
||
//! let res = serve(&store, "transaction.science", &req);
|
||
//! assert_eq!(res.status, 200);
|
||
//! assert_eq!(res.header("X-Map-Tier"), Some("F1"));
|
||
//! ```
|
||
|
||
pub mod dir;
|
||
pub use dir::DirStore;
|
||
|
||
use map_cascade::resolve;
|
||
pub use map_cascade::TileSource;
|
||
use map_index::cell_key;
|
||
use map_receipt::{Energy, Receipt};
|
||
use map_schema::{AltBand, CellKey, ContentId, Lod, Tier, TimeBucket};
|
||
|
||
/// Default S2 level when a request omits one (city-block scale).
|
||
pub const DEFAULT_LEVEL: u8 = 14;
|
||
|
||
/// Encoded tile bytes plus the MIME type a transport should send.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct ServedTile {
|
||
pub bytes: Vec<u8>,
|
||
pub content_type: String,
|
||
}
|
||
|
||
/// A tile store the server can both **resolve against** (via [`TileSource`]) and
|
||
/// **read bytes from**. A real implementation backs this with a tileset
|
||
/// directory, object storage, or a live feed.
|
||
pub trait TileServer: TileSource {
|
||
/// Fetch the encoded payload for a resolved content id.
|
||
fn fetch(&self, id: &ContentId) -> Option<ServedTile>;
|
||
}
|
||
|
||
/// A parsed tile request: a cell on the datum and a fidelity ceiling.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub struct TileRequest {
|
||
pub lat: f64,
|
||
pub lon: f64,
|
||
pub level: u8,
|
||
pub tier: Tier,
|
||
}
|
||
|
||
/// A transport-neutral view of an incoming request — fill it from your framework.
|
||
#[derive(Clone, Copy, Debug)]
|
||
pub struct HttpReq<'a> {
|
||
pub method: &'a str,
|
||
pub path: &'a str,
|
||
/// The `If-None-Match` header value, if any (quotes optional).
|
||
pub if_none_match: Option<&'a str>,
|
||
/// Monotonic receipt sequence for this connection/steward.
|
||
pub sequence: u64,
|
||
/// Per-request entropy for the receipt nonce (the transport's to supply).
|
||
pub nonce: [u8; 8],
|
||
}
|
||
|
||
/// A transport-neutral response. Map `status`/`headers`/`body` onto your framework.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct Response {
|
||
pub status: u16,
|
||
pub headers: Vec<(String, String)>,
|
||
pub body: Vec<u8>,
|
||
}
|
||
|
||
impl Response {
|
||
fn new(status: u16) -> Self {
|
||
Response { status, headers: Vec::new(), body: Vec::new() }
|
||
}
|
||
fn with(mut self, k: &str, v: impl Into<String>) -> Self {
|
||
self.headers.push((k.to_string(), v.into()));
|
||
self
|
||
}
|
||
/// Case-insensitive header lookup (convenience for callers and tests).
|
||
pub fn header(&self, name: &str) -> Option<&str> {
|
||
self.headers
|
||
.iter()
|
||
.find(|(k, _)| k.eq_ignore_ascii_case(name))
|
||
.map(|(_, v)| v.as_str())
|
||
}
|
||
}
|
||
|
||
/// Parse a fidelity token: `f0`..`f4` (or their names), or `auto` for the full
|
||
/// cascade (a `Photoreal` ceiling — resolution falls back to whatever exists).
|
||
pub fn parse_tier(s: &str) -> Option<Tier> {
|
||
match s.to_ascii_lowercase().as_str() {
|
||
"auto" | "f4" | "photoreal" => Some(Tier::Photoreal),
|
||
"f3" | "feature" => Some(Tier::Feature),
|
||
"f2" | "field" => Some(Tier::Field),
|
||
"f1" | "base" => Some(Tier::Base),
|
||
"f0" | "cache" => Some(Tier::Cache),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Parse `\[/\]tiles/{tier}/{lat}/{lon}\[/{level}\]` (query string ignored).
|
||
pub fn parse_path(path: &str) -> Option<TileRequest> {
|
||
let path = path.split('?').next().unwrap_or(path);
|
||
let mut seg = path.split('/').filter(|s| !s.is_empty());
|
||
if seg.next()? != "tiles" {
|
||
return None;
|
||
}
|
||
let tier = parse_tier(seg.next()?)?;
|
||
let lat: f64 = seg.next()?.parse().ok()?;
|
||
let lon: f64 = seg.next()?.parse().ok()?;
|
||
if !(-90.0..=90.0).contains(&lat) || !(-180.0..=180.0).contains(&lon) {
|
||
return None;
|
||
}
|
||
let level = match seg.next() {
|
||
Some(l) => l.parse().ok().filter(|&v| v <= 30)?,
|
||
None => DEFAULT_LEVEL,
|
||
};
|
||
Some(TileRequest { lat, lon, level, tier })
|
||
}
|
||
|
||
fn etag_matches(if_none_match: &str, etag: &str) -> bool {
|
||
let strip = |s: &str| s.trim().trim_start_matches("W/").trim_matches('"').to_string();
|
||
if if_none_match.trim() == "*" {
|
||
return true;
|
||
}
|
||
let target = strip(etag);
|
||
if_none_match.split(',').any(|c| strip(c) == target)
|
||
}
|
||
|
||
/// Handle one tile request: parse → cascade-resolve → content-addressed response
|
||
/// with a JCR-1 energy receipt. Pure; no I/O beyond the supplied store.
|
||
///
|
||
/// Status codes: `200` served, `304` not modified, `400` unparseable path,
|
||
/// `404` no tile (uncovered region or resolved id missing from the store),
|
||
/// `405` method not `GET`/`HEAD`.
|
||
pub fn serve<S: TileServer>(source: &S, steward: &str, req: &HttpReq) -> Response {
|
||
let head = req.method.eq_ignore_ascii_case("HEAD");
|
||
if !head && !req.method.eq_ignore_ascii_case("GET") {
|
||
return Response::new(405).with("Allow", "GET, HEAD");
|
||
}
|
||
let Some(tr) = parse_path(req.path) else {
|
||
return Response::new(400);
|
||
};
|
||
let cell = cell_key(tr.lat, tr.lon, tr.level, AltBand(0), TimeBucket::ATEMPORAL, Lod(0));
|
||
let res = match resolve(source, &cell, tr.tier) {
|
||
Ok(r) => r,
|
||
Err(_) => return Response::new(404),
|
||
};
|
||
|
||
let etag = format!("\"{}\"", res.content_id.to_hex());
|
||
if let Some(inm) = req.if_none_match {
|
||
if etag_matches(inm, &etag) {
|
||
return Response::new(304)
|
||
.with("ETag", etag)
|
||
.with("Cache-Control", "public, max-age=31536000, immutable");
|
||
}
|
||
}
|
||
|
||
let Some(served) = source.fetch(&res.content_id) else {
|
||
return Response::new(404);
|
||
};
|
||
|
||
let energy = Energy::estimate_tier(res.tier);
|
||
let receipt = Receipt {
|
||
cell,
|
||
content_id: res.content_id,
|
||
tier: res.tier,
|
||
energy,
|
||
sequence: req.sequence,
|
||
nonce: req.nonce,
|
||
};
|
||
|
||
let mut out = Response::new(200)
|
||
.with("Content-Type", served.content_type.clone())
|
||
.with("Content-Length", served.bytes.len().to_string())
|
||
.with("ETag", etag)
|
||
// Content-addressed payloads are immutable: cache forever.
|
||
.with("Cache-Control", "public, max-age=31536000, immutable")
|
||
.with("X-Map-Tier", res.tier.wire_tag())
|
||
.with("X-Map-Fell-Back", if res.fell_back { "1" } else { "0" })
|
||
.with("X-Map-Energy-Microjoules", energy.estimated_uj.to_string());
|
||
|
||
// The JCR-1 receipt as a compact single-line header (full bytes are the
|
||
// transport's to seal/sign with the steward key; this is the unsigned core).
|
||
if let Ok(wire) = receipt.to_jcr1_wire(steward) {
|
||
if let Ok(json) = serde_json::to_string(&wire) {
|
||
out = out.with("X-Map-Receipt", json);
|
||
}
|
||
}
|
||
|
||
out.body = if head { Vec::new() } else { served.bytes };
|
||
out
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
// A reference in-memory store — usable on its own and the basis for tests.
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
|
||
use std::collections::HashMap;
|
||
|
||
/// An in-memory [`TileServer`]: tiles keyed by `(s2 cell, tier)`, bytes keyed by
|
||
/// content id. The F0 cache short-circuit is a client concern, so [`cached`]
|
||
/// returns `None` here and the cascade resolves F1–F4.
|
||
///
|
||
/// [`cached`]: TileSource::cached
|
||
#[derive(Default)]
|
||
pub struct MemoryStore {
|
||
by_cell: HashMap<(u64, u8), ContentId>,
|
||
by_id: HashMap<[u8; 32], (Vec<u8>, String)>,
|
||
}
|
||
|
||
impl MemoryStore {
|
||
/// Insert a tile for `cell` at `tier`; returns its content id.
|
||
pub fn insert(&mut self, cell: &CellKey, tier: Tier, bytes: Vec<u8>, content_type: &str) -> ContentId {
|
||
let id = ContentId::of_bytes(&bytes);
|
||
self.by_cell.insert((cell.s2.0, tier.rank()), id);
|
||
self.by_id.insert(id.0, (bytes, content_type.to_string()));
|
||
id
|
||
}
|
||
}
|
||
|
||
impl TileSource for MemoryStore {
|
||
fn cached(&self, _cell: &CellKey) -> Option<ContentId> {
|
||
None
|
||
}
|
||
fn tile(&self, cell: &CellKey, tier: Tier) -> Option<ContentId> {
|
||
self.by_cell.get(&(cell.s2.0, tier.rank())).copied()
|
||
}
|
||
}
|
||
|
||
impl TileServer for MemoryStore {
|
||
fn fetch(&self, id: &ContentId) -> Option<ServedTile> {
|
||
self.by_id
|
||
.get(&id.0)
|
||
.map(|(bytes, ct)| ServedTile { bytes: bytes.clone(), content_type: ct.clone() })
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn cell_at(lat: f64, lon: f64) -> CellKey {
|
||
cell_key(lat, lon, DEFAULT_LEVEL, AltBand(0), TimeBucket::ATEMPORAL, Lod(0))
|
||
}
|
||
fn req<'a>(method: &'a str, path: &'a str) -> HttpReq<'a> {
|
||
HttpReq { method, path, if_none_match: None, sequence: 1, nonce: [7; 8] }
|
||
}
|
||
|
||
#[test]
|
||
fn parses_paths_and_rejects_bad_ones() {
|
||
let r = parse_path("/tiles/f2/37.5/-122.25").unwrap();
|
||
assert_eq!(r.tier, Tier::Field);
|
||
assert_eq!(r.level, DEFAULT_LEVEL);
|
||
assert_eq!(parse_path("/tiles/auto/0/0/9").unwrap().tier, Tier::Photoreal);
|
||
assert!(parse_path("/tiles/f2/91.0/0.0").is_none()); // lat out of range
|
||
assert!(parse_path("/tiles/nope/0/0").is_none()); // bad tier
|
||
assert!(parse_path("/health").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn serves_tile_with_receipt_and_etag() {
|
||
let mut store = MemoryStore::default();
|
||
let cell = cell_at(37.0, -122.0);
|
||
let id = store.insert(&cell, Tier::Base, b"terrain".to_vec(), "application/octet-stream");
|
||
|
||
let res = serve(&store, "transaction.science", &req("GET", "/tiles/auto/37.0/-122.0"));
|
||
assert_eq!(res.status, 200);
|
||
assert_eq!(res.body, b"terrain");
|
||
assert_eq!(res.header("X-Map-Tier"), Some("F1"));
|
||
assert_eq!(res.header("X-Map-Fell-Back"), Some("1")); // asked F4, got F1
|
||
assert_eq!(res.header("ETag"), Some(format!("\"{}\"", id.to_hex()).as_str()));
|
||
let receipt = res.header("X-Map-Receipt").unwrap();
|
||
assert!(receipt.contains("\"map_receipt\":\"map/1\""));
|
||
assert!(receipt.contains("\"tier\":\"F1\""));
|
||
assert!(receipt.contains("transaction.science"));
|
||
assert!(res.header("X-Map-Energy-Microjoules").is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn picks_highest_tier_at_or_below_ceiling() {
|
||
let mut store = MemoryStore::default();
|
||
let cell = cell_at(10.0, 20.0);
|
||
store.insert(&cell, Tier::Base, b"f1".to_vec(), "x");
|
||
store.insert(&cell, Tier::Feature, b"f3".to_vec(), "x");
|
||
|
||
// Ceiling F4 → highest present is F3.
|
||
let r = serve(&store, "s", &req("GET", "/tiles/f4/10.0/20.0"));
|
||
assert_eq!(r.header("X-Map-Tier"), Some("F3"));
|
||
assert_eq!(r.body, b"f3");
|
||
// Ceiling F2 → must not return F3; falls to F1.
|
||
let r = serve(&store, "s", &req("GET", "/tiles/f2/10.0/20.0"));
|
||
assert_eq!(r.header("X-Map-Tier"), Some("F1"));
|
||
assert_eq!(r.body, b"f1");
|
||
}
|
||
|
||
#[test]
|
||
fn conditional_request_returns_304() {
|
||
let mut store = MemoryStore::default();
|
||
let cell = cell_at(1.0, 2.0);
|
||
let id = store.insert(&cell, Tier::Base, b"data".to_vec(), "x");
|
||
let etag = format!("\"{}\"", id.to_hex());
|
||
|
||
let mut r = req("GET", "/tiles/auto/1.0/2.0");
|
||
r.if_none_match = Some(&etag);
|
||
let res = serve(&store, "s", &r);
|
||
assert_eq!(res.status, 304);
|
||
assert!(res.body.is_empty());
|
||
assert_eq!(res.header("ETag"), Some(etag.as_str()));
|
||
}
|
||
|
||
#[test]
|
||
fn head_omits_body_keeps_headers() {
|
||
let mut store = MemoryStore::default();
|
||
let cell = cell_at(5.0, 5.0);
|
||
store.insert(&cell, Tier::Base, b"payload".to_vec(), "x");
|
||
let res = serve(&store, "s", &req("HEAD", "/tiles/auto/5.0/5.0"));
|
||
assert_eq!(res.status, 200);
|
||
assert!(res.body.is_empty());
|
||
assert_eq!(res.header("Content-Length"), Some("7"));
|
||
}
|
||
|
||
#[test]
|
||
fn uncovered_region_is_404_and_bad_method_405() {
|
||
let store = MemoryStore::default(); // empty: no base floor anywhere
|
||
assert_eq!(serve(&store, "s", &req("GET", "/tiles/auto/0.0/0.0")).status, 404);
|
||
assert_eq!(serve(&store, "s", &req("POST", "/tiles/auto/0.0/0.0")).status, 405);
|
||
assert_eq!(serve(&store, "s", &req("GET", "/nope")).status, 400);
|
||
}
|
||
}
|