open-standards/map/map-rs/crates/map-serve/src/dir.rs
Transaction Science ba1212361f Add aac, map, sandbox standards; catalog the standards family
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]>
2026-06-07 17:04:36 -04:00

204 lines
6.8 KiB
Rust

//! A filesystem-backed [`TileServer`] — content-addressed blobs on disk plus a
//! small JSON index from `(cell, tier)` to content id.
//!
//! This is the storage half of a real deployment, with no dependency beyond
//! `std` (and `serde_json`, already used for receipts): tiles are written as
//! immutable blobs named by their content id under `blobs/`, and an `index.json`
//! records which blob serves each `(s2 cell, tier)`. [`DirStore::open`] reloads a
//! persisted store, so a server restart keeps serving. Bind it under [`serve`]
//! exactly like [`MemoryStore`].
//!
//! [`serve`]: crate::serve
//! [`MemoryStore`]: crate::MemoryStore
use std::collections::HashMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use map_schema::{CellKey, ContentId, Tier};
use crate::{ServedTile, TileServer, TileSource};
/// A content-addressed tile store rooted at a directory.
pub struct DirStore {
root: PathBuf,
/// `(s2 cell, tier rank) -> (content id, content type)`.
index: HashMap<(u64, u8), (ContentId, String)>,
}
impl DirStore {
/// Open (or create) a store rooted at `root`, loading any existing index.
pub fn open(root: impl AsRef<Path>) -> io::Result<Self> {
let root = root.as_ref().to_path_buf();
fs::create_dir_all(root.join("blobs"))?;
let index = match fs::read(root.join("index.json")) {
Ok(bytes) => parse_index(&bytes),
Err(e) if e.kind() == io::ErrorKind::NotFound => HashMap::new(),
Err(e) => return Err(e),
};
Ok(DirStore { root, index })
}
/// Write a tile for `cell` at `tier`: stores the bytes as a content-addressed
/// blob, updates the index, and persists it. Returns the content id.
pub fn insert(
&mut self,
cell: &CellKey,
tier: Tier,
bytes: &[u8],
content_type: &str,
) -> io::Result<ContentId> {
let id = ContentId::of_bytes(bytes);
fs::write(self.blob_path(&id), bytes)?;
self.index.insert((cell.s2.0, tier.rank()), (id, content_type.to_string()));
self.save_index()?;
Ok(id)
}
fn blob_path(&self, id: &ContentId) -> PathBuf {
self.root.join("blobs").join(id.to_hex())
}
fn save_index(&self) -> io::Result<()> {
let mut entries: Vec<serde_json::Value> = self
.index
.iter()
.map(|(&(s2, rank), (id, ct))| {
serde_json::json!({ "s2": s2, "rank": rank, "id": id.to_hex(), "ct": ct })
})
.collect();
// Stable on-disk order (the map iteration order is otherwise arbitrary).
entries.sort_by_key(|e| (e["s2"].as_u64().unwrap_or(0), e["rank"].as_u64().unwrap_or(0)));
let doc = serde_json::json!({ "tiles": entries });
fs::write(self.root.join("index.json"), serde_json::to_vec(&doc)?)
}
}
fn parse_index(bytes: &[u8]) -> HashMap<(u64, u8), (ContentId, String)> {
let mut out = HashMap::new();
let Ok(doc) = serde_json::from_slice::<serde_json::Value>(bytes) else {
return out;
};
let Some(tiles) = doc["tiles"].as_array() else {
return out;
};
for e in tiles {
let (Some(s2), Some(rank), Some(hex), Some(ct)) = (
e["s2"].as_u64(),
e["rank"].as_u64(),
e["id"].as_str(),
e["ct"].as_str(),
) else {
continue;
};
if let Some(id) = content_id_from_hex(hex) {
out.insert((s2, rank as u8), (id, ct.to_string()));
}
}
out
}
fn content_id_from_hex(hex: &str) -> Option<ContentId> {
if hex.len() != 64 {
return None;
}
let mut bytes = [0u8; 32];
for (i, b) in bytes.iter_mut().enumerate() {
*b = u8::from_str_radix(hex.get(i * 2..i * 2 + 2)?, 16).ok()?;
}
Some(ContentId(bytes))
}
impl TileSource for DirStore {
fn cached(&self, _cell: &CellKey) -> Option<ContentId> {
None
}
fn tile(&self, cell: &CellKey, tier: Tier) -> Option<ContentId> {
self.index.get(&(cell.s2.0, tier.rank())).map(|(id, _)| *id)
}
}
impl TileServer for DirStore {
fn fetch(&self, id: &ContentId) -> Option<ServedTile> {
let bytes = fs::read(self.blob_path(id)).ok()?;
// Recover the content type from the index (small; linear is fine).
let content_type = self
.index
.values()
.find(|(cid, _)| cid == id)
.map(|(_, ct)| ct.clone())
.unwrap_or_else(|| "application/octet-stream".to_string());
Some(ServedTile { bytes, content_type })
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{serve, HttpReq};
use map_index::cell_key;
use map_schema::{AltBand, Lod, TimeBucket};
fn temp_root(name: &str) -> PathBuf {
let p = std::env::temp_dir().join(format!("map-serve-dir-{}-{}", std::process::id(), name));
let _ = fs::remove_dir_all(&p);
p
}
fn cell() -> CellKey {
cell_key(40.0, -74.0, 14, AltBand(0), TimeBucket::ATEMPORAL, Lod(0))
}
#[test]
fn writes_then_serves_from_disk() {
let root = temp_root("serve");
let mut store = DirStore::open(&root).unwrap();
store.insert(&cell(), Tier::Base, b"on-disk-terrain", "model/gltf-binary").unwrap();
let req = HttpReq {
method: "GET",
path: "/tiles/auto/40.0/-74.0",
if_none_match: None,
sequence: 1,
nonce: [0; 8],
};
let res = serve(&store, "transaction.science", &req);
assert_eq!(res.status, 200);
assert_eq!(res.body, b"on-disk-terrain");
assert_eq!(res.header("Content-Type"), Some("model/gltf-binary"));
assert_eq!(res.header("X-Map-Tier"), Some("F1"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn index_persists_across_reopen() {
let root = temp_root("persist");
let id = {
let mut store = DirStore::open(&root).unwrap();
store.insert(&cell(), Tier::Feature, b"vector-tile", "application/x-protobuf").unwrap()
};
// Reopen: a fresh handle reloads the index and still resolves + fetches.
let reopened = DirStore::open(&root).unwrap();
assert_eq!(reopened.tile(&cell(), Tier::Feature), Some(id));
let served = reopened.fetch(&id).unwrap();
assert_eq!(served.bytes, b"vector-tile");
assert_eq!(served.content_type, "application/x-protobuf");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn missing_tile_resolves_to_404() {
let root = temp_root("empty");
let store = DirStore::open(&root).unwrap();
let req = HttpReq {
method: "GET",
path: "/tiles/auto/40.0/-74.0",
if_none_match: None,
sequence: 1,
nonce: [0; 8],
};
assert_eq!(serve(&store, "s", &req).status, 404);
let _ = fs::remove_dir_all(&root);
}
}