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 configured_timeout = Duration::from_secs(config.connect_timeout_secs);
305 let medium_timeout =
306 Duration::from_secs_f64(node.medium_path_timeout().unwrap_or(0.0).max(0.0));
307 let deadline = Instant::now() + configured_timeout.max(medium_timeout);
308 while !node.has_path(&dest).unwrap_or(false) && Instant::now() < deadline {
309 std::thread::sleep(Duration::from_millis(250));
310 }
311 if !node.has_path(&dest).unwrap_or(false) {
312 return Err(Error::msg("destination path resolution timed out"));
313 }
314 eprintln!("Path resolved.");
315
316 let recalled = node
317 .recall_identity(&dest)
318 .map_err(|_| Error::msg("failed to recall destination identity"))?
319 .ok_or_else(|| Error::msg("destination identity is unknown"))?;
320 let sig_pub: [u8; 32] = recalled.public_key[32..64].try_into().unwrap();
321 let link_id = node
322 .create_link(dest_hash, sig_pub)
323 .map_err(|_| Error::msg("failed to create RNS link"))?;
324 eprintln!("Establishing link...");
325 let hops = node.hops_to(&dest).unwrap_or(None).unwrap_or(1);
326 let link_timeout =
327 Duration::from_secs_f64(rns_core::link::keepalive::compute_establishment_timeout(
328 rns_core::constants::LINK_ESTABLISHMENT_TIMEOUT_PER_HOP,
329 hops,
330 ));
331 wait_for_link(&state, link_id, configured_timeout.max(link_timeout))?;
332 eprintln!("Link established.");
333
334 let private_key = client_identity
335 .get_private_key()
336 .ok_or_else(|| Error::msg("client identity has no private key"))?;
337 std::thread::sleep(LINK_IDENTIFY_SETTLE_DELAY);
341 identify_on_link(&node, link_id, private_key)?;
342 std::thread::sleep(LINK_IDENTIFY_SETTLE_DELAY);
343 Ok(Self {
344 node,
345 link_id,
346 identity_private_key: private_key,
347 state,
348 request_timeout: Duration::from_secs(config.request_timeout_secs),
349 })
350 }
351
352 pub(crate) fn identify(&self) -> Result<()> {
353 identify_on_link(&self.node, self.link_id, self.identity_private_key)
354 }
355
356 pub(crate) fn request(&self, path: &str, data: Vec<u8>) -> Result<Response> {
357 self.request_with_timeout(path, data, self.request_timeout)
358 }
359
360 pub(crate) fn request_with_timeout(
361 &self,
362 path: &str,
363 data: Vec<u8>,
364 timeout: Duration,
365 ) -> Result<Response> {
366 {
367 let (lock, _) = &*self.state;
368 let mut state = lock.lock().unwrap();
369 state.responses.clear();
370 state.progress = ProgressState::default();
371 }
372 self.node
373 .send_request(self.link_id, path, &data)
374 .map_err(|_| Error::msg("failed to send request"))?;
375 let deadline = Instant::now() + timeout;
376 let (lock, cv) = &*self.state;
377 let mut state = lock.lock().unwrap();
378 loop {
379 if let Some(response) = state.responses.pop_front() {
380 return Ok(response);
381 }
382 let now = Instant::now();
383 if now >= deadline {
384 return Err(Error::msg("request timed out"));
385 }
386 let wait = deadline.saturating_duration_since(now);
387 let (next, _) = cv.wait_timeout(state, wait).unwrap();
388 state = next;
389 }
390 }
391}
392
393fn identify_on_link(node: &RnsNode, link_id: [u8; 16], private_key: [u8; 64]) -> Result<()> {
394 node.identify_on_link(link_id, private_key)
395 .map_err(|_| Error::msg("failed to identify on RNS link"))
396}
397
398#[derive(Default)]
399struct SharedCallbacks {
400 state: Arc<(Mutex<ClientState>, Condvar)>,
401}
402
403#[derive(Default)]
404struct ClientState {
405 established: Vec<[u8; 16]>,
406 responses: VecDeque<Response>,
407 progress: ProgressState,
408}
409
410#[derive(Default)]
411struct ProgressState {
412 started_at: Option<Instant>,
413 last_percent: Option<usize>,
414}
415
416#[derive(Debug, Clone)]
417pub(crate) struct Response {
418 pub(crate) data: Vec<u8>,
419 pub(crate) metadata: Option<Vec<u8>>,
420}
421
422impl Callbacks for SharedCallbacks {
423 fn on_announce(&mut self, _announced: AnnouncedIdentity) {}
424
425 fn on_path_updated(&mut self, _dest_hash: DestHash, _hops: u8) {}
426
427 fn on_local_delivery(&mut self, _dest_hash: DestHash, _raw: Vec<u8>, _packet_hash: PacketHash) {
428 }
429
430 fn on_link_established(
431 &mut self,
432 link_id: LinkId,
433 _dest_hash: DestHash,
434 _rtt: f64,
435 _is_initiator: bool,
436 ) {
437 let (lock, cv) = &*self.state;
438 lock.lock().unwrap().established.push(link_id.0);
439 cv.notify_all();
440 }
441
442 fn on_response_with_metadata(
443 &mut self,
444 _link_id: LinkId,
445 _request_id: [u8; 16],
446 data: Vec<u8>,
447 metadata: Option<Vec<u8>>,
448 ) {
449 let (lock, cv) = &*self.state;
450 lock.lock()
451 .unwrap()
452 .responses
453 .push_back(Response { data, metadata });
454 cv.notify_all();
455 }
456
457 fn on_resource_progress(&mut self, _link_id: LinkId, received: usize, total: usize) {
458 if total == 0 {
459 return;
460 }
461 let (lock, _) = &*self.state;
462 let mut state = lock.lock().unwrap();
463 let progress = &mut state.progress;
464 let started_at = *progress.started_at.get_or_insert_with(Instant::now);
465 let percent = ((received.saturating_mul(100)) / total).min(100);
466 if progress.last_percent == Some(percent) && received < total {
467 return;
468 }
469 progress.last_percent = Some(percent);
470 let elapsed = started_at.elapsed().as_secs_f64().max(0.001);
471 let rate = received as f64 / elapsed;
472 eprintln!("rns: transfer {percent}% ({received}/{total} parts, {rate:.1} parts/s)");
473 if received >= total {
474 progress.started_at = None;
475 progress.last_percent = None;
476 }
477 }
478}
479
480fn wait_for_link(
481 state: &Arc<(Mutex<ClientState>, Condvar)>,
482 link_id: [u8; 16],
483 timeout: Duration,
484) -> Result<()> {
485 let deadline = Instant::now() + timeout;
486 let (lock, cv) = &**state;
487 let mut state = lock.lock().unwrap();
488 loop {
489 if state.established.contains(&link_id) {
490 return Ok(());
491 }
492 let now = Instant::now();
493 if now >= deadline {
494 return Err(Error::msg("link establishment timed out"));
495 }
496 let (next, _) = cv
497 .wait_timeout(state, deadline.saturating_duration_since(now))
498 .unwrap();
499 state = next;
500 }
501}
502
503fn is_not_identified_status(bytes: &[u8]) -> bool {
504 matches!(
505 bytes.split_first(),
506 Some((&protocol::RES_DISALLOWED, body)) if body == b"Not identified"
507 )
508}
509
510pub(crate) fn decode_status(bytes: Vec<u8>) -> Result<Vec<u8>> {
511 let Some((&code, body)) = bytes.split_first() else {
512 return Err(Error::msg("empty response"));
513 };
514 if code == protocol::RES_OK {
515 Ok(body.to_vec())
516 } else {
517 Err(Error::msg(format!(
518 "remote returned status 0x{code:02x}: {}",
519 String::from_utf8_lossy(body)
520 )))
521 }
522}
523
524pub(crate) fn ensure_metadata_ok(metadata: &[u8]) -> Result<()> {
525 let value = msgpack::unpack_exact(metadata)
526 .map_err(|e| Error::msg(format!("invalid response metadata: {e}")))?;
527 let Some(map) = value.as_map() else {
528 return Err(Error::msg("response metadata is not a map"));
529 };
530 let code = map.iter().find_map(|(key, value)| {
531 if matches!(key, Value::UInt(v) if *v == protocol::IDX_RESULT_CODE) {
532 value.as_uint().map(|v| v as u8)
533 } else {
534 None
535 }
536 });
537 match code {
538 Some(protocol::RES_OK) => Ok(()),
539 Some(code) => Err(Error::msg(format!("remote returned status 0x{code:02x}"))),
540 None => Err(Error::msg("response metadata missing status code")),
541 }
542}
543
544#[derive(Debug, Clone, PartialEq, Eq)]
545struct FetchRef {
546 sha: String,
547 name: String,
548}
549
550fn parse_fetch_command(input: &str) -> Result<FetchRef> {
551 let mut parts = input.split_whitespace();
552 let sha = parts
553 .next()
554 .ok_or_else(|| Error::msg("fetch command missing sha"))?;
555 let name = parts
556 .next()
557 .ok_or_else(|| Error::msg("fetch command missing ref"))?;
558 Ok(FetchRef {
559 sha: sha.to_string(),
560 name: name.to_string(),
561 })
562}
563
564#[derive(Debug, Clone, PartialEq, Eq)]
565struct PushSpec {
566 local: String,
567 remote: String,
568 force: bool,
569}
570
571fn parse_push_spec(input: &str) -> Result<PushSpec> {
572 let (force, spec) = input
573 .strip_prefix('+')
574 .map(|s| (true, s))
575 .unwrap_or((false, input));
576 let (local, remote) = spec
577 .split_once(':')
578 .ok_or_else(|| Error::msg("push spec must be <local>:<remote>"))?;
579 Ok(PushSpec {
580 local: local.to_string(),
581 remote: remote.to_string(),
582 force,
583 })
584}
585
586#[derive(Debug, Default)]
587struct ClientOptions {
588 config_dir: Option<PathBuf>,
589 rns_config_dir: Option<PathBuf>,
590 url: String,
591}
592
593impl ClientOptions {
594 fn parse<I>(args: I) -> Result<Self>
595 where
596 I: IntoIterator<Item = String>,
597 {
598 let mut options = ClientOptions::default();
599 let mut positional = Vec::new();
600 let mut args = args.into_iter();
601 while let Some(arg) = args.next() {
602 match arg.as_str() {
603 "-c" | "--config" => {
604 options.config_dir = Some(PathBuf::from(
605 args.next()
606 .ok_or_else(|| Error::msg("missing config path"))?,
607 ));
608 }
609 "--rnsconfig" => {
610 options.rns_config_dir = Some(PathBuf::from(
611 args.next()
612 .ok_or_else(|| Error::msg("missing RNS config path"))?,
613 ));
614 }
615 "-h" | "--help" => return Err(Error::msg(usage())),
616 other => positional.push(other.to_string()),
617 }
618 }
619 options.url = positional
620 .last()
621 .cloned()
622 .ok_or_else(|| Error::msg(usage()))?;
623 Ok(options)
624 }
625}
626
627fn usage() -> &'static str {
628 "usage: git-remote-rns [--config DIR] [--rnsconfig DIR] <remote-name> <rns://destination/repo>"
629}
630
631#[cfg(test)]
632mod tests {
633 use super::*;
634 use std::io::{BufRead, BufReader};
635 use std::process::{Child, Command, Stdio};
636 use std::sync::mpsc;
637
638 #[test]
639 fn parses_fetch_command() {
640 assert_eq!(
641 parse_fetch_command("abc refs/heads/main").unwrap(),
642 FetchRef {
643 sha: "abc".into(),
644 name: "refs/heads/main".into()
645 }
646 );
647 }
648
649 #[test]
650 fn parses_forced_push_spec() {
651 assert_eq!(
652 parse_push_spec("+refs/heads/main:refs/heads/main").unwrap(),
653 PushSpec {
654 local: "refs/heads/main".into(),
655 remote: "refs/heads/main".into(),
656 force: true
657 }
658 );
659 }
660
661 #[test]
662 fn decodes_status_payload() {
663 assert_eq!(
664 decode_status(protocol::status_bytes(protocol::RES_OK, b"refs")).unwrap(),
665 b"refs"
666 );
667 assert!(decode_status(protocol::status_bytes(protocol::RES_NOT_FOUND, b"no")).is_err());
668 }
669
670 #[test]
671 fn metadata_status_ok_is_accepted() {
672 assert!(ensure_metadata_ok(&protocol::metadata_status(protocol::RES_OK)).is_ok());
673 assert!(ensure_metadata_ok(&protocol::metadata_status(protocol::RES_REMOTE_FAIL)).is_err());
674 }
675
676 #[test]
677 fn parses_remote_refs_and_ignores_head() {
678 let refs = parse_remote_refs(
679 b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa HEAD\nbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb refs/heads/main\nmalformed\n",
680 );
681
682 assert_eq!(refs.len(), 1);
683 assert_eq!(
684 refs["refs/heads/main"],
685 "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
686 );
687 }
688
689 #[test]
690 fn fetch_have_set_uses_per_ref_and_global_matching_haves() {
691 let remote_refs = HashMap::from([
692 (
693 "refs/heads/main".to_string(),
694 "1111111111111111111111111111111111111111".to_string(),
695 ),
696 (
697 "refs/tags/v1".to_string(),
698 "2222222222222222222222222222222222222222".to_string(),
699 ),
700 ]);
701 let fetch_refs = vec![FetchRef {
702 sha: "3333333333333333333333333333333333333333".into(),
703 name: "refs/heads/feature".into(),
704 }];
705
706 let have = build_fetch_have_set(
707 &remote_refs,
708 &fetch_refs,
709 |name| match name {
710 "refs/heads/main" => Some("1111111111111111111111111111111111111111".into()),
711 "refs/tags/v1" => Some("not-remote".into()),
712 "refs/heads/feature" => Some("4444444444444444444444444444444444444444".into()),
713 _ => None,
714 },
715 |sha| sha == "2222222222222222222222222222222222222222",
716 );
717
718 assert_eq!(
719 have,
720 vec![
721 "1111111111111111111111111111111111111111",
722 "2222222222222222222222222222222222222222",
723 "4444444444444444444444444444444444444444"
724 ]
725 );
726 }
727
728 #[test]
729 fn fetch_have_set_includes_existing_remote_objects_without_matching_refs() {
730 let remote_refs = HashMap::from([(
731 "refs/tags/v1".to_string(),
732 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
733 )]);
734
735 let have = build_fetch_have_set(
736 &remote_refs,
737 &[],
738 |_| None,
739 |sha| sha == "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
740 );
741
742 assert_eq!(have, vec!["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]);
743 }
744
745 #[test]
746 fn push_exclusion_set_keeps_only_local_known_remote_objects() {
747 let remote_refs = HashMap::from([
748 ("refs/heads/main".to_string(), "aaaa".to_string()),
749 ("refs/heads/dev".to_string(), "bbbb".to_string()),
750 ("refs/tags/v1".to_string(), "aaaa".to_string()),
751 ]);
752
753 let exclusions = build_push_exclusion_set(&remote_refs, |sha| sha == "aaaa");
754
755 assert_eq!(exclusions, vec!["aaaa"]);
756 }
757
758 #[test]
759 fn sync_client_reidentifies_after_not_identified_response() {
760 let _guard = python_rns_test_guard();
761 if !python_rns_available() {
762 eprintln!("Skipping: Python RNS not available");
763 return;
764 }
765
766 let mut python = PythonRngitPeer::spawn_reidentify_required();
767 let ready = python.wait_for_ready();
768 let tmp = tempfile::tempdir().unwrap();
769 let rns_dir = tmp.path().join("client-rns");
770 std::fs::create_dir_all(&rns_dir).unwrap();
771 std::fs::write(rns_dir.join("config"), client_rns_config(ready.port)).unwrap();
772
773 let client = SyncClient::connect(
774 ClientConfig {
775 dir: tmp.path().join("rngit"),
776 reticulum_dir: Some(rns_dir),
777 identity_path: tmp.path().join("client_identity"),
778 connect_timeout_secs: 10,
779 request_timeout_secs: 10,
780 destination_aliases: Default::default(),
781 log_level: crate::logging::DEFAULT_LOG_LEVEL,
782 },
783 parse_hex_16(&ready.dest_hash),
784 )
785 .expect("Rust client should connect to Python Reticulum peer");
786 let helper = RemoteHelper {
787 client,
788 remote_refs: Mutex::new(HashMap::new()),
789 };
790
791 let response = helper
792 .request_with_ident_retry(
793 protocol::PATH_LIST,
794 protocol::repository_request("group/repo"),
795 )
796 .expect("Rust client should re-identify and retry the request");
797 let response = protocol::response_bin(&response.data).unwrap();
798 let refs = String::from_utf8(decode_status(response).unwrap()).unwrap();
799 assert_eq!(
800 refs,
801 "1111111111111111111111111111111111111111 refs/heads/main\n"
802 );
803
804 let first = python.wait_for_event("REQUEST");
805 assert!(
806 first.ends_with(" 1 1"),
807 "unexpected first request event: {first}"
808 );
809 let second = python.wait_for_event("REQUEST");
810 assert!(
811 second.ends_with(" 2 2"),
812 "retry happened without a second LINKIDENTIFY: {second}"
813 );
814 }
815
816 #[test]
817 fn sync_client_identifies_before_first_python_request() {
818 let _guard = python_rns_test_guard();
819 if !python_rns_available() {
820 eprintln!("Skipping: Python RNS not available");
821 return;
822 }
823
824 let mut python = PythonRngitPeer::spawn();
825 let ready = python.wait_for_ready();
826 let tmp = tempfile::tempdir().unwrap();
827 let rns_dir = tmp.path().join("client-rns");
828 std::fs::create_dir_all(&rns_dir).unwrap();
829 std::fs::write(rns_dir.join("config"), client_rns_config(ready.port)).unwrap();
830
831 let client = SyncClient::connect(
832 ClientConfig {
833 dir: tmp.path().join("rngit"),
834 reticulum_dir: Some(rns_dir),
835 identity_path: tmp.path().join("client_identity"),
836 connect_timeout_secs: 10,
837 request_timeout_secs: 10,
838 destination_aliases: Default::default(),
839 log_level: crate::logging::DEFAULT_LOG_LEVEL,
840 },
841 parse_hex_16(&ready.dest_hash),
842 )
843 .expect("Rust client should connect to Python Reticulum peer");
844
845 let response = client
846 .request(
847 protocol::PATH_LIST,
848 protocol::repository_request("group/repo"),
849 )
850 .expect("Python peer should answer the first request");
851 let response = protocol::response_bin(&response.data).unwrap();
852 let refs = String::from_utf8(decode_status(response).unwrap()).unwrap();
853 assert_eq!(
854 refs,
855 "1111111111111111111111111111111111111111 refs/heads/main\n"
856 );
857
858 let request = python.wait_for_event("REQUEST");
859 let remote = request
860 .split_whitespace()
861 .nth(2)
862 .expect("REQUEST event should include remote identity hash");
863 assert_ne!(remote, "none", "Python saw an unidentified first request");
864 }
865
866 struct PythonReady {
867 port: u16,
868 dest_hash: String,
869 }
870
871 struct PythonRngitPeer {
872 child: Child,
873 events: mpsc::Receiver<String>,
874 }
875
876 impl PythonRngitPeer {
877 fn spawn() -> Self {
878 Self::spawn_with_reidentify_required(false)
879 }
880
881 fn spawn_reidentify_required() -> Self {
882 Self::spawn_with_reidentify_required(true)
883 }
884
885 fn spawn_with_reidentify_required(reidentify_required: bool) -> Self {
886 let mut command = Command::new("python3");
887 command.args(["-c", PYTHON_RNGIT_PEER_SCRIPT]);
888 if reidentify_required {
889 command.env("RNGIT_TEST_REQUIRE_REIDENTIFY", "1");
890 }
891 let mut child = command
892 .stdin(Stdio::piped())
893 .stdout(Stdio::piped())
894 .stderr(Stdio::piped())
895 .spawn()
896 .expect("failed to start Python RNS process");
897
898 let stdout = child.stdout.take().unwrap();
899 let stderr = child.stderr.take().unwrap();
900 let (tx, rx) = mpsc::channel();
901 std::thread::spawn(move || {
902 for line in BufReader::new(stdout).lines().map_while(|line| line.ok()) {
903 let _ = tx.send(line);
904 }
905 });
906 std::thread::spawn(move || {
907 for line in BufReader::new(stderr).lines().map_while(|line| line.ok()) {
908 eprintln!("python stderr: {line}");
909 }
910 });
911
912 Self { child, events: rx }
913 }
914
915 fn wait_for_ready(&mut self) -> PythonReady {
916 let event = self.wait_for_event("READY");
917 let mut parts = event.split_whitespace();
918 assert_eq!(parts.next(), Some("READY"));
919 let port = parts.next().unwrap().parse().unwrap();
920 let dest_hash = parts.next().unwrap().to_string();
921 PythonReady { port, dest_hash }
922 }
923
924 fn wait_for_event(&mut self, prefix: &str) -> String {
925 let deadline = Instant::now() + Duration::from_secs(10);
926 loop {
927 let remaining = deadline
928 .checked_duration_since(Instant::now())
929 .expect("timed out waiting for Python event");
930 let event = self
931 .events
932 .recv_timeout(remaining)
933 .expect("timed out waiting for Python event");
934 if event.starts_with(prefix) {
935 return event;
936 }
937 }
938 }
939 }
940
941 impl Drop for PythonRngitPeer {
942 fn drop(&mut self) {
943 if let Some(stdin) = self.child.stdin.as_mut() {
944 use std::io::Write;
945 let _ = writeln!(stdin, "stop");
946 let _ = stdin.flush();
947 }
948 let _ = self.child.kill();
949 let _ = self.child.wait();
950 }
951 }
952
953 static PYTHON_RNS_TEST_LOCK: std::sync::OnceLock<std::sync::Mutex<()>> =
954 std::sync::OnceLock::new();
955
956 fn python_rns_test_guard() -> std::sync::MutexGuard<'static, ()> {
957 PYTHON_RNS_TEST_LOCK
958 .get_or_init(|| std::sync::Mutex::new(()))
959 .lock()
960 .unwrap()
961 }
962
963 fn python_rns_available() -> bool {
964 Command::new("python3")
965 .args(["-c", "import RNS; print('ok')"])
966 .stdout(Stdio::null())
967 .stderr(Stdio::null())
968 .status()
969 .map(|status| status.success())
970 .unwrap_or(false)
971 }
972
973 fn parse_hex_16(input: &str) -> [u8; 16] {
974 assert_eq!(input.len(), 32, "expected 16-byte hex string");
975 let mut out = [0u8; 16];
976 for i in 0..16 {
977 out[i] = u8::from_str_radix(&input[i * 2..i * 2 + 2], 16).unwrap();
978 }
979 out
980 }
981
982 fn client_rns_config(port: u16) -> String {
983 format!(
984 r#"[reticulum]
985 enable_transport = No
986 share_instance = No
987 panic_on_interface_error = Yes
988
989[interfaces]
990 [[Python RNGit Test TCP]]
991 type = TCPClientInterface
992 target_host = 127.0.0.1
993 target_port = {port}
994 mode = full
995"#
996 )
997 }
998
999 const PYTHON_RNGIT_PEER_SCRIPT: &str = r#"
1000import os
1001import signal
1002import socket
1003import sys
1004import tempfile
1005import threading
1006import time
1007
1008sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
1009sock.bind(("127.0.0.1", 0))
1010port = sock.getsockname()[1]
1011sock.close()
1012
1013config_dir = tempfile.mkdtemp()
1014with open(os.path.join(config_dir, "config"), "w") as f:
1015 f.write(f"""[reticulum]
1016 enable_transport = false
1017 share_instance = no
1018
1019[interfaces]
1020 [[TCP Server Interface]]
1021 type = TCPServerInterface
1022 interface_enabled = true
1023 listen_ip = 127.0.0.1
1024 listen_port = {port}
1025 mode = full
1026""")
1027
1028import RNS
1029
1030running = True
1031require_reidentify = os.environ.get("RNGIT_TEST_REQUIRE_REIDENTIFY") == "1"
1032identifications = 0
1033requests = 0
1034reticulum = RNS.Reticulum(configdir=config_dir)
1035identity = RNS.Identity()
1036destination = RNS.Destination(identity, RNS.Destination.IN, RNS.Destination.SINGLE, "git", "repositories")
1037
1038def emit(*parts):
1039 print(" ".join(str(part) for part in parts), flush=True)
1040
1041def list_refs(path, data, request_id, link_id, remote_identity, requested_at):
1042 global requests
1043 requests += 1
1044 remote = remote_identity.hash.hex() if remote_identity is not None else "none"
1045 emit("REQUEST", path, remote, requests, identifications)
1046 if remote_identity is None:
1047 return b"\x01Not identified"
1048 if require_reidentify and identifications < 2:
1049 return b"\x01Not identified"
1050 return b"\x001111111111111111111111111111111111111111 refs/heads/main\n"
1051
1052def identified(link, remote_identity):
1053 global identifications
1054 identifications += 1
1055 emit("IDENTIFIED", remote_identity.hash.hex(), identifications)
1056
1057def link_established(link):
1058 emit("LINK_ESTABLISHED")
1059 link.set_remote_identified_callback(identified)
1060
1061destination.register_request_handler("/git/list", response_generator=list_refs, allow=RNS.Destination.ALLOW_ALL)
1062destination.set_link_established_callback(link_established)
1063
1064def announce_loop():
1065 while running:
1066 destination.announce()
1067 time.sleep(0.25)
1068
1069threading.Thread(target=announce_loop, daemon=True).start()
1070emit("READY", port, destination.hash.hex())
1071
1072signal.signal(signal.SIGTERM, lambda *a: sys.exit(0))
1073try:
1074 for line in sys.stdin:
1075 if line.strip() == "stop":
1076 break
1077finally:
1078 running = False
1079"#;
1080}