Skip to main content

gpp_sync/
lib.rs

1//! `gpp-sync` — CRDT-based, offline-first P2P synchronization (layer 8).
2//!
3//! Transport is TCP + Noise_XX (mutual static-key auth, TOFU). Peers exchange
4//! a *state vector* (object id set, branch tips, policy set) then transfer
5//! only what the other lacks:
6//!
7//! * **Objects** — content-addressed, add-only set; verified on receipt.
8//! * **Refs** — adopt missing branches; divergent same-name branches are
9//!   preserved as `name@peer` forks (no silent merge — see `gpp merge`).
10//! * **Policies** — add-only union by name.
11//! * **Graphex** — optional zero-knowledge index sync (encrypted blobs ride
12//!   the object set; metadata merges OR-Set / LWW).
13//!
14//! Trust and timeline are **never** synced (per `docs/SYNC_PROTOCOL.md`).
15#![forbid(unsafe_code)]
16
17mod error;
18mod transport;
19
20use std::collections::BTreeMap;
21use std::net::TcpStream;
22use std::path::{Path, PathBuf};
23
24use gpp_core::{Hash, ObjectStore};
25use gpp_graphex::{EdgeIndexRow, GraphStore, NodeIndexRow};
26use gpp_history::RefStore;
27use serde::{Deserialize, Serialize};
28
29pub use error::{Error, Result};
30pub use transport::{Transport, generate_keypair};
31
32const PROTOCOL_VERSION: u32 = 1;
33
34fn hex(b: &[u8]) -> String {
35    b.iter().map(|x| format!("{x:02x}")).collect()
36}
37fn unhex(s: &str) -> Result<Vec<u8>> {
38    if !s.len().is_multiple_of(2) {
39        return Err(Error::Protocol("odd hex".into()));
40    }
41    (0..s.len())
42        .step_by(2)
43        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| Error::Protocol(e.to_string())))
44        .collect()
45}
46
47// ---- sync identity / repo id / TOFU ---------------------------------------
48
49fn sync_dir(gpp: &Path) -> PathBuf {
50    gpp.join("sync")
51}
52
53/// Persistent Noise static keypair for this repo: `(private, public)`.
54pub fn ensure_identity(gpp: &Path) -> Result<(Vec<u8>, Vec<u8>)> {
55    let dir = sync_dir(gpp);
56    std::fs::create_dir_all(&dir)?;
57    let path = dir.join("identity");
58    if let Ok(s) = std::fs::read_to_string(&path) {
59        let mut it = s.split_whitespace();
60        if let (Some(p), Some(q)) = (it.next(), it.next()) {
61            return Ok((unhex(p)?, unhex(q)?));
62        }
63    }
64    let (priv_, pub_) = generate_keypair()?;
65    std::fs::write(&path, format!("{} {}", hex(&priv_), hex(&pub_)))?;
66    Ok((priv_, pub_))
67}
68
69/// Stable repository id (shared by all replicas). Created on first use;
70/// [`set_repo_id`] lets a fresh clone adopt a peer's id.
71pub fn ensure_repo_id(gpp: &Path) -> Result<String> {
72    let dir = sync_dir(gpp);
73    std::fs::create_dir_all(&dir)?;
74    let path = dir.join("repo_id");
75    if let Ok(s) = std::fs::read_to_string(&path) {
76        let t = s.trim();
77        if !t.is_empty() {
78            return Ok(t.to_string());
79        }
80    }
81    let (_, pubkey) = generate_keypair()?;
82    let id = Hash::of(&pubkey).to_base32();
83    std::fs::write(&path, &id)?;
84    Ok(id)
85}
86
87pub fn set_repo_id(gpp: &Path, id: &str) -> Result<()> {
88    let dir = sync_dir(gpp);
89    std::fs::create_dir_all(&dir)?;
90    std::fs::write(dir.join("repo_id"), id)?;
91    Ok(())
92}
93
94/// Trust-on-first-use check for a peer's static key.
95fn tofu(gpp: &Path, peer_name: &str, remote_static: &[u8]) -> Result<()> {
96    let path = sync_dir(gpp).join("known_peers");
97    let key = hex(remote_static);
98    let body = std::fs::read_to_string(&path).unwrap_or_default();
99    for line in body.lines() {
100        let mut it = line.split_whitespace();
101        if let (Some(n), Some(k)) = (it.next(), it.next())
102            && n == peer_name
103        {
104            if k == key {
105                return Ok(());
106            }
107            return Err(Error::PeerKeyChanged(peer_name.to_string()));
108        }
109    }
110    let mut f = body;
111    f.push_str(&format!("{peer_name} {key}\n"));
112    std::fs::write(&path, f)?;
113    Ok(())
114}
115
116// ---- wire messages ---------------------------------------------------------
117
118#[derive(Serialize, Deserialize)]
119enum Msg {
120    Hello {
121        repo_id: String,
122        version: u32,
123    },
124    State {
125        object_ids: Vec<String>,
126        branch_tips: BTreeMap<String, String>,
127        policies: Vec<String>,
128    },
129    Push {
130        objects: Vec<(String, String)>, // (id, hex frame)
131        refs: BTreeMap<String, String>,
132        policies: Vec<(String, String)>, // (name, contents)
133        nodes: Vec<NodeIndexRow>,
134        edges: Vec<EdgeIndexRow>,
135    },
136    Done,
137}
138
139#[derive(Debug, Default, Clone, PartialEq, Eq)]
140pub struct SyncReport {
141    pub objects_received: usize,
142    pub refs_adopted: usize,
143    pub forks_created: usize,
144    pub policies_added: usize,
145    pub graph_rows_merged: usize,
146}
147
148#[derive(Debug, Clone, Copy, Default)]
149pub struct SyncOptions {
150    pub graph_only: bool,
151    pub include_graphex: bool,
152}
153
154fn send(t: &mut Transport, m: &Msg) -> Result<()> {
155    t.send(&serde_json::to_vec(m)?)
156}
157fn recv(t: &mut Transport) -> Result<Msg> {
158    Ok(serde_json::from_slice(&t.recv()?)?)
159}
160
161// ---- local state helpers ---------------------------------------------------
162
163fn policies_dir(gpp: &Path) -> PathBuf {
164    gpp.join("policies")
165}
166
167fn local_policies(gpp: &Path) -> BTreeMap<String, String> {
168    let mut out = BTreeMap::new();
169    if let Ok(rd) = std::fs::read_dir(policies_dir(gpp)) {
170        for e in rd.flatten() {
171            let p = e.path();
172            if p.extension().is_some_and(|x| x == "policy")
173                && let (Some(stem), Ok(body)) = (
174                    p.file_stem().map(|s| s.to_string_lossy().into_owned()),
175                    std::fs::read_to_string(&p),
176                )
177            {
178                out.insert(stem, body);
179            }
180        }
181    }
182    out
183}
184
185fn build_state(gpp: &Path, opts: SyncOptions) -> Result<Msg> {
186    let store = ObjectStore::open(gpp);
187    let object_ids = if opts.graph_only {
188        Vec::new()
189    } else {
190        store.iter_ids().iter().map(|h| h.to_base32()).collect()
191    };
192    let refs = RefStore::open(gpp);
193    let mut branch_tips = BTreeMap::new();
194    for b in refs.list().map_err(|e| Error::Other(e.to_string()))? {
195        if let Some(tip) = b.tip {
196            branch_tips.insert(b.name, tip.to_base32());
197        }
198    }
199    Ok(Msg::State {
200        object_ids,
201        branch_tips,
202        policies: local_policies(gpp).into_keys().collect(),
203    })
204}
205
206/// Compute the Push payload this side owes a peer with the given state.
207fn build_push(gpp: &Path, remote: &Msg, opts: SyncOptions) -> Result<Msg> {
208    let Msg::State {
209        object_ids: their_objs,
210        policies: their_pol,
211        ..
212    } = remote
213    else {
214        return Err(Error::Protocol("expected State".into()));
215    };
216    let store = ObjectStore::open(gpp);
217    let theirs: std::collections::BTreeSet<&String> = their_objs.iter().collect();
218
219    let mut objects = Vec::new();
220    if !opts.graph_only {
221        for h in store.iter_ids() {
222            let b32 = h.to_base32();
223            if !theirs.contains(&b32) {
224                objects.push((b32, hex(&store.read_raw(&h)?)));
225            }
226        }
227    }
228
229    let refs = RefStore::open(gpp);
230    let mut ref_map = BTreeMap::new();
231    for b in refs.list().map_err(|e| Error::Other(e.to_string()))? {
232        if let Some(tip) = b.tip {
233            ref_map.insert(b.name, tip.to_base32());
234        }
235    }
236
237    let local_pol = local_policies(gpp);
238    let their_set: std::collections::BTreeSet<&String> = their_pol.iter().collect();
239    let policies: Vec<(String, String)> = local_pol
240        .into_iter()
241        .filter(|(n, _)| !their_set.contains(n))
242        .collect();
243
244    let (nodes, edges) = if opts.graph_only || opts.include_graphex {
245        match GraphStore::open(gpp) {
246            Ok(g) => g.export_index().map_err(|e| Error::Other(e.to_string()))?,
247            Err(_) => (Vec::new(), Vec::new()),
248        }
249    } else {
250        (Vec::new(), Vec::new())
251    };
252
253    Ok(Msg::Push {
254        objects,
255        refs: ref_map,
256        policies,
257        nodes,
258        edges,
259    })
260}
261
262/// Apply a received Push to the local repo.
263fn apply_push(gpp: &Path, peer_name: &str, m: Msg, rep: &mut SyncReport) -> Result<()> {
264    let Msg::Push {
265        objects,
266        refs,
267        policies,
268        nodes,
269        edges,
270    } = m
271    else {
272        return Err(Error::Protocol("expected Push".into()));
273    };
274
275    let store = ObjectStore::open(gpp);
276    for (id, frame) in objects {
277        let h = Hash::from_base32(&id).map_err(|e| Error::Protocol(e.to_string()))?;
278        store.write_raw(&h, &unhex(&frame)?)?;
279        rep.objects_received += 1;
280    }
281
282    let refstore = RefStore::open(gpp);
283    for (name, tip_s) in refs {
284        let tip = Hash::from_base32(&tip_s).map_err(|e| Error::Protocol(e.to_string()))?;
285        match refstore
286            .read_ref(&name)
287            .map_err(|e| Error::Other(e.to_string()))?
288        {
289            None => {
290                refstore
291                    .write_ref(&name, tip)
292                    .map_err(|e| Error::Other(e.to_string()))?;
293                rep.refs_adopted += 1;
294            }
295            Some(local) if local == tip => {}
296            Some(_) => {
297                // Divergent: preserve the peer's as a fork; never auto-merge.
298                // (`@` is not a valid ref char, so use a dotted form.)
299                let fork = format!("{name}.fork.{peer_name}");
300                refstore
301                    .write_ref(&fork, tip)
302                    .map_err(|e| Error::Other(e.to_string()))?;
303                rep.forks_created += 1;
304            }
305        }
306    }
307
308    let pdir = policies_dir(gpp);
309    for (name, body) in policies {
310        let dest = pdir.join(format!("{name}.policy"));
311        if !dest.exists() {
312            std::fs::create_dir_all(&pdir)?;
313            std::fs::write(dest, body)?;
314            rep.policies_added += 1;
315        }
316    }
317
318    if (!nodes.is_empty() || !edges.is_empty())
319        && let Ok(g) = GraphStore::open(gpp)
320    {
321        rep.graph_rows_merged += g
322            .import_index(&nodes, &edges)
323            .map_err(|e| Error::Other(e.to_string()))?;
324    }
325    Ok(())
326}
327
328fn handshake_hello(t: &mut Transport, gpp: &Path, initiator: bool) -> Result<()> {
329    let repo_id = ensure_repo_id(gpp)?;
330    let hello = Msg::Hello {
331        repo_id: repo_id.clone(),
332        version: PROTOCOL_VERSION,
333    };
334    let theirs = if initiator {
335        send(t, &hello)?;
336        recv(t)?
337    } else {
338        let r = recv(t)?;
339        send(t, &hello)?;
340        r
341    };
342    match theirs {
343        Msg::Hello { repo_id: tid, .. } if tid == repo_id => Ok(()),
344        Msg::Hello { repo_id: tid, .. } => Err(Error::RepoMismatch {
345            local: repo_id,
346            remote: tid,
347        }),
348        _ => Err(Error::Protocol("expected Hello".into())),
349    }
350}
351
352/// Drive a sync as the connecting peer.
353pub fn connect(addr: &str, gpp: &Path, peer_name: &str, opts: SyncOptions) -> Result<SyncReport> {
354    let (priv_, _) = ensure_identity(gpp)?;
355    let stream = TcpStream::connect(addr)?;
356    let mut t = Transport::initiate(stream, &priv_)?;
357    tofu(gpp, peer_name, &t.remote_static)?;
358    handshake_hello(&mut t, gpp, true)?;
359
360    let mut rep = SyncReport::default();
361    send(&mut t, &build_state(gpp, opts)?)?;
362    let remote_state = recv(&mut t)?;
363
364    // Push phase, then pull phase.
365    send(&mut t, &build_push(gpp, &remote_state, opts)?)?;
366    send(&mut t, &Msg::Done)?;
367    loop {
368        match recv(&mut t)? {
369            Msg::Done => break,
370            m => apply_push(gpp, peer_name, m, &mut rep)?,
371        }
372    }
373    Ok(rep)
374}
375
376/// Handle one inbound sync on an accepted stream (responder role).
377/// Trust-on-first-use only; see [`serve_with_auth`] to additionally gate on an
378/// authorized-keys allowlist.
379pub fn serve(
380    stream: TcpStream,
381    gpp: &Path,
382    peer_name: &str,
383    opts: SyncOptions,
384) -> Result<SyncReport> {
385    serve_with_auth(stream, gpp, peer_name, opts, None)
386}
387
388/// Like [`serve`], but reject any peer whose Noise static key is not in
389/// `allow` *immediately after the handshake* — before the repo-id exchange,
390/// TOFU pinning, or any object data. `None` disables the check (TOFU only).
391///
392/// Noise XX only reveals the initiator's static key once the handshake
393/// completes, so this is the earliest point the key is known; rejecting here
394/// means an unauthorized peer transfers no application data. Keys are compared
395/// in the lowercase-hex form written to `known_peers`, case-insensitively.
396pub fn serve_with_auth(
397    stream: TcpStream,
398    gpp: &Path,
399    peer_name: &str,
400    opts: SyncOptions,
401    allow: Option<&[String]>,
402) -> Result<SyncReport> {
403    let (priv_, _) = ensure_identity(gpp)?;
404    let mut t = Transport::respond(stream, &priv_)?;
405    if let Some(allow) = allow {
406        let key = hex(&t.remote_static);
407        if !allow.iter().any(|k| k.trim().eq_ignore_ascii_case(&key)) {
408            return Err(Error::Unauthorized(key));
409        }
410    }
411    tofu(gpp, peer_name, &t.remote_static)?;
412    handshake_hello(&mut t, gpp, false)?;
413
414    let mut rep = SyncReport::default();
415    let remote_state = recv(&mut t)?;
416    send(&mut t, &build_state(gpp, opts)?)?;
417
418    // Pull phase first (mirror of the initiator), then push.
419    loop {
420        match recv(&mut t)? {
421            Msg::Done => break,
422            m => apply_push(gpp, peer_name, m, &mut rep)?,
423        }
424    }
425    send(&mut t, &build_push(gpp, &remote_state, opts)?)?;
426    send(&mut t, &Msg::Done)?;
427    Ok(rep)
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433    use std::net::TcpListener;
434
435    fn init_repo(root: &Path) -> PathBuf {
436        let gpp = root.join(".gpp");
437        std::fs::create_dir_all(gpp.join("refs")).unwrap();
438        ObjectStore::init(&gpp).unwrap();
439        std::fs::write(gpp.join("HEAD"), "ref: refs/main\n").unwrap();
440        gpp
441    }
442
443    #[test]
444    fn converges_objects_refs_and_policies() {
445        let a = tempfile::tempdir().unwrap();
446        let b = tempfile::tempdir().unwrap();
447        let ga = init_repo(a.path());
448        let gb = init_repo(b.path());
449
450        let id = ensure_repo_id(&ga).unwrap();
451        set_repo_id(&gb, &id).unwrap();
452
453        let sa = ObjectStore::open(&ga);
454        let oid = sa
455            .write(&gpp_core::Blob::new(b"hello sync".to_vec()))
456            .unwrap();
457        RefStore::open(&ga).write_ref("main", oid).unwrap();
458        std::fs::create_dir_all(ga.join("policies")).unwrap();
459        std::fs::write(ga.join("policies/secrets.policy"), "name=\"secrets\"\n").unwrap();
460
461        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
462        let addr = listener.local_addr().unwrap().to_string();
463        let gb2 = gb.clone();
464        let server = std::thread::spawn(move || {
465            let (s, _) = listener.accept().unwrap();
466            serve(s, &gb2, "peerA", SyncOptions::default()).unwrap()
467        });
468
469        let rep = connect(&addr, &ga, "peerB", SyncOptions::default()).unwrap();
470        let srv = server.join().unwrap();
471
472        assert_eq!(srv.objects_received, 1);
473        assert_eq!(srv.refs_adopted, 1);
474        assert_eq!(srv.policies_added, 1);
475        let sb = ObjectStore::open(&gb);
476        assert_eq!(
477            sb.read::<gpp_core::Blob>(&oid).unwrap().content,
478            b"hello sync"
479        );
480        assert_eq!(RefStore::open(&gb).read_ref("main").unwrap(), Some(oid));
481        assert!(gb.join("policies/secrets.policy").exists());
482        assert_eq!(rep.objects_received, 0);
483    }
484
485    #[test]
486    fn divergent_branch_becomes_a_fork() {
487        let a = tempfile::tempdir().unwrap();
488        let b = tempfile::tempdir().unwrap();
489        let ga = init_repo(a.path());
490        let gb = init_repo(b.path());
491        let id = ensure_repo_id(&ga).unwrap();
492        set_repo_id(&gb, &id).unwrap();
493
494        let oa = ObjectStore::open(&ga)
495            .write(&gpp_core::Blob::new(b"A tip".to_vec()))
496            .unwrap();
497        let ob = ObjectStore::open(&gb)
498            .write(&gpp_core::Blob::new(b"B tip".to_vec()))
499            .unwrap();
500        RefStore::open(&ga).write_ref("main", oa).unwrap();
501        RefStore::open(&gb).write_ref("main", ob).unwrap();
502
503        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
504        let addr = listener.local_addr().unwrap().to_string();
505        let gb2 = gb.clone();
506        let server = std::thread::spawn(move || {
507            let (s, _) = listener.accept().unwrap();
508            serve(s, &gb2, "A", SyncOptions::default()).unwrap()
509        });
510        connect(&addr, &ga, "B", SyncOptions::default()).unwrap();
511        server.join().unwrap();
512
513        let rb = RefStore::open(&gb);
514        assert_eq!(rb.read_ref("main").unwrap(), Some(ob));
515        assert_eq!(rb.read_ref("main.fork.A").unwrap(), Some(oa));
516    }
517
518    #[test]
519    fn unauthorized_static_key_is_rejected_before_data() {
520        let a = tempfile::tempdir().unwrap();
521        let b = tempfile::tempdir().unwrap();
522        let ga = init_repo(a.path());
523        let gb = init_repo(b.path());
524        let id = ensure_repo_id(&ga).unwrap();
525        set_repo_id(&gb, &id).unwrap();
526
527        // Give A a tip so that, if the gate failed open, objects would flow.
528        let oa = ObjectStore::open(&ga)
529            .write(&gpp_core::Blob::new(b"secret".to_vec()))
530            .unwrap();
531        RefStore::open(&ga).write_ref("main", oa).unwrap();
532
533        // Allowlist contains some other key, never A's.
534        let allow = vec!["00".repeat(32)];
535        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
536        let addr = listener.local_addr().unwrap().to_string();
537        let gb2 = gb.clone();
538        let server = std::thread::spawn(move || {
539            let (s, _) = listener.accept().unwrap();
540            serve_with_auth(s, &gb2, "A", SyncOptions::default(), Some(&allow))
541        });
542        let r = connect(&addr, &ga, "B", SyncOptions::default());
543        let srv = server.join().unwrap();
544
545        assert!(matches!(srv, Err(Error::Unauthorized(_))));
546        assert!(
547            r.is_err(),
548            "client should not complete against a rejecting server"
549        );
550        // Nothing was received: the rejection precedes the repo-id hello.
551        assert!(ObjectStore::open(&gb).read::<gpp_core::Blob>(&oa).is_err());
552        assert!(RefStore::open(&gb).read_ref("main").unwrap().is_none());
553    }
554
555    #[test]
556    fn authorized_static_key_is_accepted() {
557        let a = tempfile::tempdir().unwrap();
558        let b = tempfile::tempdir().unwrap();
559        let ga = init_repo(a.path());
560        let gb = init_repo(b.path());
561        let id = ensure_repo_id(&ga).unwrap();
562        set_repo_id(&gb, &id).unwrap();
563
564        let oa = ObjectStore::open(&ga)
565            .write(&gpp_core::Blob::new(b"hello".to_vec()))
566            .unwrap();
567        RefStore::open(&ga).write_ref("main", oa).unwrap();
568
569        // A's public static key, in the same hex form `known_peers` uses.
570        let (_, pub_a) = ensure_identity(&ga).unwrap();
571        let allow = vec![hex(&pub_a)];
572
573        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
574        let addr = listener.local_addr().unwrap().to_string();
575        let gb2 = gb.clone();
576        let server = std::thread::spawn(move || {
577            let (s, _) = listener.accept().unwrap();
578            serve_with_auth(s, &gb2, "A", SyncOptions::default(), Some(&allow)).unwrap()
579        });
580        connect(&addr, &ga, "B", SyncOptions::default()).unwrap();
581        let srv = server.join().unwrap();
582
583        assert_eq!(srv.objects_received, 1);
584        assert_eq!(RefStore::open(&gb).read_ref("main").unwrap(), Some(oa));
585    }
586
587    #[test]
588    fn repo_id_mismatch_is_rejected() {
589        let a = tempfile::tempdir().unwrap();
590        let b = tempfile::tempdir().unwrap();
591        let ga = init_repo(a.path());
592        let gb = init_repo(b.path());
593        ensure_repo_id(&ga).unwrap();
594        ensure_repo_id(&gb).unwrap();
595
596        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
597        let addr = listener.local_addr().unwrap().to_string();
598        let gb2 = gb.clone();
599        let server = std::thread::spawn(move || {
600            let (s, _) = listener.accept().unwrap();
601            serve(s, &gb2, "A", SyncOptions::default())
602        });
603        let r = connect(&addr, &ga, "B", SyncOptions::default());
604        let _ = server.join().unwrap();
605        assert!(matches!(r, Err(Error::RepoMismatch { .. })));
606    }
607}