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