Skip to main content

borg_mesh/
origin.rs

1//! Where each synced line came from: `<store>/mesh/origins.jsonl`, one signed record per line hash.
2//!
3//! ```json
4//! { "h": "<blake3 of the line>", "key": "<hex key of the node that first stamped it>", "ms": 1760000000000,
5//!   "sig": "<that key's signature of borg-origin-v1, h and ms>" }
6//! ```
7//!
8//! A node stamps the lines its own hooks wrote (any line without a record) with its key and the time
9//! it first saw them; a line received from a peer travels with its record, which the receiver
10//! verifies before storing either. The membership then decides whether a line is admitted
11//! (`Membership::admits`): a member's always, a revoked key's only if stamped before the revocation.
12//! Records are verified on receipt, not on every load: only this node writes this file.
13
14use std::collections::{BTreeMap, HashMap};
15use std::io::Write;
16use std::path::{Path, PathBuf};
17
18use serde::{Deserialize, Serialize};
19
20use crate::identity::{self, Identity};
21use crate::lines::{self, Hash};
22use crate::membership::Membership;
23use crate::{from_hex, to_hex};
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct Origin {
27    pub h: String,
28    pub key: String,
29    pub ms: i64,
30    pub sig: String,
31}
32
33fn signed(h: &str, ms: i64) -> Vec<u8> {
34    format!("borg-origin-v1\n{h}\n{ms}").into_bytes()
35}
36
37impl Origin {
38    pub fn new(me: &Identity, h: &Hash, ms: i64) -> Origin {
39        let h = to_hex(h);
40        let sig = me.sign(&signed(&h, ms));
41        Origin { key: me.public(), h, ms, sig }
42    }
43
44    /// The record is `key`'s signature for exactly this line.
45    pub fn verify(&self, line: &str) -> Result<(), String> {
46        if from_hex::<32>(&self.h)? != lines::hash(line) {
47            return Err("origin names another line".into());
48        }
49        identity::verify(&self.key, &signed(&self.h, self.ms), &self.sig).map_err(|e| format!("origin of {}: {e}", &self.h[..8]))
50    }
51}
52
53pub fn path(home: &Path) -> PathBuf {
54    identity::dir(home).join("origins.jsonl")
55}
56
57/// The first record per line hash.
58pub fn load(home: &Path) -> HashMap<Hash, Origin> {
59    let mut out = HashMap::new();
60    for l in std::fs::read_to_string(path(home)).unwrap_or_default().lines() {
61        let Ok(o) = serde_json::from_str::<Origin>(l) else { continue };
62        let Ok(h) = from_hex::<32>(&o.h) else { continue };
63        out.entry(h).or_insert(o);
64    }
65    out
66}
67
68pub fn append(home: &Path, records: &[Origin]) -> Result<(), String> {
69    if records.is_empty() {
70        return Ok(());
71    }
72    let p = path(home);
73    std::fs::create_dir_all(identity::dir(home)).map_err(|e| e.to_string())?;
74    let text: String = records.iter().map(|o| format!("{}\n", serde_json::to_string(o).unwrap())).collect();
75    let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&p).map_err(|e| format!("{}: {e}", p.display()))?;
76    f.write_all(text.as_bytes()).map_err(|e| format!("{}: {e}", p.display()))
77}
78
79/// Stamps every line of `logs` that has no record yet as this node's, at `now`; returns how many.
80pub fn stamp(home: &Path, me: &Identity, logs: &[PathBuf], now: i64) -> Result<usize, String> {
81    let mut known: std::collections::HashSet<Hash> = load(home).into_keys().collect();
82    let mut new = Vec::new();
83    for log in logs {
84        for h in lines::read(log).into_keys() {
85            if known.insert(h) {
86                new.push(Origin::new(me, &h, now));
87            }
88        }
89    }
90    append(home, &new)?;
91    Ok(new.len())
92}
93
94/// The lines of `set` the membership admits (lines without a record are not shared yet).
95pub fn admitted(set: BTreeMap<Hash, String>, origins: &HashMap<Hash, Origin>, m: &Membership) -> BTreeMap<Hash, String> {
96    set.into_iter().filter(|(h, _)| origins.get(h).is_some_and(|o| m.admits(&o.key, o.ms))).collect()
97}
98
99/// Applies revocations to a log already on disk: lines whose origin the membership no longer
100/// admits move to `<store>/mesh/quarantine.jsonl`, so the conscience stops asking them. Returns
101/// how many moved.
102pub fn quarantine(home: &Path, log: &Path, m: &Membership) -> Result<usize, String> {
103    let origins = load(home);
104    let refused = |l: &str| origins.get(&lines::hash(l)).is_some_and(|o| !m.admits(&o.key, o.ms));
105    // Read to rename under the log's lock, which its writers take for each append (borg live.rs
106    // `locked`: the lessons' learn, the jsonl appends): a line appended meanwhile is never lost.
107    let moved = borg::live::locked(log, || -> Result<usize, String> {
108        let text = std::fs::read_to_string(log).unwrap_or_default();
109        let (out, keep): (Vec<&str>, Vec<&str>) = text.lines().partition(|l| refused(l));
110        if out.is_empty() {
111            return Ok(0);
112        }
113        let q = identity::dir(home).join("quarantine.jsonl");
114        let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&q).map_err(|e| format!("{}: {e}", q.display()))?;
115        f.write_all(out.iter().map(|l| format!("{l}\n")).collect::<String>().as_bytes()).map_err(|e| e.to_string())?;
116        let tmp = log.with_extension("jsonl.quarantine-tmp");
117        std::fs::write(&tmp, keep.iter().map(|l| format!("{l}\n")).collect::<String>()).map_err(|e| format!("{}: {e}", tmp.display()))?;
118        std::fs::rename(&tmp, log).map_err(|e| format!("{}: {e}", log.display()))?;
119        Ok(out.len())
120    })?;
121    if moved > 0 {
122        // A removed line may have been the winning version of a lesson: make the winner among the
123        // remaining lines the last one again, or the conscience would ask an older version.
124        lines::settle(log)?;
125    }
126    Ok(moved)
127}
128
129/// `<store>/mesh/quarantined-at`: which membership (`fingerprint`) the logs were last checked against.
130fn marker(home: &Path) -> PathBuf {
131    identity::dir(home).join("quarantined-at")
132}
133
134/// The membership as this node applies it: the signed version and the fork revocations on top.
135fn fingerprint(m: &Membership) -> String {
136    to_hex(blake3::hash(&serde_json::to_vec(m).expect("membership serialises")).as_bytes())
137}
138
139/// Quarantines every log of `home` against `m` and records that it did. Returns how many lines moved.
140pub fn apply(home: &Path, m: &Membership) -> Result<usize, String> {
141    let mut moved = 0;
142    for log in lines::logs(home) {
143        moved += quarantine(home, &log, m)?;
144    }
145    std::fs::create_dir_all(identity::dir(home)).map_err(|e| e.to_string())?;
146    std::fs::write(marker(home), fingerprint(m)).map_err(|e| format!("{}: {e}", marker(home).display()))?;
147    Ok(moved)
148}
149
150/// `apply`, unless the logs were already checked against exactly this membership.
151pub fn apply_if_new(home: &Path, m: &Membership) -> Result<usize, String> {
152    if std::fs::read_to_string(marker(home)).is_ok_and(|t| t.trim() == fingerprint(m)) {
153        return Ok(0);
154    }
155    apply(home, m)
156}
157
158/// `borg mesh join`: installs the documents an admin handed over (one version, or the history
159/// file) and quarantines what the resulting membership no longer admits. Returns the installed
160/// membership and how many lines moved.
161pub fn join(home: &Path, collective: &str, docs: &[Membership]) -> Result<(Membership, usize), String> {
162    for d in docs {
163        d.verify()?;
164        if d.collective != collective {
165            return Err(format!("the membership of {:?}, this directory belongs to {collective:?}", d.collective));
166        }
167    }
168    let installed = match Membership::load(home)? {
169        Some(_) => match Membership::adopt(home, docs)? {
170            (m, true) => m,
171            (_, false) => return Err("nothing newer than the installed membership that an admin of it signed; hand over membership-history.jsonl".into()),
172        },
173        None => {
174            docs.iter().min_by_key(|d| d.version).ok_or("no membership document")?.save(home)?;
175            Membership::adopt(home, docs)?.0
176        }
177    };
178    let moved = apply(home, &installed)?;
179    Ok((installed, moved))
180}
181
182/// `borg mesh revoke`: the admin's new version, and its own logs quarantined at once (a line the
183/// revoked node stamped after `now`, a clock running ahead, may already be here).
184pub fn revoke(home: &Path, admin: &Identity, key: &str, now: i64) -> Result<(Membership, usize), String> {
185    let mut m = Membership::load(home)?.ok_or("no membership")?;
186    m.revoke(admin, key, now)?;
187    m.save(home)?;
188    let moved = apply(home, &m)?;
189    Ok((m, moved))
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn records_verify_only_for_their_line_and_key() {
198        let home = std::env::temp_dir().join(format!("borg-mesh-origin-{}", std::process::id()));
199        let _ = std::fs::remove_dir_all(&home);
200        let me = Identity::load_or_create(&home).unwrap();
201        let o = Origin::new(&me, &lines::hash("{\"a\":1}"), 5);
202        assert!(o.verify("{\"a\":1}").is_ok());
203        assert!(o.verify("{\"a\":2}").is_err(), "another line");
204        let backdated = Origin { ms: 4, ..o.clone() };
205        assert!(backdated.verify("{\"a\":1}").is_err(), "the time is signed");
206        let log = home.join("lessons.jsonl");
207        std::fs::write(&log, "{\"a\":1}\n{\"b\":2}\n").unwrap();
208        assert_eq!(stamp(&home, &me, std::slice::from_ref(&log), 7).unwrap(), 2);
209        assert_eq!(stamp(&home, &me, &[log], 8).unwrap(), 0, "stamped once");
210        let _ = std::fs::remove_dir_all(&home);
211    }
212
213    fn lesson(id: &str, version: u32, q: &str) -> String {
214        serde_json::json!({"id": id, "origin_ms": 1, "question": q, "version": version}).to_string()
215    }
216
217    /// B holds L1 v3, then merges [v2 (bob), v4 (carol)]: the file reads v3, v2, v4. Carol is
218    /// revoked, v4 is quarantined, and the conscience must ask v3 (as every other node does), not
219    /// the v2 that is now last.
220    #[test]
221    fn quarantine_settles_so_the_version_never_goes_back() {
222        let home = std::env::temp_dir().join(format!("borg-mesh-origin-settle-{}", std::process::id()));
223        let _ = std::fs::remove_dir_all(&home);
224        std::fs::create_dir_all(&home).unwrap();
225        let (admin, bob, carol) = (
226            Identity::load_or_create(&home).unwrap(),
227            Identity::load_or_create(&home.join("bob")).unwrap(),
228            Identity::load_or_create(&home.join("carol")).unwrap(),
229        );
230        let mut m = Membership::found("work", &admin);
231        m.invite(&admin, &bob.public(), "member").unwrap();
232        m.invite(&admin, &carol.public(), "member").unwrap();
233        let log = home.join("lessons.jsonl");
234        let (v3, v2, v4) = (lesson("L1", 3, "three"), lesson("L1", 2, "two"), lesson("L1", 4, "four"));
235        std::fs::write(&log, format!("{v3}\n")).unwrap();
236        lines::merge(&log, &[v2.clone(), v4.clone()]).unwrap();
237        let o = |who: &Identity, l: &str, ms| Origin::new(who, &lines::hash(l), ms);
238        append(&home, &[o(&admin, &v3, 1), o(&bob, &v2, 2), o(&carol, &v4, 50)]).unwrap();
239        let asked = || borg::conscience::load(&log, None).into_iter().map(|c| c.question).collect::<Vec<_>>();
240        assert_eq!(asked(), vec!["four".to_string()]);
241
242        m.revoke(&admin, &carol.public(), 10).unwrap();
243        assert_eq!(apply(&home, &m).unwrap(), 1);
244        assert_eq!(asked(), vec!["three".to_string()], "the highest remaining version, not the last line");
245        assert_eq!(apply_if_new(&home, &m).unwrap(), 0, "checked against this membership already");
246        let _ = std::fs::remove_dir_all(&home);
247    }
248
249    /// A quarantine rewrites a log while a hook appends to it: both take the log's lock, so the
250    /// rewrite waits for the writer, and the line appended is kept.
251    #[test]
252    fn quarantine_and_appends_share_the_log_lock() {
253        let home = std::env::temp_dir().join(format!("borg-mesh-origin-lock-{}", std::process::id()));
254        let _ = std::fs::remove_dir_all(&home);
255        std::fs::create_dir_all(&home).unwrap();
256        let (admin, carol) = (Identity::load_or_create(&home).unwrap(), Identity::load_or_create(&home.join("carol")).unwrap());
257        let mut m = Membership::found("work", &admin);
258        m.invite(&admin, &carol.public(), "member").unwrap();
259        let log = home.join("lessons.jsonl");
260        let bad = &lesson("L1", 1, "carol's");
261        std::fs::write(&log, format!("{bad}\n")).unwrap();
262        append(&home, &[Origin::new(&carol, &lines::hash(bad), 50)]).unwrap();
263        m.revoke(&admin, &carol.public(), 10).unwrap();
264        let wait = std::time::Duration::from_millis(300);
265        // A writer holds the lock: the quarantine waits for it.
266        let moved = borg::live::locked(&log, || {
267            let (h, l, m) = (home.clone(), log.clone(), m.clone());
268            let t = std::thread::spawn(move || quarantine(&h, &l, &m).unwrap());
269            std::thread::sleep(wait);
270            assert!(!t.is_finished(), "the rewrite waits for the writer");
271            std::fs::OpenOptions::new().append(true).open(&log).unwrap().write_all(format!("{}\n", lesson("L2", 1, "kept")).as_bytes()).unwrap();
272            t
273        })
274        .join()
275        .unwrap();
276        assert_eq!(moved, 1);
277        assert_eq!(std::fs::read_to_string(&log).unwrap().lines().collect::<Vec<_>>(), [lesson("L2", 1, "kept")]);
278        let _ = std::fs::remove_dir_all(&home);
279    }
280
281    /// Carol's outcome line, stamped after her revocation, moves to quarantine and stops counting.
282    #[test]
283    fn a_revoked_key_s_outcomes_are_quarantined() {
284        let home = std::env::temp_dir().join(format!("borg-mesh-origin-outcomes-{}", std::process::id()));
285        let _ = std::fs::remove_dir_all(&home);
286        std::fs::create_dir_all(&home).unwrap();
287        let (admin, carol) = (Identity::load_or_create(&home).unwrap(), Identity::load_or_create(&home.join("carol")).unwrap());
288        let mut m = Membership::found("work", &admin);
289        m.invite(&admin, &carol.public(), "member").unwrap();
290        std::fs::write(home.join("lessons.jsonl"), format!("{}\n", lesson("L1", 1, "one"))).unwrap();
291        let line = |s: &str| {
292            let x = borg::outcome::Shared {
293                kind: borg::outcome::CHANGED.into(),
294                s: s.into(),
295                lessons: vec!["L1".into()],
296                ms: 5,
297                node: "n".into(),
298                ..Default::default()
299            };
300            serde_json::to_string(&x).unwrap()
301        };
302        let (good, bad) = (line("t1"), line("t2"));
303        let team = home.join(borg::outcome::TEAM_FILE);
304        std::fs::write(&team, format!("{good}\n{bad}\n")).unwrap();
305        append(&home, &[Origin::new(&admin, &lines::hash(&good), 1), Origin::new(&carol, &lines::hash(&bad), 50)]).unwrap();
306        let l1 = [borg::conscience::Challenge { id: "L1".into(), ..Default::default() }];
307        let changed = || borg::memory::derive(&borg::outcome::read_team(&home.join("lessons.jsonl")), &l1, None)["L1"].changed;
308        assert_eq!(changed(), 2);
309        m.revoke(&admin, &carol.public(), 10).unwrap();
310        assert_eq!(apply(&home, &m).unwrap(), 1);
311        assert_eq!(changed(), 1, "the revoked key's result no longer counts");
312        assert!(std::fs::read_to_string(identity::dir(&home).join("quarantine.jsonl")).unwrap().contains("\"t2\""));
313        let _ = std::fs::remove_dir_all(&home);
314    }
315}