1use std::collections::{BTreeSet, HashMap, VecDeque};
2use std::io::{self, BufRead, Write};
3use std::path::PathBuf;
4use std::sync::{Arc, Condvar, Mutex};
5use std::time::{Duration, Instant};
6
7use rns_core::msgpack::{self, Value};
8use rns_core::types::{DestHash, LinkId};
9use rns_net::{AnnouncedIdentity, Callbacks, PacketHash, RnsNode};
10
11use crate::config::ClientConfig;
12use crate::logging;
13use crate::protocol::{self, RefUpdate};
14use crate::util::{
15 default_reticulum_dir, default_rngit_dir, load_or_create_identity, parse_rns_url_with_aliases,
16};
17use crate::{git, Error, Result};
18
19const LINK_IDENTIFY_SETTLE_DELAY: Duration = Duration::from_millis(750);
20const NOT_IDENTIFIED_RETRY_DELAY: Duration = Duration::from_millis(750);
21const NOT_IDENTIFIED_RETRIES: usize = 5;
22
23pub fn main<I>(args: I) -> Result<()>
24where
25 I: IntoIterator<Item = String>,
26{
27 let options = ClientOptions::parse(args)?;
28 let rngit_dir = options.config_dir.unwrap_or_else(default_rngit_dir);
29 let rns_dir = options.rns_config_dir.or_else(default_reticulum_dir);
30 let config = ClientConfig::load_or_create_for_run(rngit_dir, rns_dir)?;
31 logging::init_file_logger(&config.dir.join("client_log"), config.log_level)?;
32 let (dest_hash, repository) =
33 parse_rns_url_with_aliases(&options.url, &config.destination_aliases)?;
34
35 let helper = RemoteHelper::connect(config, dest_hash)?;
36 helper.run(repository)
37}
38
39struct RemoteHelper {
40 client: SyncClient,
41 remote_refs: Mutex<HashMap<String, String>>,
42}
43
44impl RemoteHelper {
45 fn connect(config: ClientConfig, dest_hash: [u8; 16]) -> Result<Self> {
46 Ok(Self {
47 client: SyncClient::connect(config, dest_hash)?,
48 remote_refs: Mutex::new(HashMap::new()),
49 })
50 }
51
52 fn run(self, repository: String) -> Result<()> {
53 let stdin = io::stdin();
54 let stdout = io::stdout();
55 self.run_io(repository, stdin.lock(), stdout.lock())
56 }
57
58 fn run_io<R: BufRead, W: Write>(
59 &self,
60 repository: String,
61 mut input: R,
62 mut output: W,
63 ) -> Result<()> {
64 let mut line = String::new();
65 let mut fetch_refs = Vec::new();
66 let mut push_specs = Vec::new();
67
68 loop {
69 line.clear();
70 if input.read_line(&mut line)? == 0 {
71 break;
72 }
73 let command = line.trim_end();
74 if command.is_empty() {
75 let completed_batch = !fetch_refs.is_empty() || !push_specs.is_empty();
76 if !fetch_refs.is_empty() {
77 self.fetch(&repository, &fetch_refs)?;
78 fetch_refs.clear();
79 writeln!(output)?;
80 output.flush()?;
81 }
82 if !push_specs.is_empty() {
83 for spec in push_specs.drain(..) {
84 self.push(&repository, &spec)?;
85 writeln!(output, "ok {}", spec.remote)?;
86 }
87 writeln!(output)?;
88 output.flush()?;
89 }
90 if completed_batch {
91 break;
92 }
93 continue;
94 }
95
96 if command == "capabilities" {
97 writeln!(output, "option")?;
98 writeln!(output, "list")?;
99 writeln!(output, "fetch")?;
100 writeln!(output, "push")?;
101 writeln!(output)?;
102 } else if command == "list" || command == "list for-push" {
103 let refs = self.list(&repository)?;
104 output.write_all(&refs)?;
105 writeln!(output)?;
106 } else if let Some(rest) = command.strip_prefix("option ") {
107 let _ = rest;
108 writeln!(output, "ok")?;
109 } else if let Some(rest) = command.strip_prefix("fetch ") {
110 fetch_refs.push(parse_fetch_command(rest)?);
111 } else if let Some(rest) = command.strip_prefix("push ") {
112 push_specs.push(parse_push_spec(rest)?);
113 } else {
114 writeln!(output, "error unsupported command")?;
115 }
116 output.flush()?;
117 }
118 Ok(())
119 }
120
121 fn list(&self, repository: &str) -> Result<Vec<u8>> {
122 let response = self.request_with_ident_retry(
123 protocol::PATH_LIST,
124 protocol::repository_request(repository),
125 )?;
126 let bytes = protocol::response_bin(&response.data)?;
127 let refs = decode_status(bytes)?;
128 self.record_remote_refs(&refs);
129 Ok(refs)
130 }
131
132 fn fetch(&self, repository: &str, refs: &[FetchRef]) -> Result<()> {
133 let have = self.fetch_have_set(refs);
134 let requested_refs = refs
135 .iter()
136 .map(|r| (r.sha.clone(), r.name.clone()))
137 .collect::<Vec<_>>();
138 let response = self.request_with_ident_retry(
139 protocol::PATH_FETCH,
140 protocol::fetch_request_for_refs(repository, &have, &requested_refs),
141 )?;
142 if let Some(metadata) = response.metadata {
143 ensure_metadata_ok(&metadata)?;
144 let bundle = protocol::response_bin(&response.data)?;
145 git::fetch_bundle_into_local(
146 &bundle,
147 &refs.iter().map(|r| r.name.clone()).collect::<Vec<_>>(),
148 )?;
149 return Ok(());
150 }
151 let bytes = protocol::response_bin(&response.data)?;
152 let bundle = decode_status(bytes)?;
153 if !bundle.is_empty() {
154 git::fetch_bundle_into_local(
155 &bundle,
156 &refs.iter().map(|r| r.name.clone()).collect::<Vec<_>>(),
157 )?;
158 }
159 Ok(())
160 }
161
162 fn request_with_ident_retry(&self, path: &str, data: Vec<u8>) -> Result<Response> {
163 for attempt in 0..=NOT_IDENTIFIED_RETRIES {
164 let response = self.client.request(path, data.clone())?;
165 if response.metadata.is_none() {
166 if let Ok(bytes) = protocol::response_bin(&response.data) {
167 if is_not_identified_status(&bytes) && attempt < NOT_IDENTIFIED_RETRIES {
168 self.client.identify()?;
169 std::thread::sleep(NOT_IDENTIFIED_RETRY_DELAY);
170 continue;
171 }
172 }
173 }
174 return Ok(response);
175 }
176 unreachable!("not-identified retry loop always returns")
177 }
178
179 fn push(&self, repository: &str, spec: &PushSpec) -> Result<()> {
180 let mut updates = Vec::new();
181 let mut bundle_refs = Vec::new();
182 if spec.local.is_empty() {
183 updates.push(RefUpdate {
184 refname: spec.remote.clone(),
185 old: None,
186 new: None,
187 force: spec.force,
188 });
189 } else {
190 let sha = git::local_ref_sha(&spec.local)?
191 .ok_or_else(|| Error::msg(format!("unknown local ref {}", spec.local)))?;
192 bundle_refs.push(spec.local.clone());
193 updates.push(RefUpdate {
194 refname: spec.remote.clone(),
195 old: None,
196 new: Some(sha),
197 force: spec.force,
198 });
199 }
200
201 let exclusions = self.push_exclusion_set();
202 let bundle = git::create_local_bundle(&bundle_refs, &exclusions)?;
203 let response = self.client.request(
204 protocol::PATH_PUSH,
205 protocol::push_request(repository, bundle, updates),
206 )?;
207 let bytes = protocol::response_bin(&response.data)?;
208 let _ = decode_status(bytes)?;
209 Ok(())
210 }
211
212 fn record_remote_refs(&self, refs: &[u8]) {
213 *self.remote_refs.lock().unwrap() = parse_remote_refs(refs);
214 }
215
216 fn fetch_have_set(&self, refs: &[FetchRef]) -> Vec<String> {
217 let remote_refs = self.remote_refs.lock().unwrap();
218 build_fetch_have_set(
219 &remote_refs,
220 refs,
221 |refname| git::local_ref_sha(refname).ok().flatten(),
222 git::object_exists_local,
223 )
224 }
225
226 fn push_exclusion_set(&self) -> Vec<String> {
227 let remote_refs = self.remote_refs.lock().unwrap();
228 build_push_exclusion_set(&remote_refs, git::object_exists_local)
229 }
230}
231
232fn parse_remote_refs(refs: &[u8]) -> HashMap<String, String> {
233 let Ok(text) = std::str::from_utf8(refs) else {
234 return HashMap::new();
235 };
236 text.lines()
237 .filter_map(|line| {
238 let (sha, name) = line.split_once(' ')?;
239 (name != "HEAD").then(|| (name.to_string(), sha.to_string()))
240 })
241 .collect()
242}
243
244fn build_fetch_have_set(
245 remote_refs: &HashMap<String, String>,
246 refs: &[FetchRef],
247 resolve_local_ref: impl Fn(&str) -> Option<String>,
248 object_exists: impl Fn(&str) -> bool,
249) -> Vec<String> {
250 let mut have = BTreeSet::new();
251 for fetch_ref in refs {
252 if let Some(local_sha) = resolve_local_ref(&fetch_ref.name) {
253 if local_sha != fetch_ref.sha {
254 have.insert(local_sha);
255 }
256 }
257 }
258 for (refname, remote_sha) in remote_refs {
259 if object_exists(remote_sha) {
260 have.insert(remote_sha.clone());
261 continue;
262 }
263 if let Some(local_sha) = resolve_local_ref(refname) {
264 if &local_sha == remote_sha {
265 have.insert(local_sha);
266 }
267 }
268 }
269 have.into_iter().collect()
270}
271
272fn build_push_exclusion_set(
273 remote_refs: &HashMap<String, String>,
274 object_exists: impl Fn(&str) -> bool,
275) -> Vec<String> {
276 remote_refs
277 .values()
278 .filter(|sha| object_exists(sha))
279 .cloned()
280 .collect::<BTreeSet<_>>()
281 .into_iter()
282 .collect()
283}
284
285pub(crate) struct SyncClient {
286 node: RnsNode,
287 link_id: [u8; 16],
288 identity_private_key: [u8; 64],
289 state: Arc<(Mutex<ClientState>, Condvar)>,
290 request_timeout: Duration,
291}
292
293impl SyncClient {
294 pub(crate) fn connect(config: ClientConfig, dest_hash: [u8; 16]) -> Result<Self> {
295 let callbacks = SharedCallbacks::default();
296 let state = callbacks.state.clone();
297 let node = RnsNode::from_config(config.reticulum_dir.as_deref(), Box::new(callbacks))?;
298 let client_identity = load_or_create_identity(&config.identity_path)?;
299
300 let dest = DestHash(dest_hash);
301 eprintln!("Requesting path to {}...", crate::util::hex(&dest_hash));
302 node.request_path(&dest)
303 .map_err(|_| Error::msg("failed to request destination path"))?;
304 let deadline = Instant::now() + Duration::from_secs(config.connect_timeout_secs);
305 while !node.has_path(&dest).unwrap_or(false) && Instant::now() < deadline {
306 std::thread::sleep(Duration::from_millis(250));
307 }
308 if !node.has_path(&dest).unwrap_or(false) {
309 return Err(Error::msg("destination path resolution timed out"));
310 }
311 eprintln!("Path resolved.");
312
313 let recalled = node
314 .recall_identity(&dest)
315 .map_err(|_| Error::msg("failed to recall destination identity"))?
316 .ok_or_else(|| Error::msg("destination identity is unknown"))?;
317 let sig_pub: [u8; 32] = recalled.public_key[32..64].try_into().unwrap();
318 let link_id = node
319 .create_link(dest_hash, sig_pub)
320 .map_err(|_| Error::msg("failed to create RNS link"))?;
321 eprintln!("Establishing link...");
322 wait_for_link(
323 &state,
324 link_id,
325 Duration::from_secs(config.connect_timeout_secs),
326 )?;
327 eprintln!("Link established.");
328
329 let private_key = client_identity
330 .get_private_key()
331 .ok_or_else(|| Error::msg("client identity has no private key"))?;
332 std::thread::sleep(LINK_IDENTIFY_SETTLE_DELAY);
336 identify_on_link(&node, link_id, private_key)?;
337 std::thread::sleep(LINK_IDENTIFY_SETTLE_DELAY);
338 Ok(Self {
339 node,
340 link_id,
341 identity_private_key: private_key,
342 state,
343 request_timeout: Duration::from_secs(config.request_timeout_secs),
344 })
345 }
346
347 pub(crate) fn identify(&self) -> Result<()> {
348 identify_on_link(&self.node, self.link_id, self.identity_private_key)
349 }
350
351 pub(crate) fn request(&self, path: &str, data: Vec<u8>) -> Result<Response> {
352 self.request_with_timeout(path, data, self.request_timeout)
353 }
354
355 pub(crate) fn request_with_timeout(
356 &self,
357 path: &str,
358 data: Vec<u8>,
359 timeout: Duration,
360 ) -> Result<Response> {
361 {
362 let (lock, _) = &*self.state;
363 let mut state = lock.lock().unwrap();
364 state.responses.clear();
365 state.progress = ProgressState::default();
366 }
367 self.node
368 .send_request(self.link_id, path, &data)
369 .map_err(|_| Error::msg("failed to send request"))?;
370 let deadline = Instant::now() + timeout;
371 let (lock, cv) = &*self.state;
372 let mut state = lock.lock().unwrap();
373 loop {
374 if let Some(response) = state.responses.pop_front() {
375 return Ok(response);
376 }
377 let now = Instant::now();
378 if now >= deadline {
379 return Err(Error::msg("request timed out"));
380 }
381 let wait = deadline.saturating_duration_since(now);
382 let (next, _) = cv.wait_timeout(state, wait).unwrap();
383 state = next;
384 }
385 }
386}
387
388fn identify_on_link(node: &RnsNode, link_id: [u8; 16], private_key: [u8; 64]) -> Result<()> {
389 node.identify_on_link(link_id, private_key)
390 .map_err(|_| Error::msg("failed to identify on RNS link"))
391}
392
393#[derive(Default)]
394struct SharedCallbacks {
395 state: Arc<(Mutex<ClientState>, Condvar)>,
396}
397
398#[derive(Default)]
399struct ClientState {
400 established: Vec<[u8; 16]>,
401 responses: VecDeque<Response>,
402 progress: ProgressState,
403}
404
405#[derive(Default)]
406struct ProgressState {
407 started_at: Option<Instant>,
408 last_percent: Option<usize>,
409}
410
411#[derive(Debug, Clone)]
412pub(crate) struct Response {
413 pub(crate) data: Vec<u8>,
414 pub(crate) metadata: Option<Vec<u8>>,
415}
416
417impl Callbacks for SharedCallbacks {
418 fn on_announce(&mut self, _announced: AnnouncedIdentity) {}
419
420 fn on_path_updated(&mut self, _dest_hash: DestHash, _hops: u8) {}
421
422 fn on_local_delivery(&mut self, _dest_hash: DestHash, _raw: Vec<u8>, _packet_hash: PacketHash) {
423 }
424
425 fn on_link_established(
426 &mut self,
427 link_id: LinkId,
428 _dest_hash: DestHash,
429 _rtt: f64,
430 _is_initiator: bool,
431 ) {
432 let (lock, cv) = &*self.state;
433 lock.lock().unwrap().established.push(link_id.0);
434 cv.notify_all();
435 }
436
437 fn on_response_with_metadata(
438 &mut self,
439 _link_id: LinkId,
440 _request_id: [u8; 16],
441 data: Vec<u8>,
442 metadata: Option<Vec<u8>>,
443 ) {
444 let (lock, cv) = &*self.state;
445 lock.lock()
446 .unwrap()
447 .responses
448 .push_back(Response { data, metadata });
449 cv.notify_all();
450 }
451
452 fn on_resource_progress(&mut self, _link_id: LinkId, received: usize, total: usize) {
453 if total == 0 {
454 return;
455 }
456 let (lock, _) = &*self.state;
457 let mut state = lock.lock().unwrap();
458 let progress = &mut state.progress;
459 let started_at = *progress.started_at.get_or_insert_with(Instant::now);
460 let percent = ((received.saturating_mul(100)) / total).min(100);
461 if progress.last_percent == Some(percent) && received < total {
462 return;
463 }
464 progress.last_percent = Some(percent);
465 let elapsed = started_at.elapsed().as_secs_f64().max(0.001);
466 let rate = received as f64 / elapsed;
467 eprintln!("rns: transfer {percent}% ({received}/{total} parts, {rate:.1} parts/s)");
468 if received >= total {
469 progress.started_at = None;
470 progress.last_percent = None;
471 }
472 }
473}
474
475fn wait_for_link(
476 state: &Arc<(Mutex<ClientState>, Condvar)>,
477 link_id: [u8; 16],
478 timeout: Duration,
479) -> Result<()> {
480 let deadline = Instant::now() + timeout;
481 let (lock, cv) = &**state;
482 let mut state = lock.lock().unwrap();
483 loop {
484 if state.established.contains(&link_id) {
485 return Ok(());
486 }
487 let now = Instant::now();
488 if now >= deadline {
489 return Err(Error::msg("link establishment timed out"));
490 }
491 let (next, _) = cv
492 .wait_timeout(state, deadline.saturating_duration_since(now))
493 .unwrap();
494 state = next;
495 }
496}
497
498fn is_not_identified_status(bytes: &[u8]) -> bool {
499 matches!(
500 bytes.split_first(),
501 Some((&protocol::RES_DISALLOWED, body)) if body == b"Not identified"
502 )
503}
504
505pub(crate) fn decode_status(bytes: Vec<u8>) -> Result<Vec<u8>> {
506 let Some((&code, body)) = bytes.split_first() else {
507 return Err(Error::msg("empty response"));
508 };
509 if code == protocol::RES_OK {
510 Ok(body.to_vec())
511 } else {
512 Err(Error::msg(format!(
513 "remote returned status 0x{code:02x}: {}",
514 String::from_utf8_lossy(body)
515 )))
516 }
517}
518
519pub(crate) fn ensure_metadata_ok(metadata: &[u8]) -> Result<()> {
520 let value = msgpack::unpack_exact(metadata)
521 .map_err(|e| Error::msg(format!("invalid response metadata: {e}")))?;
522 let Some(map) = value.as_map() else {
523 return Err(Error::msg("response metadata is not a map"));
524 };
525 let code = map.iter().find_map(|(key, value)| {
526 if matches!(key, Value::UInt(v) if *v == protocol::IDX_RESULT_CODE) {
527 value.as_uint().map(|v| v as u8)
528 } else {
529 None
530 }
531 });
532 match code {
533 Some(protocol::RES_OK) => Ok(()),
534 Some(code) => Err(Error::msg(format!("remote returned status 0x{code:02x}"))),
535 None => Err(Error::msg("response metadata missing status code")),
536 }
537}
538
539#[derive(Debug, Clone, PartialEq, Eq)]
540struct FetchRef {
541 sha: String,
542 name: String,
543}
544
545fn parse_fetch_command(input: &str) -> Result<FetchRef> {
546 let mut parts = input.split_whitespace();
547 let sha = parts
548 .next()
549 .ok_or_else(|| Error::msg("fetch command missing sha"))?;
550 let name = parts
551 .next()
552 .ok_or_else(|| Error::msg("fetch command missing ref"))?;
553 Ok(FetchRef {
554 sha: sha.to_string(),
555 name: name.to_string(),
556 })
557}
558
559#[derive(Debug, Clone, PartialEq, Eq)]
560struct PushSpec {
561 local: String,
562 remote: String,
563 force: bool,
564}
565
566fn parse_push_spec(input: &str) -> Result<PushSpec> {
567 let (force, spec) = input
568 .strip_prefix('+')
569 .map(|s| (true, s))
570 .unwrap_or((false, input));
571 let (local, remote) = spec
572 .split_once(':')
573 .ok_or_else(|| Error::msg("push spec must be <local>:<remote>"))?;
574 Ok(PushSpec {
575 local: local.to_string(),
576 remote: remote.to_string(),
577 force,
578 })
579}
580
581#[derive(Debug, Default)]
582struct ClientOptions {
583 config_dir: Option<PathBuf>,
584 rns_config_dir: Option<PathBuf>,
585 url: String,
586}
587
588impl ClientOptions {
589 fn parse<I>(args: I) -> Result<Self>
590 where
591 I: IntoIterator<Item = String>,
592 {
593 let mut options = ClientOptions::default();
594 let mut positional = Vec::new();
595 let mut args = args.into_iter();
596 while let Some(arg) = args.next() {
597 match arg.as_str() {
598 "-c" | "--config" => {
599 options.config_dir = Some(PathBuf::from(
600 args.next()
601 .ok_or_else(|| Error::msg("missing config path"))?,
602 ));
603 }
604 "--rnsconfig" => {
605 options.rns_config_dir = Some(PathBuf::from(
606 args.next()
607 .ok_or_else(|| Error::msg("missing RNS config path"))?,
608 ));
609 }
610 "-h" | "--help" => return Err(Error::msg(usage())),
611 other => positional.push(other.to_string()),
612 }
613 }
614 options.url = positional
615 .last()
616 .cloned()
617 .ok_or_else(|| Error::msg(usage()))?;
618 Ok(options)
619 }
620}
621
622fn usage() -> &'static str {
623 "usage: git-remote-rns [--config DIR] [--rnsconfig DIR] <remote-name> <rns://destination/repo>"
624}
625
626#[cfg(test)]
627mod tests {
628 use super::*;
629 use std::io::{BufRead, BufReader};
630 use std::process::{Child, Command, Stdio};
631 use std::sync::mpsc;
632
633 #[test]
634 fn parses_fetch_command() {
635 assert_eq!(
636 parse_fetch_command("abc refs/heads/main").unwrap(),
637 FetchRef {
638 sha: "abc".into(),
639 name: "refs/heads/main".into()
640 }
641 );
642 }
643
644 #[test]
645 fn parses_forced_push_spec() {
646 assert_eq!(
647 parse_push_spec("+refs/heads/main:refs/heads/main").unwrap(),
648 PushSpec {
649 local: "refs/heads/main".into(),
650 remote: "refs/heads/main".into(),
651 force: true
652 }
653 );
654 }
655
656 #[test]
657 fn decodes_status_payload() {
658 assert_eq!(
659 decode_status(protocol::status_bytes(protocol::RES_OK, b"refs")).unwrap(),
660 b"refs"
661 );
662 assert!(decode_status(protocol::status_bytes(protocol::RES_NOT_FOUND, b"no")).is_err());
663 }
664
665 #[test]
666 fn metadata_status_ok_is_accepted() {
667 assert!(ensure_metadata_ok(&protocol::metadata_status(protocol::RES_OK)).is_ok());
668 assert!(ensure_metadata_ok(&protocol::metadata_status(protocol::RES_REMOTE_FAIL)).is_err());
669 }
670
671 #[test]
672 fn parses_remote_refs_and_ignores_head() {
673 let refs = parse_remote_refs(
674 b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\nbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb refs/heads/main\nmalformed\n",
675 );
676
677 assert_eq!(refs.len(), 1);
678 assert_eq!(
679 refs["refs/heads/main"],
680 "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
681 );
682 }
683
684 #[test]
685 fn fetch_have_set_uses_per_ref_and_global_matching_haves() {
686 let remote_refs = HashMap::from([
687 (
688 "refs/heads/main".to_string(),
689 "1111111111111111111111111111111111111111".to_string(),
690 ),
691 (
692 "refs/tags/v1".to_string(),
693 "2222222222222222222222222222222222222222".to_string(),
694 ),
695 ]);
696 let fetch_refs = vec![FetchRef {
697 sha: "3333333333333333333333333333333333333333".into(),
698 name: "refs/heads/feature".into(),
699 }];
700
701 let have = build_fetch_have_set(
702 &remote_refs,
703 &fetch_refs,
704 |name| match name {
705 "refs/heads/main" => Some("1111111111111111111111111111111111111111".into()),
706 "refs/tags/v1" => Some("not-remote".into()),
707 "refs/heads/feature" => Some("4444444444444444444444444444444444444444".into()),
708 _ => None,
709 },
710 |sha| sha == "2222222222222222222222222222222222222222",
711 );
712
713 assert_eq!(
714 have,
715 vec![
716 "1111111111111111111111111111111111111111",
717 "2222222222222222222222222222222222222222",
718 "4444444444444444444444444444444444444444"
719 ]
720 );
721 }
722
723 #[test]
724 fn fetch_have_set_includes_existing_remote_objects_without_matching_refs() {
725 let remote_refs = HashMap::from([(
726 "refs/tags/v1".to_string(),
727 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
728 )]);
729
730 let have = build_fetch_have_set(
731 &remote_refs,
732 &[],
733 |_| None,
734 |sha| sha == "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
735 );
736
737 assert_eq!(have, vec!["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]);
738 }
739
740 #[test]
741 fn push_exclusion_set_keeps_only_local_known_remote_objects() {
742 let remote_refs = HashMap::from([
743 ("refs/heads/main".to_string(), "aaaa".to_string()),
744 ("refs/heads/dev".to_string(), "bbbb".to_string()),
745 ("refs/tags/v1".to_string(), "aaaa".to_string()),
746 ]);
747
748 let exclusions = build_push_exclusion_set(&remote_refs, |sha| sha == "aaaa");
749
750 assert_eq!(exclusions, vec!["aaaa"]);
751 }
752
753 #[test]
754 fn sync_client_reidentifies_after_not_identified_response() {
755 let _guard = python_rns_test_guard();
756 if !python_rns_available() {
757 eprintln!("Skipping: Python RNS not available");
758 return;
759 }
760
761 let mut python = PythonRngitPeer::spawn_reidentify_required();
762 let ready = python.wait_for_ready();
763 let tmp = tempfile::tempdir().unwrap();
764 let rns_dir = tmp.path().join("client-rns");
765 std::fs::create_dir_all(&rns_dir).unwrap();
766 std::fs::write(rns_dir.join("config"), client_rns_config(ready.port)).unwrap();
767
768 let client = SyncClient::connect(
769 ClientConfig {
770 dir: tmp.path().join("rngit"),
771 reticulum_dir: Some(rns_dir),
772 identity_path: tmp.path().join("client_identity"),
773 connect_timeout_secs: 10,
774 request_timeout_secs: 10,
775 destination_aliases: Default::default(),
776 log_level: crate::logging::DEFAULT_LOG_LEVEL,
777 },
778 parse_hex_16(&ready.dest_hash),
779 )
780 .expect("Rust client should connect to Python Reticulum peer");
781 let helper = RemoteHelper {
782 client,
783 remote_refs: Mutex::new(HashMap::new()),
784 };
785
786 let response = helper
787 .request_with_ident_retry(
788 protocol::PATH_LIST,
789 protocol::repository_request("group/repo"),
790 )
791 .expect("Rust client should re-identify and retry the request");
792 let response = protocol::response_bin(&response.data).unwrap();
793 let refs = String::from_utf8(decode_status(response).unwrap()).unwrap();
794 assert_eq!(
795 refs,
796 "1111111111111111111111111111111111111111 refs/heads/main\n"
797 );
798
799 let first = python.wait_for_event("REQUEST");
800 assert!(
801 first.ends_with(" 1 1"),
802 "unexpected first request event: {first}"
803 );
804 let second = python.wait_for_event("REQUEST");
805 assert!(
806 second.ends_with(" 2 2"),
807 "retry happened without a second LINKIDENTIFY: {second}"
808 );
809 }
810
811 #[test]
812 fn sync_client_identifies_before_first_python_request() {
813 let _guard = python_rns_test_guard();
814 if !python_rns_available() {
815 eprintln!("Skipping: Python RNS not available");
816 return;
817 }
818
819 let mut python = PythonRngitPeer::spawn();
820 let ready = python.wait_for_ready();
821 let tmp = tempfile::tempdir().unwrap();
822 let rns_dir = tmp.path().join("client-rns");
823 std::fs::create_dir_all(&rns_dir).unwrap();
824 std::fs::write(rns_dir.join("config"), client_rns_config(ready.port)).unwrap();
825
826 let client = SyncClient::connect(
827 ClientConfig {
828 dir: tmp.path().join("rngit"),
829 reticulum_dir: Some(rns_dir),
830 identity_path: tmp.path().join("client_identity"),
831 connect_timeout_secs: 10,
832 request_timeout_secs: 10,
833 destination_aliases: Default::default(),
834 log_level: crate::logging::DEFAULT_LOG_LEVEL,
835 },
836 parse_hex_16(&ready.dest_hash),
837 )
838 .expect("Rust client should connect to Python Reticulum peer");
839
840 let response = client
841 .request(
842 protocol::PATH_LIST,
843 protocol::repository_request("group/repo"),
844 )
845 .expect("Python peer should answer the first request");
846 let response = protocol::response_bin(&response.data).unwrap();
847 let refs = String::from_utf8(decode_status(response).unwrap()).unwrap();
848 assert_eq!(
849 refs,
850 "1111111111111111111111111111111111111111 refs/heads/main\n"
851 );
852
853 let request = python.wait_for_event("REQUEST");
854 let remote = request
855 .split_whitespace()
856 .nth(2)
857 .expect("REQUEST event should include remote identity hash");
858 assert_ne!(remote, "none", "Python saw an unidentified first request");
859 }
860
861 struct PythonReady {
862 port: u16,
863 dest_hash: String,
864 }
865
866 struct PythonRngitPeer {
867 child: Child,
868 events: mpsc::Receiver<String>,
869 }
870
871 impl PythonRngitPeer {
872 fn spawn() -> Self {
873 Self::spawn_with_reidentify_required(false)
874 }
875
876 fn spawn_reidentify_required() -> Self {
877 Self::spawn_with_reidentify_required(true)
878 }
879
880 fn spawn_with_reidentify_required(reidentify_required: bool) -> Self {
881 let mut command = Command::new("python3");
882 command.args(["-c", PYTHON_RNGIT_PEER_SCRIPT]);
883 if reidentify_required {
884 command.env("RNGIT_TEST_REQUIRE_REIDENTIFY", "1");
885 }
886 let mut child = command
887 .stdin(Stdio::piped())
888 .stdout(Stdio::piped())
889 .stderr(Stdio::piped())
890 .spawn()
891 .expect("failed to start Python RNS process");
892
893 let stdout = child.stdout.take().unwrap();
894 let stderr = child.stderr.take().unwrap();
895 let (tx, rx) = mpsc::channel();
896 std::thread::spawn(move || {
897 for line in BufReader::new(stdout).lines().map_while(|line| line.ok()) {
898 let _ = tx.send(line);
899 }
900 });
901 std::thread::spawn(move || {
902 for line in BufReader::new(stderr).lines().map_while(|line| line.ok()) {
903 eprintln!("python stderr: {line}");
904 }
905 });
906
907 Self { child, events: rx }
908 }
909
910 fn wait_for_ready(&mut self) -> PythonReady {
911 let event = self.wait_for_event("READY");
912 let mut parts = event.split_whitespace();
913 assert_eq!(parts.next(), Some("READY"));
914 let port = parts.next().unwrap().parse().unwrap();
915 let dest_hash = parts.next().unwrap().to_string();
916 PythonReady { port, dest_hash }
917 }
918
919 fn wait_for_event(&mut self, prefix: &str) -> String {
920 let deadline = Instant::now() + Duration::from_secs(10);
921 loop {
922 let remaining = deadline
923 .checked_duration_since(Instant::now())
924 .expect("timed out waiting for Python event");
925 let event = self
926 .events
927 .recv_timeout(remaining)
928 .expect("timed out waiting for Python event");
929 if event.starts_with(prefix) {
930 return event;
931 }
932 }
933 }
934 }
935
936 impl Drop for PythonRngitPeer {
937 fn drop(&mut self) {
938 if let Some(stdin) = self.child.stdin.as_mut() {
939 use std::io::Write;
940 let _ = writeln!(stdin, "stop");
941 let _ = stdin.flush();
942 }
943 let _ = self.child.kill();
944 let _ = self.child.wait();
945 }
946 }
947
948 static PYTHON_RNS_TEST_LOCK: std::sync::OnceLock<std::sync::Mutex<()>> =
949 std::sync::OnceLock::new();
950
951 fn python_rns_test_guard() -> std::sync::MutexGuard<'static, ()> {
952 PYTHON_RNS_TEST_LOCK
953 .get_or_init(|| std::sync::Mutex::new(()))
954 .lock()
955 .unwrap()
956 }
957
958 fn python_rns_available() -> bool {
959 Command::new("python3")
960 .args(["-c", "import RNS; print('ok')"])
961 .stdout(Stdio::null())
962 .stderr(Stdio::null())
963 .status()
964 .map(|status| status.success())
965 .unwrap_or(false)
966 }
967
968 fn parse_hex_16(input: &str) -> [u8; 16] {
969 assert_eq!(input.len(), 32, "expected 16-byte hex string");
970 let mut out = [0u8; 16];
971 for i in 0..16 {
972 out[i] = u8::from_str_radix(&input[i * 2..i * 2 + 2], 16).unwrap();
973 }
974 out
975 }
976
977 fn client_rns_config(port: u16) -> String {
978 format!(
979 r#"[reticulum]
980 enable_transport = No
981 share_instance = No
982 panic_on_interface_error = Yes
983
984[interfaces]
985 [[Python RNGit Test TCP]]
986 type = TCPClientInterface
987 target_host = 127.0.0.1
988 target_port = {port}
989 mode = full
990"#
991 )
992 }
993
994 const PYTHON_RNGIT_PEER_SCRIPT: &str = r#"
995import os
996import signal
997import socket
998import sys
999import tempfile
1000import threading
1001import time
1002
1003sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
1004sock.bind(("127.0.0.1", 0))
1005port = sock.getsockname()[1]
1006sock.close()
1007
1008config_dir = tempfile.mkdtemp()
1009with open(os.path.join(config_dir, "config"), "w") as f:
1010 f.write(f"""[reticulum]
1011 enable_transport = false
1012 share_instance = no
1013
1014[interfaces]
1015 [[TCP Server Interface]]
1016 type = TCPServerInterface
1017 interface_enabled = true
1018 listen_ip = 127.0.0.1
1019 listen_port = {port}
1020 mode = full
1021""")
1022
1023import RNS
1024
1025running = True
1026require_reidentify = os.environ.get("RNGIT_TEST_REQUIRE_REIDENTIFY") == "1"
1027identifications = 0
1028requests = 0
1029reticulum = RNS.Reticulum(configdir=config_dir)
1030identity = RNS.Identity()
1031destination = RNS.Destination(identity, RNS.Destination.IN, RNS.Destination.SINGLE, "git", "repositories")
1032
1033def emit(*parts):
1034 print(" ".join(str(part) for part in parts), flush=True)
1035
1036def list_refs(path, data, request_id, link_id, remote_identity, requested_at):
1037 global requests
1038 requests += 1
1039 remote = remote_identity.hash.hex() if remote_identity is not None else "none"
1040 emit("REQUEST", path, remote, requests, identifications)
1041 if remote_identity is None:
1042 return b"\x01Not identified"
1043 if require_reidentify and identifications < 2:
1044 return b"\x01Not identified"
1045 return b"\x001111111111111111111111111111111111111111 refs/heads/main\n"
1046
1047def identified(link, remote_identity):
1048 global identifications
1049 identifications += 1
1050 emit("IDENTIFIED", remote_identity.hash.hex(), identifications)
1051
1052def link_established(link):
1053 emit("LINK_ESTABLISHED")
1054 link.set_remote_identified_callback(identified)
1055
1056destination.register_request_handler("/git/list", response_generator=list_refs, allow=RNS.Destination.ALLOW_ALL)
1057destination.set_link_established_callback(link_established)
1058
1059def announce_loop():
1060 while running:
1061 destination.announce()
1062 time.sleep(0.25)
1063
1064threading.Thread(target=announce_loop, daemon=True).start()
1065emit("READY", port, destination.hash.hex())
1066
1067signal.signal(signal.SIGTERM, lambda *a: sys.exit(0))
1068try:
1069 for line in sys.stdin:
1070 if line.strip() == "stop":
1071 break
1072finally:
1073 running = False
1074"#;
1075}