1#![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
47fn sync_dir(gpp: &Path) -> PathBuf {
50 gpp.join("sync")
51}
52
53pub 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
69pub 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
94fn 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#[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)>, refs: BTreeMap<String, String>,
132 policies: Vec<(String, String)>, 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
161fn 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
206fn 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
262fn 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 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
352pub 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 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
376pub 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
388pub 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 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 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 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 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 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}