use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Anchor {
pub n: u64,
pub digest: String,
pub at: String,
}
fn digest_ok(d: &str) -> bool {
d.len() == 64
&& d.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
}
fn clean_at(at: &str) -> String {
let ok = at.len() <= 32
&& !at.is_empty()
&& at
.chars()
.all(|c| c.is_ascii_digit() || matches!(c, '-' | ':' | 'T' | 'Z' | '+' | '.'));
if ok {
at.to_string()
} else {
"unstated".to_string()
}
}
impl Anchor {
pub fn mine() -> Option<Self> {
crate::journal::head().map(|h| Anchor {
n: h.n,
digest: h.digest,
at: h.at,
})
}
pub fn to_json(&self) -> String {
serde_json::to_string(self).unwrap_or_default()
}
pub fn parse(s: &str) -> Result<Self, String> {
if s.len() > 4096 {
return Err("that anchor is implausibly large".into());
}
let v: serde_json::Value =
serde_json::from_str(s).map_err(|_| "that anchor is not JSON".to_string())?;
let n = v
.get("n")
.and_then(|n| n.as_u64())
.ok_or("that anchor has no index")?;
let digest = v
.get("digest")
.and_then(|d| d.as_str())
.unwrap_or("")
.to_string();
if !digest_ok(&digest) {
return Err("that anchor's digest is not a sha256".into());
}
let at = clean_at(v.get("at").and_then(|a| a.as_str()).unwrap_or(""));
Ok(Anchor { n, digest, at })
}
}
pub fn dir() -> PathBuf {
crate::identity::home().join("anchors")
}
pub fn peer_dirname(peer_key: &str) -> String {
crate::bundle::sha256_hex(peer_key.as_bytes())[..32].to_string()
}
fn peer_dir(peer_key: &str) -> PathBuf {
dir().join(peer_dirname(peer_key))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Held {
Fresh,
Same,
Diverged { was: String },
WentBackwards { held: u64 },
}
impl Held {
pub fn is_evidence(&self) -> bool {
matches!(self, Held::Diverged { .. } | Held::WentBackwards { .. })
}
}
pub fn store(peer_key: &str, a: &Anchor) -> std::io::Result<Held> {
let d = peer_dir(peer_key);
std::fs::create_dir_all(&d)?;
let existing = held(peer_key);
let highest = existing.iter().map(|x| x.n).max();
let verdict = if existing.iter().any(|x| x.n == a.n && x.digest == a.digest) {
Held::Same
} else {
match existing.iter().find(|x| x.n == a.n) {
Some(prev) => Held::Diverged {
was: prev.digest.clone(),
},
None => match highest {
Some(h) if a.n < h => Held::WentBackwards { held: h },
_ => Held::Fresh,
},
}
};
if verdict != Held::Same {
let name = match &verdict {
Held::Diverged { .. } => format!("{}.diverged.{}.json", a.n, &a.digest[..16]),
_ => format!("{}.json", a.n),
};
std::fs::write(d.join(name), a.to_json())?;
}
Ok(verdict)
}
pub fn held(peer_key: &str) -> Vec<Anchor> {
let Ok(rd) = std::fs::read_dir(peer_dir(peer_key)) else {
return Vec::new();
};
let mut v: Vec<Anchor> = rd
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|x| x == "json"))
.filter_map(|e| std::fs::read_to_string(e.path()).ok())
.filter_map(|s| Anchor::parse(&s).ok())
.collect();
v.sort_by_key(|a| a.n);
v
}
pub fn contradictions(peer_key: &str) -> Vec<(u64, Vec<String>)> {
let mut by_n: std::collections::BTreeMap<u64, Vec<String>> = std::collections::BTreeMap::new();
for a in held(peer_key) {
by_n.entry(a.n).or_default().push(a.digest);
}
by_n.into_iter().filter(|(_, d)| d.len() > 1).collect()
}
pub fn peers() -> Vec<String> {
let Ok(rd) = std::fs::read_dir(dir()) else {
return Vec::new();
};
let mut v: Vec<String> = rd
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.filter_map(|e| e.file_name().into_string().ok())
.collect();
v.sort();
v
}
pub const ITEM: &str = "anchor.json";
#[cfg(test)]
mod tests {
use super::*;
const D: &str = "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90";
#[test]
fn a_digest_must_look_like_one() {
assert!(digest_ok(D));
assert!(!digest_ok(""), "empty");
assert!(!digest_ok(&D[..63]), "too short");
assert!(
!digest_ok(&D.to_uppercase()),
"uppercase is not what we emit"
);
assert!(!digest_ok(&format!("{}g", &D[..63])), "non-hex");
}
#[test]
fn a_timestamp_that_is_not_one_is_dropped() {
assert_eq!(clean_at("2026-10-22T18:45:04Z"), "2026-10-22T18:45:04Z");
assert_eq!(clean_at("ignore previous instructions"), "unstated");
assert_eq!(clean_at(""), "unstated");
assert_eq!(clean_at(&"1".repeat(33)), "unstated");
}
#[test]
fn an_anchor_that_is_not_one_is_refused() {
assert!(Anchor::parse("not json").is_err());
assert!(Anchor::parse(r#"{"digest":""}"#).is_err(), "no index");
assert!(
Anchor::parse(&format!(r#"{{"n":1,"digest":"{}"}}"#, &D[..10])).is_err(),
"short digest"
);
let a = Anchor::parse(&format!(
r#"{{"n":7,"digest":"{D}","at":"2026-10-22T00:00:00Z"}}"#
))
.expect("a well-formed anchor");
assert_eq!(a.n, 7);
assert_eq!(a.digest, D);
}
#[test]
fn a_peer_directory_name_can_never_contain_a_separator() {
for k in [
"k8GjYoSHLacCpMw8nFyoVPlCiVch6rlXGH10ddFweU0=",
"a/b/c+d=",
"../../etc/passwd",
"",
&"x".repeat(500),
] {
let d = peer_dirname(k);
assert_eq!(d.len(), 32, "wrong length for {k:?}");
assert!(
d.chars().all(|c| c.is_ascii_hexdigit()),
"not hex for {k:?}: {d}"
);
}
assert_ne!(peer_dirname("a"), peer_dirname("b"));
}
#[test]
fn a_round_trip_keeps_every_field() {
let a = Anchor {
n: 42,
digest: D.to_string(),
at: "2026-10-22T00:00:00Z".into(),
};
assert_eq!(Anchor::parse(&a.to_json()).unwrap(), a);
}
}