Skip to main content

rns_git/
client.rs

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        // Python Reticulum can receive early LINKIDENTIFY/request packets before
290        // the responder side has fully activated the new link. Give both the
291        // link and the remote identity callback a short settle window.
292        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}