1use std::io::Cursor;
25use std::path::{Path, PathBuf};
26
27use crate::error::{Error, Result};
28use crate::fetch::Progress;
29use crate::objects::ObjectId;
30use crate::pkt_line::{self, Packet};
31use crate::progress::{Cancel, ProgressPhase, ProgressSink, ProgressUnit};
32use crate::protocol_v2::{cap_lines_for_command_request, server_advertises_bundle_uri};
33use crate::repo::Repository;
34use crate::resumable::{discard_download, download_resumable, resolve_url, ResumableError};
35use crate::transfer::TransferStats;
36use crate::transport::http::{discover_upload_pack_exact, HttpClient};
37
38pub const BUNDLE_REFS_PREFIX: &str = "refs/bundles/";
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
43pub enum BundleListMode {
44 #[default]
46 All,
47 Any,
49}
50
51impl BundleListMode {
52 fn as_str(self) -> &'static str {
53 match self {
54 Self::All => "all",
55 Self::Any => "any",
56 }
57 }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct BundleListEntry {
63 pub id: String,
65 pub uri: String,
67 pub creation_token: Option<u64>,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Default)]
73pub struct BundleList {
74 pub mode: BundleListMode,
76 pub bundles: Vec<BundleListEntry>,
78}
79
80impl BundleList {
81 pub fn parse<'a>(lines: impl IntoIterator<Item = &'a str>) -> Result<Self> {
88 let mut list = Self::default();
89 let mut entries: Vec<(String, Option<String>, Option<u64>)> = Vec::new();
90 for line in lines {
91 let Some((key, value)) = line.trim_end().split_once('=') else {
92 continue;
93 };
94 let Some(key) = key.strip_prefix("bundle.") else {
95 continue;
96 };
97 match key.to_ascii_lowercase().as_str() {
98 "version" if value != "1" => {
99 return Err(Error::Message(format!(
100 "unsupported bundle list version {value}"
101 )))
102 }
103 "mode" => {
104 list.mode = if value == "any" {
105 BundleListMode::Any
106 } else {
107 BundleListMode::All
108 };
109 }
110 _ => {}
111 }
112 let Some((id, field)) = key.rsplit_once('.') else {
113 continue;
114 };
115 let index = match entries.iter().position(|(i, _, _)| i == id) {
116 Some(i) => i,
117 None => {
118 entries.push((id.to_owned(), None, None));
119 entries.len() - 1
120 }
121 };
122 match field.to_ascii_lowercase().as_str() {
123 "uri" => entries[index].1 = Some(value.to_owned()),
124 "creationtoken" => entries[index].2 = value.parse().ok(),
125 _ => {}
126 }
127 }
128 list.bundles = entries
129 .into_iter()
130 .filter_map(|(id, uri, creation_token)| {
131 Some(BundleListEntry {
132 id,
133 uri: uri?,
134 creation_token,
135 })
136 })
137 .collect();
138 Ok(list)
139 }
140
141 #[must_use]
143 pub fn to_lines(&self) -> Vec<String> {
144 let mut lines = vec![
145 "bundle.version=1".to_owned(),
146 format!("bundle.mode={}", self.mode.as_str()),
147 ];
148 if self.bundles.iter().any(|b| b.creation_token.is_some()) {
149 lines.push("bundle.heuristic=creationToken".to_owned());
150 }
151 for bundle in &self.bundles {
152 lines.push(format!("bundle.{}.uri={}", bundle.id, bundle.uri));
153 if let Some(token) = bundle.creation_token {
154 lines.push(format!("bundle.{}.creationToken={token}", bundle.id));
155 }
156 }
157 lines
158 }
159
160 #[must_use]
163 pub fn clone_bundle(&self) -> Option<&BundleListEntry> {
164 self.bundles
165 .iter()
166 .filter(|b| b.creation_token.is_some())
167 .max_by_key(|b| b.creation_token)
168 .or_else(|| self.bundles.first())
169 }
170}
171
172pub fn fetch_bundle_list(client: &dyn HttpClient, repo_url: &str) -> Result<Option<BundleList>> {
185 let (version, _refs, caps, _head) =
186 discover_upload_pack_exact(client, repo_url, Some("version=2"))?;
187 if version != 2 || !server_advertises_bundle_uri(&caps) {
188 return Ok(None);
189 }
190 let mut request = Vec::new();
191 pkt_line::write_line_to_vec(&mut request, "command=bundle-uri")?;
192 for line in cap_lines_for_command_request(&caps) {
193 pkt_line::write_line_to_vec(&mut request, &line)?;
194 }
195 request.extend_from_slice(b"0001");
196 request.extend_from_slice(b"0000");
197 let url = format!("{}/git-upload-pack", repo_url.trim_end_matches('/'));
198 let body = client.post(
199 &url,
200 "application/x-git-upload-pack-request",
201 "application/x-git-upload-pack-result",
202 &request,
203 Some("version=2"),
204 )?;
205 let mut lines = Vec::new();
206 let mut cursor = Cursor::new(body);
207 while let Some(packet) = pkt_line::read_packet(&mut cursor)? {
208 match packet {
209 Packet::Data(line) => {
210 if let Some(msg) = line.strip_prefix("ERR ") {
211 return Err(Error::Message(format!("bundle-uri: {msg}")));
212 }
213 lines.push(line);
214 }
215 _ => break,
216 }
217 }
218 let list = BundleList::parse(lines.iter().map(String::as_str))?;
219 Ok((!list.bundles.is_empty()).then_some(list))
220}
221
222#[derive(Debug, Clone, PartialEq, Eq)]
224pub enum BundleSeed {
225 NotOffered,
227 Seeded(SeededBundle),
229 Failed {
232 uri: Option<String>,
234 reason: String,
236 },
237}
238
239#[derive(Debug, Clone, PartialEq, Eq)]
241pub struct SeededBundle {
242 pub uri: String,
244 pub bytes: u64,
246 pub resumed_from: u64,
248 pub transfer: TransferStats,
250 pub refs: Vec<(String, ObjectId)>,
252}
253
254#[must_use]
257pub fn bundle_cache_path(cache_dir: &Path, uri: &str) -> PathBuf {
258 let digest = crate::objects::HashAlgo::Sha256.checksum_digest(uri.as_bytes());
259 cache_dir.join(format!("{}.bundle", hex::encode(&digest[..12])))
260}
261
262#[must_use]
284pub fn seed_from_bundle_uri(
285 repo: &Repository,
286 client: &dyn HttpClient,
287 repo_url: &str,
288 cache_dir: &Path,
289 progress: &mut dyn Progress,
290 cancel: &dyn Cancel,
291) -> BundleSeed {
292 let list = match fetch_bundle_list(client, repo_url) {
293 Ok(Some(list)) => list,
294 Ok(None) => return BundleSeed::NotOffered,
295 Err(e) => {
296 return BundleSeed::Failed {
297 uri: None,
298 reason: e.to_string(),
299 }
300 }
301 };
302 let Some(entry) = list.clone_bundle() else {
303 return BundleSeed::NotOffered;
304 };
305 let base = format!("{}/", repo_url.trim_end_matches('/'));
306 let Some(uri) = resolve_url(&base, &entry.uri) else {
307 return BundleSeed::Failed {
308 uri: Some(entry.uri.clone()),
309 reason: "the bundle URI is not a valid URL".to_owned(),
310 };
311 };
312 match seed_from(repo, client, &uri, cache_dir, progress, cancel) {
313 Ok(seeded) => BundleSeed::Seeded(seeded),
314 Err(e) => BundleSeed::Failed {
315 uri: Some(uri),
316 reason: e.to_string(),
317 },
318 }
319}
320
321fn seed_from(
322 repo: &Repository,
323 client: &dyn HttpClient,
324 uri: &str,
325 cache_dir: &Path,
326 progress: &mut dyn Progress,
327 cancel: &dyn Cancel,
328) -> Result<SeededBundle> {
329 std::fs::create_dir_all(cache_dir)?;
330 let dest = bundle_cache_path(cache_dir, uri);
331 let downloaded =
332 download_resumable(client, uri, &dest, progress, cancel).map_err(|e| match e {
333 ResumableError::Cancelled => Error::Cancelled,
334 e => Error::from(Box::new(e)),
335 })?;
336 let bundle = repo.bundles().open(&dest).map_err(bundle_error)?;
337 if !bundle.header().prerequisites.is_empty() {
338 discard_download(&dest)?;
340 return Err(Error::Message(
341 "the advertised bundle needs history this clone does not have".to_owned(),
342 ));
343 }
344 let mut indexing = WithoutReceiving {
346 inner: progress,
347 muted: false,
348 };
349 let unbundled = match bundle.unbundle_with_progress(repo, &mut indexing, cancel) {
350 Ok(u) => u,
351 Err(e) => {
352 discard_download(&dest)?;
354 return Err(bundle_error(e));
355 }
356 };
357 let mut refs = Vec::new();
358 for tip in &unbundled.refs {
359 let Some(rest) = tip.name.strip_prefix("refs/") else {
360 continue;
361 };
362 let name = format!("{BUNDLE_REFS_PREFIX}{rest}");
363 if crate::check_ref_format::check_refname_format(
364 &name,
365 &crate::check_ref_format::RefNameOptions::default(),
366 )
367 .is_err()
368 {
369 continue;
370 }
371 crate::refs::update_direct_ref_on_store(repo.refs(), repo.git_dir(), &name, &tip.oid)?;
372 refs.push((name, tip.oid));
373 }
374 discard_download(&dest)?;
375 Ok(SeededBundle {
376 uri: uri.to_owned(),
377 bytes: downloaded.bytes,
378 resumed_from: downloaded.resumed_from,
379 transfer: unbundled.transfer,
380 refs,
381 })
382}
383
384struct WithoutReceiving<'a> {
386 inner: &'a mut dyn Progress,
387 muted: bool,
388}
389
390impl ProgressSink for WithoutReceiving<'_> {
391 fn start_with_unit(&mut self, phase: ProgressPhase, unit: ProgressUnit, total: Option<u64>) {
392 self.muted = phase == ProgressPhase::ReceivingObjects;
393 if !self.muted {
394 self.inner.start_with_unit(phase, unit, total);
395 }
396 }
397
398 fn start(&mut self, phase: ProgressPhase, total: Option<u64>) {
399 self.start_with_unit(phase, phase.default_unit(), total);
400 }
401
402 fn inc(&mut self, units: u64) {
403 if !self.muted {
404 self.inner.inc(units);
405 }
406 }
407
408 fn set(&mut self, current: u64) {
409 if !self.muted {
410 self.inner.set(current);
411 }
412 }
413
414 fn finish(&mut self) {
415 if !self.muted {
416 self.inner.finish();
417 }
418 self.muted = false;
419 }
420}
421
422fn bundle_error(e: crate::bundle::BundleError) -> Error {
423 Error::Message(format!("bundle: {e}"))
424}
425
426pub fn remove_bundle_refs(repo: &Repository) -> Result<()> {
433 for (name, _) in crate::refs::list_refs(repo.git_dir(), BUNDLE_REFS_PREFIX)? {
434 crate::refs::delete_ref(repo.git_dir(), &name)?;
435 }
436 Ok(())
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442
443 #[test]
444 fn bundle_lists_round_trip() {
445 let list = BundleList {
446 mode: BundleListMode::All,
447 bundles: vec![BundleListEntry {
448 id: "clone".to_owned(),
449 uri: "https://h/r/bundles/1-ab.bundle".to_owned(),
450 creation_token: Some(1),
451 }],
452 };
453 let lines = list.to_lines();
454 assert_eq!(
455 lines,
456 [
457 "bundle.version=1",
458 "bundle.mode=all",
459 "bundle.heuristic=creationToken",
460 "bundle.clone.uri=https://h/r/bundles/1-ab.bundle",
461 "bundle.clone.creationToken=1",
462 ]
463 );
464 assert_eq!(
465 BundleList::parse(lines.iter().map(String::as_str)).ok(),
466 Some(list)
467 );
468 }
469
470 #[test]
471 fn bundle_lists_pick_the_newest_bundle_and_skip_junk() {
472 let list = BundleList::parse([
473 "bundle.version=1",
474 "bundle.mode=any",
475 "bundle.old.uri=old.bundle",
476 "bundle.old.creationToken=5",
477 "bundle.new.uri=new.bundle",
478 "bundle.new.creationToken=9",
479 "bundle.nouri.creationToken=10",
480 "not a key",
481 "other.key=1",
482 ]);
483 let list = match list {
484 Ok(l) => l,
485 Err(e) => panic!("parse: {e}"),
486 };
487 assert_eq!(list.mode, BundleListMode::Any);
488 assert_eq!(list.bundles.len(), 2);
489 assert_eq!(list.clone_bundle().map(|b| b.id.as_str()), Some("new"));
490 assert!(BundleList::parse(["bundle.version=2"]).is_err());
491 assert_eq!(BundleList::default().clone_bundle(), None);
492 }
493
494 #[test]
495 fn cache_paths_depend_only_on_the_uri() {
496 let dir = Path::new("/cache");
497 assert_eq!(
498 bundle_cache_path(dir, "http://h/a"),
499 bundle_cache_path(dir, "http://h/a")
500 );
501 assert_ne!(
502 bundle_cache_path(dir, "http://h/a"),
503 bundle_cache_path(dir, "http://h/b")
504 );
505 assert!(bundle_cache_path(dir, "x").starts_with(dir));
506 }
507}