use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Routes {
#[serde(default)]
pub local: BTreeMap<String, String>,
}
impl Routes {
pub fn load() -> Self {
let p = crate::identity::home().join("routes.json");
fs::read_to_string(p)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
pub fn save(&self) -> std::io::Result<()> {
let dir = crate::identity::home();
fs::create_dir_all(&dir)?;
fs::write(dir.join("routes.json"), serde_json::to_vec_pretty(self)?)
}
pub fn home_of(&self, handle: &str) -> Option<PathBuf> {
self.local.get(handle).map(PathBuf::from)
}
}
pub fn observe_inbox() -> std::io::Result<crate::state::LocalState> {
let st = crate::state::LocalState::observe(&scan_inbox())?;
let failed = crate::journal::record_arrivals();
if failed > 0 {
eprintln!("gage: could not journal {failed} arrival(s)");
}
Ok(st)
}
pub fn scan_inbox() -> Vec<String> {
let st = crate::state::LocalState::load();
scan_inbox_knowing(&|w| st.witnessed(w).is_some())
}
pub fn scan_inbox_knowing(known: &dyn Fn(&str) -> bool) -> Vec<String> {
let home = crate::identity::home();
let mut w = Vec::new();
for stage in [pending_dir(&home), accepted_dir(&home)] {
let Ok(rd) = std::fs::read_dir(&stage) else { continue };
for e in rd.flatten() {
if e.path().is_dir() {
if let Some(x) = crate::bundle::witness_if_sound_unless(&e.path(), known) {
w.push(x);
}
}
}
}
w
}
pub fn pending_dir(ctx_home: &std::path::Path) -> PathBuf {
ctx_home.join("inbox/pending")
}
pub fn accepted_dir(ctx_home: &std::path::Path) -> PathBuf {
ctx_home.join("inbox/accepted")
}
pub fn sent_dir(ctx_home: &std::path::Path) -> PathBuf {
ctx_home.join("sent")
}
pub fn deliver(src: &std::path::Path, dest: &std::path::Path) -> std::io::Result<()> {
if dest.exists() {
return Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
format!(
"{} already exists; refusing to overwrite a bundle already delivered",
dest.display()
),
));
}
let bytes = fs::read(src.join("manifest.json"))?;
let m: crate::manifest::Manifest = serde_json::from_slice(&bytes)?;
let bad = |msg: String| std::io::Error::new(std::io::ErrorKind::InvalidInput, msg);
let mut seen = std::collections::BTreeSet::new();
for it in &m.items {
if !crate::manifest::path_is_safe(&it.path) {
return Err(bad(format!("refusing to deliver unsafe path: {}", it.path)));
}
if !it.path.starts_with("items/") {
return Err(bad(format!(
"refusing to deliver an item outside items/: {}",
it.path
)));
}
if !seen.insert(it.path.as_str()) {
return Err(bad(format!("manifest declares {} twice", it.path)));
}
if !src.join(&it.path).is_file() {
return Err(bad(format!(
"manifest declares {} but the file is missing",
it.path
)));
}
}
let parent = dest.parent().ok_or_else(|| bad("destination has no parent".into()))?;
let leaf = dest
.file_name()
.ok_or_else(|| bad("destination has no name".into()))?
.to_string_lossy()
.to_string();
fs::create_dir_all(parent)?;
let tmp = parent.join(format!(".partial-{}-{}", std::process::id(), leaf));
let _ = fs::remove_dir_all(&tmp);
let staged = (|| -> std::io::Result<()> {
fs::create_dir_all(tmp.join("items"))?;
fs::write(tmp.join("manifest.json"), &bytes)?;
if let Ok(sig) = fs::read(src.join("manifest.sig")) {
fs::write(tmp.join("manifest.sig"), sig)?;
}
for it in &m.items {
if let Some(p) = std::path::Path::new(&it.path).parent() {
fs::create_dir_all(tmp.join(p))?;
}
fs::copy(src.join(&it.path), tmp.join(&it.path))?;
}
Ok(())
})();
if let Err(e) = staged {
let _ = fs::remove_dir_all(&tmp);
return Err(e);
}
if let Err(e) = fs::rename(&tmp, dest) {
let _ = fs::remove_dir_all(&tmp);
return Err(e);
}
Ok(())
}
pub fn restage(from: &std::path::Path, dest: &std::path::Path) -> std::io::Result<()> {
match deliver(from, dest) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && resumable(from, dest) => {
}
Err(e) => return Err(e),
}
fs::remove_dir_all(from)
}
fn resumable(from: &std::path::Path, dest: &std::path::Path) -> bool {
let bytes = match (fs::read(from.join("manifest.json")), fs::read(dest.join("manifest.json"))) {
(Ok(x), Ok(y)) if x == y => x,
_ => return false,
};
let Ok(m) = serde_json::from_slice::<crate::manifest::Manifest>(&bytes) else {
return false;
};
m.items.iter().all(|it| {
crate::manifest::path_is_safe(&it.path)
&& matches!(fs::read(dest.join(&it.path)),
Ok(b) if crate::bundle::sha256_hex(&b) == it.sha256)
})
}
pub fn find_by_id(ctx_home: &std::path::Path, id: &str) -> Option<PathBuf> {
if !crate::manifest::id_is_safe(id) {
return None;
}
[accepted_dir(ctx_home), pending_dir(ctx_home), sent_dir(ctx_home)]
.into_iter()
.map(|d| d.join(id))
.find(|p| p.join("manifest.json").is_file())
}
pub fn list(stage: &std::path::Path) -> Vec<(String, crate::bundle::Loaded)> {
let mut out = Vec::new();
if let Ok(rd) = fs::read_dir(stage) {
for e in rd.flatten() {
if !e.path().is_dir() {
continue;
}
if e.file_name().to_string_lossy().starts_with('.') {
continue;
}
if let Ok(l) = crate::bundle::load(&e.path()) {
let name = e.file_name().to_string_lossy().to_string();
out.push((name, l));
}
}
}
out.sort_by(|a, b| b.1.manifest.created.cmp(&a.1.manifest.created));
out
}
#[cfg(test)]
mod tests {
use super::*;
fn manifest_json(paths: &[(&str, &[u8])]) -> Vec<u8> {
let items: Vec<String> = paths
.iter()
.map(|(p, body)| {
format!(
r#"{{"kind":"file","path":"{p}","sha256":"{}","bytes":{}}}"#,
crate::bundle::sha256_hex(body),
body.len()
)
})
.collect();
format!(
r#"{{"v":1,"id":"x","created":"2026-01-01T00:00:00Z","subject":"s",
"from":{{"handle":"a","key":"k"}},"to":["b"],"chain":[],
"items":[{}]}}"#,
items.join(",")
)
.into_bytes()
}
fn src_bundle(
tmp: &std::path::Path,
decl: &[(&str, &[u8])],
on_disk: &[(&str, &[u8])],
) -> PathBuf {
let d = tmp.join("src");
fs::create_dir_all(d.join("items")).unwrap();
fs::write(d.join("manifest.json"), manifest_json(decl)).unwrap();
fs::write(d.join("manifest.sig"), b"sig").unwrap();
for (p, body) in on_disk {
let f = d.join(p);
fs::create_dir_all(f.parent().unwrap()).unwrap();
fs::write(f, body).unwrap();
}
d
}
fn scratch(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("gage-routes-{}-{}", name, std::process::id()));
let _ = fs::remove_dir_all(&d);
fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn a_bundle_id_resolves_across_the_stages_but_never_out_of_the_home() {
let t = scratch("find");
let src = src_bundle(&t, &[("items/a", b"one")], &[("items/a", b"one")]);
let id = "abcdef0123456789";
assert_eq!(find_by_id(&t, id), None, "nothing staged yet");
deliver(&src, &pending_dir(&t).join(id)).unwrap();
assert_eq!(find_by_id(&t, id), Some(pending_dir(&t).join(id)));
deliver(&src, &accepted_dir(&t).join(id)).unwrap();
assert_eq!(find_by_id(&t, id), Some(accepted_dir(&t).join(id)));
for bad in ["../../etc", "..", "a/b", "ABCDEF0123456789", "", "zz"] {
assert_eq!(find_by_id(&t, bad), None, "{bad:?} should not resolve");
}
}
#[test]
fn a_duplicate_item_path_is_refused_rather_than_copied_twice() {
let t = scratch("dup");
let src = src_bundle(&t, &[("items/a", b"one"), ("items/a", b"one")], &[("items/a", b"one")]);
let dest = t.join("dest");
let e = deliver(&src, &dest).unwrap_err();
assert!(e.to_string().contains("twice"), "{e}");
assert!(!dest.exists());
}
#[test]
fn an_item_outside_items_cannot_overwrite_the_delivered_manifest() {
let t = scratch("outside");
let src = src_bundle(&t, &[("manifest.json", b"{}")], &[]);
let dest = t.join("dest");
let e = deliver(&src, &dest).unwrap_err();
assert!(e.to_string().contains("outside items/"), "{e}");
assert!(!dest.exists());
}
#[test]
fn a_declared_item_with_no_file_leaves_no_partial_destination() {
let t = scratch("missing");
let src = src_bundle(
&t,
&[("items/a", b"one"), ("items/b", b"two")],
&[("items/a", b"one")],
);
let dest = t.join("dest");
let e = deliver(&src, &dest).unwrap_err();
assert!(e.to_string().contains("missing"), "{e}");
assert!(!dest.exists(), "a refused delivery must not block the retry");
}
#[test]
fn delivery_stages_out_of_sight_and_is_not_listed_as_mail() {
let t = scratch("staging");
let stage = t.join("pending");
fs::create_dir_all(&stage).unwrap();
fs::create_dir_all(stage.join(".partial-1-abc/items")).unwrap();
fs::write(stage.join(".partial-1-abc/manifest.json"), manifest_json(&[])).unwrap();
assert!(list(&stage).is_empty());
}
#[test]
fn an_interrupted_accept_can_be_finished_rather_than_stuck() {
let t = scratch("restage");
let src = src_bundle(&t, &[("items/a", b"one")], &[("items/a", b"one")]);
let dest = t.join("accepted").join("x");
deliver(&src, &dest).unwrap();
assert!(dest.is_dir() && src.is_dir());
restage(&src, &dest).unwrap();
assert!(!src.exists(), "the source stage should be clear");
assert!(dest.join("items/a").is_file(), "the delivered copy stays");
}
#[test]
fn a_destination_whose_payload_was_rewritten_is_not_treated_as_resumable() {
let t = scratch("swapped");
let src = src_bundle(&t, &[("items/a", b"one")], &[("items/a", b"one")]);
let dest = t.join("accepted").join("x");
deliver(&src, &dest).unwrap();
fs::write(dest.join("items/a"), b"ONE").unwrap();
let e = restage(&src, &dest).unwrap_err();
assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists);
assert!(src.is_dir(), "the sound source must survive");
}
#[test]
fn a_different_bundle_on_the_same_id_is_still_refused() {
let t = scratch("clobber");
let dest = t.join("accepted").join("x");
let first = src_bundle(&t, &[("items/a", b"one")], &[("items/a", b"one")]);
deliver(&first, &dest).unwrap();
let other = t.join("other");
fs::create_dir_all(other.join("items")).unwrap();
fs::write(other.join("manifest.json"), manifest_json(&[("items/a", b"TWO")])).unwrap();
fs::write(other.join("items/a"), b"TWO").unwrap();
let e = restage(&other, &dest).unwrap_err();
assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists);
assert!(other.is_dir(), "a refused move must not delete the source");
assert_eq!(fs::read(dest.join("items/a")).unwrap(), b"one");
}
}