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