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);
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        // Python rngit registers its remote-identified callback from the link
333        // established callback. Wait briefly after establishment before sending
334        // LINKIDENTIFY so the server records this link in active_links.
335        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}