1use 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 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
57pub 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
79pub 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
94pub 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
99pub 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 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 lines::settle(log)?;
125 }
126 Ok(moved)
127}
128
129fn marker(home: &Path) -> PathBuf {
131 identity::dir(home).join("quarantined-at")
132}
133
134fn fingerprint(m: &Membership) -> String {
136 to_hex(blake3::hash(&serde_json::to_vec(m).expect("membership serialises")).as_bytes())
137}
138
139pub 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
150pub 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
158pub 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
182pub 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 #[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 #[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 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 #[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}