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