1use 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 #[default]
35 None,
36 All,
38 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
59fn parse_bundle_config_key(var: &str) -> Option<(Option<&str>, &str)> {
66 let rest = var.strip_prefix("bundle")?;
67 let rest = rest.strip_prefix('.')?;
68 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
88pub 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 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 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 if value == "creationToken" => {
152 list.creation_token_heuristic = true;
153 }
154 _ => {}
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 _ => {}
186 }
187 Ok(())
188}
189
190fn 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
274pub 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
296pub 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
318pub 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 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 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 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 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 for (_, temp) in pending {
395 let _ = fs::remove_file(&temp);
396 }
397 let _ = applied_any;
398 Ok(())
399}
400
401fn 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
416fn 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 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
437fn 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
457fn 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
483fn 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#[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 assert_eq!(
678 parse_bundle_config_key("bundle.my.nested.id.creationtoken"),
679 Some((Some("my.nested.id"), "creationtoken"))
680 );
681 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 assert!(parse_bundle_uri_line(&mut list, "bundle.mode=All").is_err());
692 assert!(parse_bundle_uri_line(&mut list, "bundle.mode=all ").is_err());
694 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 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 assert_eq!(relative_bundle_uri(base, "/srv/x.bundle"), "/srv/x.bundle");
721 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}