1use 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 #[default]
38 None,
39 All,
41 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
62fn parse_bundle_config_key(var: &str) -> Option<(Option<&str>, &str)> {
69 let rest = var.strip_prefix("bundle")?;
70 let rest = rest.strip_prefix('.')?;
71 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
91pub 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 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 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 if value == "creationToken" {
153 list.creation_token_heuristic = true;
154 }
155 }
156 _ => {}
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 _ => {}
188 }
189 Ok(())
190}
191
192fn 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
273pub 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
295pub 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 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 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 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 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 for (_, temp) in pending {
378 let _ = fs::remove_file(&temp);
379 }
380 let _ = applied_any;
381 Ok(())
382}
383
384fn 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 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
405fn 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
425fn 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
451fn download_https_bundle_uri(uri: &str, dest: &Path) -> Result<()> {
457 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 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 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 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 assert_eq!(
663 parse_bundle_config_key("bundle.my.nested.id.creationtoken"),
664 Some((Some("my.nested.id"), "creationtoken"))
665 );
666 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 assert!(parse_bundle_uri_line(&mut list, "bundle.mode=All").is_err());
677 assert!(parse_bundle_uri_line(&mut list, "bundle.mode=all ").is_err());
679 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 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 assert_eq!(relative_bundle_uri(base, "/srv/x.bundle"), "/srv/x.bundle");
706 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}