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]>
204 lines
6.8 KiB
Rust
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);
|
|
}
|
|
}
|