Skip to main content

sley_remote/
bundle_uri.rs

1//! Protocol v2 `bundle-uri` discovery and bundle prefetch for clone.
2//!
3//! Mirrors upstream `bundle-uri.c` / `connect.c::get_remote_bundle_uri` enough for
4//! clone auto-discovery: issue `command=bundle-uri`, parse `bundle.*=…` lines into a
5//! [`BundleUriList`], download HTTP(S) bundles via `git-remote-https`, and install
6//! them into the destination repository before the main clone fetch.
7
8use std::collections::BTreeMap;
9use std::fs;
10use std::io::{BufRead, Write};
11use std::path::{Path, PathBuf};
12use std::process::{Command, Stdio};
13
14use sley_config::GitConfig;
15use sley_core::{Capability, GitError, ObjectFormat, Result, UPSTREAM_GIT_COMPAT_VERSION};
16use sley_formats::Bundle;
17use sley_odb::{FileObjectDatabase, install_bundle_pack, verify_bundle_prerequisites};
18use sley_refs::{FileRefStore, RefTarget, RefUpdate};
19use sley_protocol::{
20    GitService, PktLineFrame, ProtocolV2CommandRequest, TransportHandshake,
21    read_protocol_v2_stateless_rpc_payload_frames, smart_http_rpc_request_content_type,
22    smart_http_rpc_result_content_type, write_protocol_v2_command_request,
23};
24use sley_transport::{
25    HttpClient, RemoteUrl, http_smart_rpc_url, parse_remote_url,
26};
27
28use crate::CredentialProvider;
29use crate::http::{
30    http_check_status, http_git_protocol_header_value, http_request_headers, http_send_with_auth,
31    http_validate_content_type,
32};
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum BundleMode {
36    /// `bundle.mode = none` (or unset): upstream warns but still fetches.
37    #[default]
38    None,
39    /// `bundle.mode = all`: every bundle in the list is needed.
40    All,
41    /// `bundle.mode = any`: a single successfully-applied bundle suffices, so
42    /// prefetch stops after the first bundle it manages to unbundle.
43    Any,
44}
45
46#[derive(Debug, Clone, Default)]
47pub struct BundleUriEntry {
48    pub id: String,
49    pub uri: Option<String>,
50    pub creation_token: u64,
51}
52
53#[derive(Debug, Clone, Default)]
54pub struct BundleUriList {
55    pub version: i32,
56    pub mode: BundleMode,
57    pub creation_token_heuristic: bool,
58    pub base_uri: String,
59    pub bundles: BTreeMap<String, BundleUriEntry>,
60}
61
62/// Split a `bundle.<subsection>.<key>` variable using `parse_config_key`
63/// semantics: the section (`bundle`) is matched case-sensitively, and the
64/// subsection/key boundary is the LAST dot (a subsection may itself contain
65/// dots). Returns `(subsection, key)` where `subsection` is `None` for a bare
66/// `bundle.<key>` (e.g. `bundle.version`). Returns `None` when `var` does not
67/// begin with `bundle.` (upstream `parse_config_key` returning -1).
68fn parse_bundle_config_key(var: &str) -> Option<(Option<&str>, &str)> {
69    let rest = var.strip_prefix("bundle")?;
70    let rest = rest.strip_prefix('.')?;
71    // `rest` is the text after "bundle."; the key runs from the last dot.
72    match rest.rfind('.') {
73        Some(dot) => Some((Some(&rest[..dot]), &rest[dot + 1..])),
74        None => Some((None, rest)),
75    }
76}
77
78pub fn handshake_advertises_bundle_uri(handshake: &TransportHandshake) -> bool {
79    handshake
80        .capabilities
81        .iter()
82        .any(|cap| cap.name == "bundle-uri")
83}
84
85pub fn transfer_bundle_uri_enabled(config: &GitConfig) -> bool {
86    config
87        .get_bool("transfer", None, "bundleuri")
88        .unwrap_or(false)
89}
90
91/// Parse one `key=value` line from a protocol-v2 `bundle-uri` response into
92/// `list`, mirroring upstream `bundle_uri_parse_line` / `bundle_list_update`.
93///
94/// The comparison is byte-exact (upstream uses `strcmp`, not case-insensitive
95/// matching, and never trims whitespace): the key is split at the FIRST `=`, an
96/// empty key or value is an error, and the `bundle.<subsection>.<key>` split
97/// uses `parse_config_key` (subsection = up to the last dot). Unknown
98/// `bundle.mode` values and non-`1` versions are errors; an unparseable
99/// `creationtoken` is warned about (not silently zeroed) and otherwise ignored.
100pub fn parse_bundle_uri_line(list: &mut BundleUriList, line: &str) -> Result<()> {
101    if line.is_empty() {
102        return Err(GitError::InvalidFormat("bundle-uri: got an empty line".into()));
103    }
104    let Some(equals) = line.find('=') else {
105        return Err(GitError::InvalidFormat(
106            "bundle-uri: line is not of the form 'key=value'".into(),
107        ));
108    };
109    let key = &line[..equals];
110    let value = &line[equals + 1..];
111    if key.is_empty() || value.is_empty() {
112        return Err(GitError::InvalidFormat(
113            "bundle-uri: line has empty key or value".into(),
114        ));
115    }
116    let Some((subsection, subkey)) = parse_bundle_config_key(key) else {
117        // parse_config_key returned -1: the key is not under the `bundle`
118        // section. Upstream treats this as an error for the line.
119        return Err(GitError::InvalidFormat(format!(
120            "bundle-uri: line is not of the form 'key=value': {line}"
121        )));
122    };
123
124    let Some(id) = subsection else {
125        // Global `bundle.<key>` settings.
126        match subkey {
127            "version" => {
128                let version: i32 = value.parse().map_err(|_| {
129                    GitError::InvalidFormat("bundle-uri: invalid bundle.version".into())
130                })?;
131                if version != 1 {
132                    return Err(GitError::InvalidFormat(
133                        "bundle-uri: unsupported bundle.version".into(),
134                    ));
135                }
136                list.version = version;
137            }
138            "mode" => {
139                list.mode = match value {
140                    "all" => BundleMode::All,
141                    "any" => BundleMode::Any,
142                    _ => {
143                        return Err(GitError::InvalidFormat(format!(
144                            "bundle-uri: unrecognized bundle.mode value '{value}'"
145                        )));
146                    }
147                };
148            }
149            "heuristic" => {
150                // Unknown heuristics are ignored (upstream loops the known
151                // heuristics and returns 0 without matching).
152                if value == "creationToken" {
153                    list.creation_token_heuristic = true;
154                }
155            }
156            // Any other global `bundle.<key>` is an unknown hint: ignore.
157            _ => {}
158        }
159        return Ok(());
160    };
161
162    let entry = list
163        .bundles
164        .entry(id.to_string())
165        .or_insert_with(|| BundleUriEntry {
166            id: id.to_string(),
167            ..BundleUriEntry::default()
168        });
169    match subkey {
170        "uri" => {
171            if entry.uri.is_some() {
172                return Err(GitError::InvalidFormat(format!(
173                    "bundle-uri: duplicate uri for bundle \"{id}\""
174                )));
175            }
176            entry.uri = Some(relative_bundle_uri(&list.base_uri, value));
177        }
178        "creationtoken" => match value.parse() {
179            Ok(token) => entry.creation_token = token,
180            Err(_) => {
181                eprintln!(
182                    "warning: could not parse bundle list key creationToken with value '{value}'"
183                );
184            }
185        },
186        // Unknown per-bundle keys are hints for heuristics we do not implement.
187        _ => {}
188    }
189    Ok(())
190}
191
192/// Resolve a bundle `uri` value against `base_uri`, mirroring upstream
193/// `relative_url`: absolute URLs (containing `://`) and absolute filesystem
194/// paths (leading `/`) pass through unchanged; only genuinely relative values
195/// are joined onto the base.
196fn relative_bundle_uri(base_uri: &str, value: &str) -> String {
197    if value.contains("://") || value.starts_with('/') {
198        return value.to_string();
199    }
200    let base = base_uri.trim_end_matches('/');
201    let value = value.trim_start_matches("./");
202    format!("{base}/{value}")
203}
204
205pub fn http_remote_bundle_uri_list(
206    client: &dyn HttpClient,
207    remote: &RemoteUrl,
208    handshake: &TransportHandshake,
209    credentials: &mut dyn CredentialProvider,
210    config: Option<&GitConfig>,
211) -> Result<BundleUriList> {
212    let git_protocol = http_git_protocol_header_value(config)?;
213    if !handshake_advertises_bundle_uri(handshake) {
214        return Ok(BundleUriList::default());
215    }
216    let mut command = ProtocolV2CommandRequest::new("bundle-uri")?;
217    command.capabilities.push(Capability {
218        name: "agent".into(),
219        value: Some(format!("git/{UPSTREAM_GIT_COMPAT_VERSION}")),
220    });
221    let url = http_smart_rpc_url(remote, GitService::UploadPack)?;
222    let mut body = Vec::new();
223    write_protocol_v2_command_request(&mut body, &command)?;
224    let content_type = smart_http_rpc_request_content_type(GitService::UploadPack)?;
225    let mut response = http_send_with_auth(remote, credentials, |auth| {
226        client.post(
227            &url,
228            &content_type,
229            &http_request_headers(auth, git_protocol.as_deref()),
230            &body,
231        )
232    })?;
233    http_check_status(&response, &url)?;
234    http_validate_content_type(
235        &response,
236        &smart_http_rpc_result_content_type(GitService::UploadPack)?,
237    )?;
238    let mut list = BundleUriList {
239        base_uri: remote_base_uri(remote),
240        ..BundleUriList::default()
241    };
242    let frames = read_protocol_v2_stateless_rpc_payload_frames(&mut response.body)?;
243    for frame in frames {
244        let PktLineFrame::Data(payload) = frame else {
245            break;
246        };
247        let line = std::str::from_utf8(&payload)
248            .map_err(|_| GitError::InvalidFormat("bundle-uri response is not UTF-8".into()))?
249            .trim_end_matches('\n');
250        parse_bundle_uri_line(&mut list, line)?;
251    }
252    Ok(list)
253}
254
255fn remote_base_uri(remote: &RemoteUrl) -> String {
256    let scheme = match remote.transport {
257        sley_transport::RemoteTransport::Http => "http",
258        sley_transport::RemoteTransport::Https => "https",
259        _ => "http",
260    };
261    let host = remote.host.as_deref().unwrap_or("localhost");
262    let host = if host.contains(':') && !host.starts_with('[') {
263        format!("[{host}]")
264    } else {
265        host.to_string()
266    };
267    match remote.port {
268        Some(port) => format!("{scheme}://{host}:{port}{}", remote.path.trim_end_matches('/')),
269        None => format!("{scheme}://{host}{}", remote.path.trim_end_matches('/')),
270    }
271}
272
273/// The order in which the advertised bundle URIs should be *downloaded*.
274///
275/// Upstream `fetch_bundles_by_token` downloads bundles newest-first (decreasing
276/// creationToken) under the creationToken heuristic, so a client that is only a
277/// little behind can stop after the first few bundles. Without the heuristic the
278/// list order (here, the deterministic `bundle.<id>` ordering of the `BTreeMap`)
279/// is used. This download order is what t5601's GIT_TRACE2 assertions check.
280pub fn bundle_uri_fetch_order(list: &BundleUriList) -> Vec<String> {
281    ordered_bundle_entries(list)
282        .into_iter()
283        .filter_map(|entry| entry.uri.clone())
284        .collect()
285}
286
287fn ordered_bundle_entries(list: &BundleUriList) -> Vec<&BundleUriEntry> {
288    let mut entries: Vec<_> = list.bundles.values().collect();
289    if list.creation_token_heuristic {
290        entries.sort_by(|left, right| right.creation_token.cmp(&left.creation_token));
291    }
292    entries
293}
294
295/// Download every advertised bundle (newest-first) and unbundle them into
296/// `git_dir`, then create `refs/bundles/*` refs from the applied bundles so the
297/// subsequent clone negotiation can use them as `have`s. Mirrors upstream
298/// `fetch_bundle_uri` for the auto-discovered list:
299///
300/// * bundles are DOWNLOADED newest-first (the order t5601 asserts via trace2),
301///   but UNBUNDLED in prerequisite order — a bundle whose prerequisite objects
302///   are not yet present is deferred and retried once its prerequisites arrive;
303/// * with `bundle.mode = any` a single successfully-applied bundle is enough, so
304///   unbundling stops after the first success;
305/// * failures are best-effort: a bundle that cannot be downloaded or applied is
306///   warned about (upstream's "failed to download bundle from URI" text) and the
307///   remaining bundles / the normal negotiation still proceed.
308pub fn prefetch_advertised_bundle_uris(
309    git_dir: &Path,
310    format: ObjectFormat,
311    list: &BundleUriList,
312) -> Result<()> {
313    if list.bundles.is_empty() {
314        return Ok(());
315    }
316    // Download in advertised (newest-first) order; keep the downloaded temp file
317    // alongside the id so it can be unbundled later in prerequisite order.
318    let mut downloaded: Vec<(String, PathBuf)> = Vec::new();
319    for entry in ordered_bundle_entries(list) {
320        let Some(uri) = entry.uri.as_deref() else {
321            continue;
322        };
323        if uri.is_empty() {
324            continue;
325        }
326        match download_bundle_uri_to_temp(uri) {
327            Ok(temp) => downloaded.push((entry.id.clone(), temp)),
328            Err(_) => {
329                eprintln!("warning: failed to download bundle from URI '{uri}'");
330            }
331        }
332    }
333
334    let db = FileObjectDatabase::from_git_dir(git_dir, format);
335    let ref_store = FileRefStore::new(git_dir, format);
336    let any_mode = list.mode == BundleMode::Any;
337
338    // Unbundle in prerequisite order: repeatedly apply any downloaded bundle
339    // whose prerequisites are already satisfied, until no further progress is
340    // made. This handles thin range bundles (e.g. `HEAD~1..HEAD`) whose base
341    // objects come from an earlier (full) bundle regardless of download order.
342    let mut pending = downloaded;
343    let mut applied_any = false;
344    loop {
345        let mut progressed = false;
346        let mut still_pending: Vec<(String, PathBuf)> = Vec::new();
347        for (id, temp) in pending.drain(..) {
348            match apply_bundle_file(&temp, format, &db, &ref_store) {
349                Ok(true) => {
350                    progressed = true;
351                    applied_any = true;
352                    let _ = fs::remove_file(&temp);
353                    if any_mode {
354                        // A single bundle satisfies `bundle.mode = any`; drop the
355                        // rest without applying them.
356                        for (_, leftover) in still_pending {
357                            let _ = fs::remove_file(&leftover);
358                        }
359                        return Ok(());
360                    }
361                }
362                Ok(false) => still_pending.push((id, temp)),
363                Err(_) => {
364                    // A genuinely broken bundle (not merely a missing
365                    // prerequisite) is dropped with a warning.
366                    let _ = fs::remove_file(&temp);
367                    progressed = true;
368                }
369            }
370        }
371        pending = still_pending;
372        if pending.is_empty() || !progressed {
373            break;
374        }
375    }
376    // Any bundles still pending had unsatisfiable prerequisites; clean them up.
377    for (_, temp) in pending {
378        let _ = fs::remove_file(&temp);
379    }
380    let _ = applied_any;
381    Ok(())
382}
383
384/// Try to unbundle `path` into `db` and create its `refs/bundles/*` refs.
385/// Returns `Ok(true)` when the bundle was applied, `Ok(false)` when it could not
386/// be applied yet because a prerequisite object is still missing (so the caller
387/// should retry after applying other bundles), or `Err` for a corrupt bundle.
388fn apply_bundle_file(
389    path: &Path,
390    format: ObjectFormat,
391    db: &FileObjectDatabase,
392    ref_store: &FileRefStore,
393) -> Result<bool> {
394    let bytes = fs::read(path)?;
395    let bundle = Bundle::parse(&bytes, format)?;
396    if verify_bundle_prerequisites(&bundle, db).is_err() {
397        // A prerequisite object is not present yet: defer.
398        return Ok(false);
399    }
400    let result = install_bundle_pack(&bundle, db, db)?;
401    create_bundle_refs(ref_store, &result.references);
402    Ok(true)
403}
404
405/// Create `refs/bundles/<branch>` refs from the applied bundle's tips (upstream
406/// `unbundle_from_file` converting `refs/*` into `refs/bundles/*`). These refs
407/// keep the fetched objects reachable and are advertised as `have`s during the
408/// subsequent clone negotiation.
409fn create_bundle_refs(ref_store: &FileRefStore, references: &[sley_formats::BundleReference]) {
410    for reference in references {
411        let Some(branch) = reference.name.strip_prefix("refs/") else {
412            continue;
413        };
414        let mut tx = ref_store.transaction();
415        tx.update(RefUpdate {
416            name: format!("refs/bundles/{branch}"),
417            expected: None,
418            new: RefTarget::Direct(reference.oid),
419            reflog: None,
420        });
421        let _ = tx.commit();
422    }
423}
424
425/// Download `uri` to a fresh temp file. HTTP(S) URIs go through
426/// `git-remote-https`; `file://` and plain-path URIs are COPIED into a temp file
427/// (never handed back as the original path — the caller deletes the returned
428/// path after installing, and must not delete the user's bundle, cf. upstream
429/// `copy_uri_to_file`).
430fn download_bundle_uri_to_temp(uri: &str) -> Result<PathBuf> {
431    let temp = unique_bundle_temp_path();
432    if uri.starts_with("http://") || uri.starts_with("https://") {
433        download_https_bundle_uri(uri, &temp)?;
434        return Ok(temp);
435    }
436    let source = uri.strip_prefix("file://").unwrap_or(uri);
437    fs::copy(source, &temp).map_err(|err| GitError::Io(err.to_string()))?;
438    Ok(temp)
439}
440
441fn unique_bundle_temp_path() -> PathBuf {
442    static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
443    let nanos = std::time::SystemTime::now()
444        .duration_since(std::time::UNIX_EPOCH)
445        .map(|duration| duration.as_nanos())
446        .unwrap_or(0);
447    let seq = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
448    std::env::temp_dir().join(format!("sley-bundle-uri-{nanos}-{seq}"))
449}
450
451/// Download `uri` into `dest` via `git-remote-https`, speaking the remote-helper
452/// `get` protocol. Upstream `download_https_uri_to_file` sends `capabilities`,
453/// waits for the helper's capability list (which must include `get`), then
454/// `get <uri> <dest>`; success is the helper's exit status (its reply to `get`
455/// is a single blank line, not an `ack`/`done`).
456fn download_https_bundle_uri(uri: &str, dest: &Path) -> Result<()> {
457    // The remote-helper protocol separates the URI and path with a space and
458    // commands with newlines, so neither may contain those (an adversary could
459    // otherwise redirect the download); mirror upstream's validation.
460    if uri.contains(' ') || uri.contains('\n') {
461        return Err(GitError::InvalidFormat(format!(
462            "bundle-uri: URI is malformed: '{uri}'"
463        )));
464    }
465    let dest_display = dest.to_string_lossy();
466    if dest_display.contains('\n') {
467        return Err(GitError::InvalidFormat(format!(
468            "bundle-uri: filename is malformed: '{dest_display}'"
469        )));
470    }
471    let helper = locate_git_remote_https()?;
472    let argv = vec!["git-remote-https".to_string(), uri.to_string()];
473    sley_core::trace2::child_start("git-remote-https", &argv);
474    let mut child = Command::new(&helper)
475        .arg(uri)
476        .stdin(Stdio::piped())
477        .stdout(Stdio::piped())
478        .stderr(Stdio::null())
479        .spawn()
480        .map_err(|err| GitError::Command(format!("failed to spawn git-remote-https: {err}")))?;
481    let mut stdin = child.stdin.take().expect("git-remote-https stdin");
482    let mut stdout = child.stdout.take().expect("git-remote-https stdout");
483
484    // Negotiate capabilities: the helper must advertise `get`.
485    let capabilities_result = (|| -> Result<bool> {
486        stdin
487            .write_all(b"capabilities\n")
488            .map_err(|err| GitError::Io(err.to_string()))?;
489        stdin.flush().map_err(|err| GitError::Io(err.to_string()))?;
490        let mut reader = std::io::BufReader::new(&mut stdout);
491        let mut line = String::new();
492        let mut found_get = false;
493        loop {
494            line.clear();
495            let read = reader
496                .read_line(&mut line)
497                .map_err(|err| GitError::Io(err.to_string()))?;
498            if read == 0 {
499                break;
500            }
501            let trimmed = line.trim_end_matches(['\n', '\r']);
502            if trimmed.is_empty() {
503                break;
504            }
505            if trimmed == "get" {
506                found_get = true;
507            }
508        }
509        Ok(found_get)
510    })();
511    let found_get = match capabilities_result {
512        Ok(found_get) => found_get,
513        Err(err) => {
514            let _ = child.wait();
515            return Err(err);
516        }
517    };
518    if !found_get {
519        let _ = child.wait();
520        return Err(GitError::Command(
521            "bundle-uri: git-remote-https lacks the 'get' capability".into(),
522        ));
523    }
524
525    // Request the download. The helper replies with a single blank line and then
526    // waits for more commands; closing stdin makes it exit, and its exit status
527    // is authoritative for success.
528    let write_result = write!(stdin, "get {uri} {}\n\n", dest.display())
529        .and_then(|()| stdin.flush())
530        .map_err(|err| GitError::Io(err.to_string()));
531    drop(stdin);
532    if let Err(err) = write_result {
533        let _ = child.wait();
534        return Err(err);
535    }
536    let status = child
537        .wait()
538        .map_err(|err| GitError::Command(format!("git-remote-https wait failed: {err}")))?;
539    if !status.success() {
540        let _ = fs::remove_file(dest);
541        return Err(GitError::Command(format!(
542            "failed to download bundle from URI '{uri}'"
543        )));
544    }
545    Ok(())
546}
547
548fn locate_git_remote_https() -> Result<PathBuf> {
549    // Search the git exec-path directories (from the environment), then `PATH`,
550    // then well-known install locations, for the `git-remote-https` helper.
551    //
552    // IMPORTANT: this must NOT shell out to `git --exec-path` to discover the
553    // exec path. In the upstream test harness the `git` on `PATH` is the sley
554    // shim, so invoking `git --exec-path` re-enters sley, whose git-compat
555    // `--exec-path` handler itself resolves `git` from `PATH` — an unbounded
556    // fork loop. The directories inspected below (in particular the harness's
557    // `GIT_EXEC_PATH`) already contain `git-remote-https`.
558    for dir in git_remote_https_search_dirs() {
559        let candidate = dir.join("git-remote-https");
560        if candidate.is_file() {
561            return Ok(candidate);
562        }
563    }
564    Err(GitError::Command(
565        "git-remote-https is not available on PATH or in GIT_EXEC_PATH".into(),
566    ))
567}
568
569fn git_remote_https_search_dirs() -> Vec<PathBuf> {
570    let mut dirs = Vec::new();
571    for var in ["GIT_TEST_EXEC_PATH", "GIT_BUILD_DIR", "GIT_EXEC_PATH"] {
572        if let Ok(path) = std::env::var(var)
573            && !path.is_empty()
574        {
575            dirs.push(PathBuf::from(path));
576        }
577    }
578    if let Some(path_var) = std::env::var_os("PATH") {
579        for dir in std::env::split_paths(&path_var) {
580            dirs.push(dir);
581        }
582    }
583    for candidate in [
584        "/opt/homebrew/opt/git/libexec/git-core",
585        "/usr/local/libexec/git-core",
586        "/usr/lib/git-core",
587        "/usr/libexec/git-core",
588    ] {
589        dirs.push(PathBuf::from(candidate));
590    }
591    dirs
592}
593
594pub fn remote_url_from_bundle_uri(uri: &str) -> Result<RemoteUrl> {
595    parse_remote_url(uri)
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601
602    fn sample_list() -> BundleUriList {
603        BundleUriList {
604            version: 1,
605            mode: BundleMode::All,
606            creation_token_heuristic: true,
607            base_uri: "http://127.0.0.1:18080/smart/repo4.git".into(),
608            ..BundleUriList::default()
609        }
610    }
611
612    #[test]
613    fn bundle_uri_fetch_order_sorts_by_creation_token_descending() {
614        let mut list = sample_list();
615        for (id, token, uri) in [
616            ("everything", 1, "http://127.0.0.1:18080/everything.bundle"),
617            ("new", 2, "http://127.0.0.1:18080/new.bundle"),
618            ("newest", 3, "http://127.0.0.1:18080/newest.bundle"),
619        ] {
620            list.bundles.insert(
621                id.to_string(),
622                BundleUriEntry {
623                    id: id.to_string(),
624                    uri: Some(uri.to_string()),
625                    creation_token: token,
626                },
627            );
628        }
629        assert_eq!(
630            bundle_uri_fetch_order(&list),
631            vec![
632                "http://127.0.0.1:18080/newest.bundle".to_string(),
633                "http://127.0.0.1:18080/new.bundle".to_string(),
634                "http://127.0.0.1:18080/everything.bundle".to_string(),
635            ]
636        );
637    }
638
639    #[test]
640    fn parse_bundle_uri_line_accepts_creation_token_heuristic_lines() {
641        let mut list = BundleUriList::default();
642        parse_bundle_uri_line(&mut list, "bundle.heuristic=creationToken").expect("test operation should succeed");
643        assert!(list.creation_token_heuristic);
644        parse_bundle_uri_line(&mut list, "bundle.newest.creationtoken=3")
645            .expect("test operation should succeed");
646        parse_bundle_uri_line(&mut list, "bundle.newest.uri=http://127.0.0.1/newest.bundle")
647            .expect("test operation should succeed");
648        let entry = list.bundles.get("newest").expect("bundle entry");
649        assert_eq!(entry.creation_token, 3);
650        assert_eq!(entry.uri.as_deref(), Some("http://127.0.0.1/newest.bundle"));
651    }
652
653    #[test]
654    fn parse_config_key_splits_subsection_at_last_dot() {
655        assert_eq!(parse_bundle_config_key("bundle.version"), Some((None, "version")));
656        assert_eq!(parse_bundle_config_key("bundle.mode"), Some((None, "mode")));
657        assert_eq!(
658            parse_bundle_config_key("bundle.everything.uri"),
659            Some((Some("everything"), "uri"))
660        );
661        // A subsection that itself contains dots splits at the LAST dot only.
662        assert_eq!(
663            parse_bundle_config_key("bundle.my.nested.id.creationtoken"),
664            Some((Some("my.nested.id"), "creationtoken"))
665        );
666        // Not under the `bundle` section (case-sensitive) → no match.
667        assert_eq!(parse_bundle_config_key("Bundle.version"), None);
668        assert_eq!(parse_bundle_config_key("transfer.bundleuri"), None);
669    }
670
671    #[test]
672    fn parse_bundle_uri_line_is_byte_exact_and_untrimmed() {
673        let mut list = BundleUriList::default();
674        // Mixed-case keys/values are NOT normalized: `bundle.mode = All` is an
675        // unrecognized value (upstream `strcmp`), so it errors.
676        assert!(parse_bundle_uri_line(&mut list, "bundle.mode=All").is_err());
677        // A trailing space is part of the value, so `all ` is not `all`.
678        assert!(parse_bundle_uri_line(&mut list, "bundle.mode=all ").is_err());
679        // Exact match works.
680        parse_bundle_uri_line(&mut list, "bundle.mode=all").expect("mode=all parses");
681        assert_eq!(list.mode, BundleMode::All);
682        parse_bundle_uri_line(&mut list, "bundle.mode=any").expect("mode=any parses");
683        assert_eq!(list.mode, BundleMode::Any);
684    }
685
686    #[test]
687    fn parse_bundle_uri_line_rejects_unsupported_version_and_bad_key() {
688        let mut list = BundleUriList::default();
689        assert!(parse_bundle_uri_line(&mut list, "bundle.version=2").is_err());
690        assert!(parse_bundle_uri_line(&mut list, "bundle.version=notanumber").is_err());
691        // Key with an empty value or empty key is rejected.
692        assert!(parse_bundle_uri_line(&mut list, "bundle.version=").is_err());
693        assert!(parse_bundle_uri_line(&mut list, "=value").is_err());
694    }
695
696    #[test]
697    fn relative_bundle_uri_passes_absolute_values_through_unchanged() {
698        let base = "http://example.com/smart/repo.git";
699        assert_eq!(
700            relative_bundle_uri(base, "http://cdn.example.com/x.bundle"),
701            "http://cdn.example.com/x.bundle"
702        );
703        // Absolute filesystem paths pass through unchanged (upstream relative_url
704        // returns absolute paths as-is).
705        assert_eq!(relative_bundle_uri(base, "/srv/x.bundle"), "/srv/x.bundle");
706        // Genuinely relative values are joined onto the base.
707        assert_eq!(
708            relative_bundle_uri(base, "x.bundle"),
709            "http://example.com/smart/repo.git/x.bundle"
710        );
711    }
712
713    #[test]
714    fn parse_bundle_uri_line_rejects_duplicate_uri() {
715        let mut list = BundleUriList::default();
716        parse_bundle_uri_line(&mut list, "bundle.a.uri=http://x/a.bundle").expect("first uri");
717        assert!(parse_bundle_uri_line(&mut list, "bundle.a.uri=http://x/b.bundle").is_err());
718    }
719}