This repository has no description
1use std::collections::{BTreeSet, HashMap};
2use std::path::Path;
3
4use axum::Router;
5use axum::body::{Body, Bytes};
6use axum::extract::{DefaultBodyLimit, Path as UrlPath, Query, State};
7use axum::http::header;
8use axum::response::Response;
9use axum::routing::{get, post};
10use knot_git::Layout;
11use knot_pack::PackError;
12use knot_types::{ObjectFormat, RepoDid, RepoRkey};
13
14mod common;
15use common::{
16 advance_via_receive, git, must, object_set, seed_branches_and_tag, serve_dids, spawn,
17};
18
19#[derive(Clone)]
20struct Receive {
21 layout: Layout,
22}
23
24fn open(layout: &Layout, did: &str, name: &str) -> Result<knot_git::Repo, PackError> {
25 let did = RepoDid::new(did).map_err(|e| PackError::BadPath(e.to_string()))?;
26 RepoRkey::new(name).map_err(|e| PackError::BadPath(e.to_string()))?;
27 Ok(layout.open(&did)?)
28}
29
30fn raw_response(content_type: &'static str, body: Vec<u8>) -> Response {
31 Response::builder()
32 .header(header::CONTENT_TYPE, content_type)
33 .body(Body::from(body))
34 .unwrap()
35}
36
37async fn receive_info_refs(
38 State(state): State<Receive>,
39 UrlPath((did, name)): UrlPath<(String, String)>,
40 Query(query): Query<HashMap<String, String>>,
41) -> Result<Response, PackError> {
42 let repo = open(&state.layout, &did, &name)?;
43 match query.get("service").map(String::as_str) {
44 Some("git-upload-pack") => Ok(raw_response(
45 "application/x-git-upload-pack-advertisement",
46 knot_pack::advertise_upload(&repo)?,
47 )),
48 Some("git-receive-pack") => Ok(raw_response(
49 "application/x-git-receive-pack-advertisement",
50 knot_pack::advertise_receive(&repo)?,
51 )),
52 _ => Err(PackError::UnsupportedService),
53 }
54}
55
56async fn receive_upload(
57 State(state): State<Receive>,
58 UrlPath((did, name)): UrlPath<(String, String)>,
59 body: Bytes,
60) -> Result<Response, PackError> {
61 let repo = open(&state.layout, &did, &name)?;
62 let result = knot_pack::upload_pack(&repo, &body)?;
63 Ok(raw_response("application/x-git-upload-pack-result", result))
64}
65
66async fn receive_receive(
67 State(state): State<Receive>,
68 UrlPath((did, name)): UrlPath<(String, String)>,
69 body: Bytes,
70) -> Result<Response, PackError> {
71 let repo = open(&state.layout, &did, &name)?;
72 let result = knot_pack::receive_pack(&repo, &body)?;
73 Ok(raw_response(
74 "application/x-git-receive-pack-result",
75 result,
76 ))
77}
78
79fn knot_receive_router(layout: Layout) -> Router {
80 Router::new()
81 .route("/{did}/{name}/info/refs", get(receive_info_refs))
82 .route("/{did}/{name}/git-upload-pack", post(receive_upload))
83 .route("/{did}/{name}/git-receive-pack", post(receive_receive))
84 .layer(DefaultBodyLimit::disable())
85 .with_state(Receive { layout })
86}
87
88fn clone_to(scratch: &Path, url: &str, dest: &Path) {
89 must(scratch, &["clone", "-q", url, dest.to_str().unwrap()]);
90}
91
92fn publish(work: &Path, bares: [&Path; 2], refs: &[&str], head: &str) {
93 bares.into_iter().for_each(|bare| {
94 let push = [&["push", "-q", bare.to_str().unwrap()], refs].concat();
95 must(work, &push);
96 must(bare, &["symbolic-ref", "HEAD", head]);
97 });
98}
99
100fn seed_cross_branch_deltas(work: &Path, knot_bare: &Path, canon_bare: &Path) {
101 std::fs::create_dir_all(work).unwrap();
102 must(work, &["init", "-q", "-b", "base"]);
103 std::fs::write(work.join("README.md"), "thin\n").unwrap();
104 must(work, &["add", "-A"]);
105 must(work, &["commit", "-q", "-m", "base"]);
106 let base = must(work, &["rev-parse", "HEAD"]);
107
108 let bulk = |stem: usize, extra: &str| {
109 let body: String = (0..200)
110 .map(|line| format!("file {stem} line {line} with some shared payload\n"))
111 .collect();
112 format!("{body}{extra}")
113 };
114 let branch = |name: &str, extra: &str| {
115 must(work, &["checkout", "-q", "-b", name, &base]);
116 (0..8).for_each(|stem| {
117 std::fs::write(work.join(format!("bulk-{stem}.txt")), bulk(stem, extra)).unwrap();
118 });
119 must(work, &["add", "-A"]);
120 must(work, &["commit", "-q", "-m", name]);
121 };
122 branch("side", "");
123 branch("other", "one divergent trailing line\n");
124 must(work, &["checkout", "-q", "base"]);
125
126 publish(
127 work,
128 [knot_bare, canon_bare],
129 &["base", "side", "other"],
130 "refs/heads/base",
131 );
132 [knot_bare, canon_bare].into_iter().for_each(|bare| {
133 must(bare, &["repack", "-adf", "--window=50", "--depth=50"]);
134 });
135}
136
137fn seed_nested_bares(work: &Path, knot_bare: &Path, canon_bare: &Path, format: ObjectFormat) {
138 let fmt = format!("--object-format={}", format.capability());
139 std::fs::create_dir_all(work).unwrap();
140 must(work, &["init", &fmt, "-q", "-b", "main"]);
141 std::fs::create_dir_all(work.join("a/c")).unwrap();
142 std::fs::create_dir_all(work.join("b")).unwrap();
143 std::fs::create_dir_all(work.join("deep/mid/bottom")).unwrap();
144 std::fs::write(work.join("root.txt"), b"01234567").unwrap();
145 std::fs::write(work.join("a/c/leaf"), b"hi\n").unwrap();
146 std::fs::write(work.join("b/leaf"), b"hi\n").unwrap();
147 std::fs::write(work.join("deep/mid/bottom/far.txt"), "x".repeat(20)).unwrap();
148 must(work, &["add", "-A"]);
149 must(work, &["commit", "-q", "-m", "c1"]);
150 std::fs::write(work.join("deep/mid/bottom/far.txt"), "y".repeat(40)).unwrap();
151 must(work, &["add", "-A"]);
152 must(work, &["commit", "-q", "-m", "c2"]);
153 publish(work, [knot_bare, canon_bare], &["main"], "refs/heads/main");
154}
155
156#[derive(Clone, Copy, PartialEq)]
157enum Mode {
158 Serve,
159 Receive,
160}
161
162struct Servers {
163 canon_bare: std::path::PathBuf,
164 knot_bare: std::path::PathBuf,
165 canon_url: String,
166 knot_url: String,
167}
168
169async fn stand_up(
170 scan: &Path,
171 canon_root: &Path,
172 did: &RepoDid,
173 name: &RepoRkey,
174 format: ObjectFormat,
175 mode: Mode,
176) -> Servers {
177 let layout = Layout::new(scan).with_object_format(format);
178 layout.create(did).unwrap();
179 let knot_bare = layout.repo_path(did).unwrap();
180
181 let canon_bare = canon_root.join(format!("{}.git", name.as_str()));
182 let fmt = format!("--object-format={}", format.capability());
183 must(
184 Path::new("/tmp"),
185 &["init", "--bare", &fmt, "-q", canon_bare.to_str().unwrap()],
186 );
187
188 let knot = match mode {
189 Mode::Serve => {
190 spawn(
191 knot_pack::router(
192 layout,
193 serve_dids(),
194 std::sync::Arc::new(knot_runtime::SystemClock),
195 ),
196 "[::1]:0",
197 )
198 .await
199 }
200 Mode::Receive => spawn(knot_receive_router(layout), "[::1]:0").await,
201 };
202 let knot_url = match mode {
203 Mode::Serve => format!("http://{knot}/{}", did.as_str()),
204 Mode::Receive => format!("http://{knot}/{}/{}", did.as_str(), name.as_str()),
205 };
206 Servers {
207 canon_url: format!("file://{}", canon_bare.to_str().unwrap()),
208 canon_bare,
209 knot_bare,
210 knot_url,
211 }
212}
213
214fn single_pack_idx(bare: &Path) -> std::path::PathBuf {
215 std::fs::read_dir(bare.join("objects/pack"))
216 .unwrap()
217 .filter_map(Result::ok)
218 .map(|entry| entry.path())
219 .find(|path| path.extension().is_some_and(|ext| ext == "idx"))
220 .expect("a single pack index after repack")
221}
222
223async fn clone_lifecycle(format: ObjectFormat, did: &str, name: &str) {
224 let scan = tempfile::tempdir().unwrap();
225 let canon_root = tempfile::tempdir().unwrap();
226 let did = RepoDid::new(did).unwrap();
227 let name = RepoRkey::new(name).unwrap();
228 let s = stand_up(
229 scan.path(),
230 canon_root.path(),
231 &did,
232 &name,
233 format,
234 Mode::Serve,
235 )
236 .await;
237
238 let scratch = tempfile::tempdir().unwrap();
239 let work = scratch.path().join("work");
240 seed_branches_and_tag(&work, [&s.knot_bare, &s.canon_bare], format);
241
242 let canon = scratch.path().join("clone-canon");
243 let knot = scratch.path().join("clone-knot");
244 clone_to(scratch.path(), &s.canon_url, &canon);
245 clone_to(scratch.path(), &s.knot_url, &knot);
246 let parse = |dir: &Path, rev: &str| must(dir, &["rev-parse", rev]);
247 assert_eq!(
248 parse(&knot, "--show-object-format"),
249 format.capability(),
250 "{format:?} clone inherits object format"
251 );
252 assert_eq!(
253 parse(&canon, "HEAD^{tree}"),
254 parse(&knot, "HEAD^{tree}"),
255 "{format:?} same checked-out tree"
256 );
257 assert_eq!(
258 must(&canon, &["ls-files", "-s"]),
259 must(&knot, &["ls-files", "-s"]),
260 "{format:?} identical working tree"
261 );
262 assert_eq!(
263 object_set(&canon),
264 object_set(&knot),
265 "{format:?} clone transfers canonical object set"
266 );
267 assert_eq!(
268 must(&canon, &["branch", "-r"]),
269 must(&knot, &["branch", "-r"]),
270 "{format:?} same remote branches"
271 );
272
273 must(
274 &s.knot_bare,
275 &["-c", "repack.writeBitmaps=false", "repack", "-adq"],
276 );
277 let repo = knot_git::Repo::open(&s.knot_bare).unwrap();
278 assert!(
279 knot_git::write_bitmap(&repo, &single_pack_idx(&s.knot_bare)).unwrap(),
280 "{format:?} single-pack repo gets a bitmap"
281 );
282 must(&s.knot_bare, &["rev-list", "--test-bitmap", "HEAD"]);
283
284 let bm_canon = scratch.path().join("reclone-canon");
285 let bm_knot = scratch.path().join("reclone-knot");
286 clone_to(scratch.path(), &s.canon_url, &bm_canon);
287 clone_to(scratch.path(), &s.knot_url, &bm_knot);
288 assert_eq!(
289 object_set(&bm_canon),
290 object_set(&bm_knot),
291 "{format:?} bitmap fast path serves canonical object set"
292 );
293 must(&bm_knot, &["fsck", "--strict"]);
294
295 std::fs::write(work.join("incremental.txt"), "fetch me\n").unwrap();
296 must(&work, &["add", "-A"]);
297 must(&work, &["commit", "-q", "-m", "c4"]);
298 let tip = must(&work, &["rev-parse", "HEAD"]);
299 let old = must(&work, &["rev-parse", "HEAD~1"]);
300 advance_via_receive(&s.knot_bare, &work, &old, &tip);
301 must(
302 &work,
303 &["push", "-q", s.canon_bare.to_str().unwrap(), "main"],
304 );
305 must(&canon, &["fetch", "-q", "origin"]);
306 must(&knot, &["fetch", "-q", "origin"]);
307 assert_eq!(
308 must(&knot, &["rev-parse", "origin/main"]),
309 tip,
310 "{format:?} fetch advances origin/main"
311 );
312 assert_eq!(
313 object_set(&canon),
314 object_set(&knot),
315 "{format:?} incremental fetch transfers canonical object set"
316 );
317}
318
319#[tokio::test(flavor = "multi_thread")]
320async fn differential_clone_fetch_and_bitmap_serving_match_canonical() {
321 clone_lifecycle(ObjectFormat::SHA1, "did:plc:squid", "scallop").await;
322 clone_lifecycle(ObjectFormat::SHA256, "did:plc:nautilus", "whelk").await;
323}
324
325#[tokio::test(flavor = "multi_thread")]
326async fn a_shallow_server_repo_is_served_and_the_clone_becomes_shallow() {
327 let scan = tempfile::tempdir().unwrap();
328 let canon_root = tempfile::tempdir().unwrap();
329 let did = RepoDid::new("did:plc:periwinkle").unwrap();
330 let name = RepoRkey::new("conch").unwrap();
331 let s = stand_up(
332 scan.path(),
333 canon_root.path(),
334 &did,
335 &name,
336 ObjectFormat::SHA1,
337 Mode::Serve,
338 )
339 .await;
340
341 let scratch = tempfile::tempdir().unwrap();
342 let work = scratch.path().join("work");
343 std::fs::create_dir_all(&work).unwrap();
344 must(&work, &["init", "-q", "-b", "main"]);
345 (1..=5).for_each(|n| {
346 std::fs::write(work.join("history.txt"), format!("revision {n}\n")).unwrap();
347 must(&work, &["add", "-A"]);
348 must(&work, &["commit", "-q", "-m", &format!("c{n}")]);
349 });
350 let work_url = format!("file://{}", work.to_str().unwrap());
351 [&s.knot_bare, &s.canon_bare].into_iter().for_each(|bare| {
352 must(
353 bare,
354 &[
355 "fetch",
356 "-q",
357 "--depth=2",
358 &work_url,
359 "main:refs/heads/main",
360 ],
361 );
362 must(bare, &["symbolic-ref", "HEAD", "refs/heads/main"]);
363 assert!(bare.join("shallow").exists(), "server repo is shallow");
364 });
365
366 let canon = scratch.path().join("clone-canon");
367 let knot = scratch.path().join("clone-knot");
368 clone_to(scratch.path(), &s.canon_url, &canon);
369 clone_to(scratch.path(), &s.knot_url, &knot);
370 assert!(
371 knot.join(".git/shallow").exists(),
372 "clone of a shallow server is itself shallow"
373 );
374 must(&knot, &["fsck"]);
375 assert_eq!(
376 must(&canon, &["rev-list", "--count", "HEAD"]),
377 must(&knot, &["rev-list", "--count", "HEAD"]),
378 "both clones see the same clamped depth"
379 );
380 assert_eq!(
381 object_set(&canon),
382 object_set(&knot),
383 "shallow clone transfers canonical object set"
384 );
385}
386
387fn fetch_branch(scratch: &Path, url: &str, label: &str, branch: &str) -> BTreeSet<String> {
388 let clone = scratch.join(format!("clone-{label}-{branch}"));
389 must(
390 scratch,
391 &[
392 "clone",
393 "-q",
394 "--single-branch",
395 "--branch",
396 "base",
397 url,
398 clone.to_str().unwrap(),
399 ],
400 );
401 must(&clone, &["fetch", "-q", "origin", branch]);
402 must(&clone, &["fsck", "--strict"]);
403 object_set(&clone)
404}
405
406#[tokio::test(flavor = "multi_thread")]
407async fn a_thin_fetch_never_deltas_against_objects_the_client_lacks() {
408 let scan = tempfile::tempdir().unwrap();
409 let canon_root = tempfile::tempdir().unwrap();
410 let did = RepoDid::new("did:plc:limpet").unwrap();
411 let name = RepoRkey::new("whelk").unwrap();
412 let s = stand_up(
413 scan.path(),
414 canon_root.path(),
415 &did,
416 &name,
417 ObjectFormat::SHA1,
418 Mode::Serve,
419 )
420 .await;
421
422 let scratch = tempfile::tempdir().unwrap();
423 seed_cross_branch_deltas(&scratch.path().join("work"), &s.knot_bare, &s.canon_bare);
424
425 ["side", "other"].into_iter().for_each(|branch| {
426 assert_eq!(
427 fetch_branch(scratch.path(), &s.canon_url, "canon", branch),
428 fetch_branch(scratch.path(), &s.knot_url, "knot", branch),
429 "thin fetch of {branch} onto a base-only clone transfers canonical object set"
430 );
431 });
432}
433
434fn push_sequence(remote: &str, scratch: &Path, label: &str) -> Vec<(String, bool)> {
435 let clone = scratch.join(format!("push-{label}"));
436 must(scratch, &["clone", "-q", remote, clone.to_str().unwrap()]);
437 let commit = |file: &str, body: &str, msg: &str| {
438 std::fs::write(clone.join(file), body).unwrap();
439 must(&clone, &["add", "-A"]);
440 must(&clone, &["commit", "-q", "-m", msg]);
441 };
442 let push = |args: &[&str]| git(&clone, &[&["push", "-q", "origin"], args].concat()).0;
443
444 commit("ff.txt", "fast forward\n", "fast forward");
445 let ff = push(&["main"]);
446 must(&clone, &["checkout", "-q", "-b", "feature"]);
447 commit("feature.txt", "new branch\n", "feature");
448 let new_branch = push(&["feature"]);
449 let delete = push(&["--delete", "feature"]);
450 must(&clone, &["checkout", "-q", "main"]);
451 must(&clone, &["reset", "-q", "--hard", "HEAD~1"]);
452 commit("diverge.txt", "non fast forward\n", "diverge");
453 let non_ff = push(&["main"]);
454
455 vec![
456 ("fast-forward".to_string(), ff),
457 ("new-branch".to_string(), new_branch),
458 ("delete-branch".to_string(), delete),
459 ("non-fast-forward".to_string(), non_ff),
460 ]
461}
462
463async fn push_diff(format: ObjectFormat, did: &str, name: &str) {
464 let scan = tempfile::tempdir().unwrap();
465 let canon_root = tempfile::tempdir().unwrap();
466 let did = RepoDid::new(did).unwrap();
467 let name = RepoRkey::new(name).unwrap();
468 let s = stand_up(
469 scan.path(),
470 canon_root.path(),
471 &did,
472 &name,
473 format,
474 Mode::Receive,
475 )
476 .await;
477
478 let scratch = tempfile::tempdir().unwrap();
479 seed_branches_and_tag(
480 &scratch.path().join("work"),
481 [&s.knot_bare, &s.canon_bare],
482 format,
483 );
484
485 let advertised = |bare: &Path| {
486 must(
487 bare,
488 &[
489 "for-each-ref",
490 "--format=%(refname) %(objectname)",
491 "refs/heads/",
492 "refs/tags/",
493 ],
494 )
495 };
496 assert_eq!(
497 push_sequence(&s.canon_url, scratch.path(), "canon"),
498 push_sequence(&s.knot_url, scratch.path(), "knot"),
499 "{format:?} knot accepts and rejects the same pushes as canonical git"
500 );
501 assert_eq!(
502 advertised(&s.canon_bare),
503 advertised(&s.knot_bare),
504 "{format:?} both servers hold the same refs after an identical push sequence"
505 );
506}
507
508#[tokio::test(flavor = "multi_thread")]
509async fn differential_push_verdicts_match_canonical() {
510 push_diff(ObjectFormat::SHA1, "did:plc:squid", "barnacle").await;
511 push_diff(ObjectFormat::SHA256, "did:plc:cuttle", "scallop").await;
512}
513
514fn enable_filter(bare: &Path) {
515 must(bare, &["config", "uploadpack.allowFilter", "true"]);
516 must(bare, &["config", "uploadpack.allowAnySHA1InWant", "true"]);
517}
518
519fn filtered_clone(scratch: &Path, url: &str, label: &str, filter: &str) -> BTreeSet<String> {
520 let clone = scratch.join(format!("clone-{label}"));
521 must(
522 scratch,
523 &[
524 "clone",
525 "-q",
526 "--no-checkout",
527 &format!("--filter={filter}"),
528 url,
529 clone.to_str().unwrap(),
530 ],
531 );
532 object_set(&clone)
533}
534
535fn fetched_set(
536 scratch: &Path,
537 url: &str,
538 label: &str,
539 oid: &str,
540 filter: &str,
541 format: ObjectFormat,
542) -> BTreeSet<String> {
543 let dest = scratch.join(format!("fetch-{label}"));
544 let fmt = format!("--object-format={}", format.capability());
545 must(scratch, &["init", &fmt, "-q", dest.to_str().unwrap()]);
546 must(
547 &dest,
548 &["fetch", "-q", &format!("--filter={filter}"), url, oid],
549 );
550 object_set(&dest)
551}
552
553fn blobless_checkout(scratch: &Path, url: &str, label: &str) -> BTreeSet<String> {
554 let clone = scratch.join(format!("clone-{label}"));
555 must(
556 scratch,
557 &[
558 "clone",
559 "-q",
560 "--filter=blob:none",
561 url,
562 clone.to_str().unwrap(),
563 ],
564 );
565 must(&clone, &["fsck", "--strict"]);
566 object_set(&clone)
567}
568
569async fn partial_clone(format: ObjectFormat, did: &str, name: &str) {
570 let scan = tempfile::tempdir().unwrap();
571 let canon_root = tempfile::tempdir().unwrap();
572 let did = RepoDid::new(did).unwrap();
573 let name = RepoRkey::new(name).unwrap();
574 let s = stand_up(
575 scan.path(),
576 canon_root.path(),
577 &did,
578 &name,
579 format,
580 Mode::Serve,
581 )
582 .await;
583 enable_filter(&s.canon_bare);
584
585 let scratch = tempfile::tempdir().unwrap();
586 let work = scratch.path().join("work");
587 seed_nested_bares(&work, &s.knot_bare, &s.canon_bare, format);
588
589 [
590 "tree:0",
591 "tree:1",
592 "tree:2",
593 "tree:3",
594 "tree:4",
595 "tree:5",
596 "blob:none",
597 "blob:limit=3",
598 "blob:limit=4",
599 "blob:limit=8",
600 "blob:limit=20",
601 ]
602 .into_iter()
603 .for_each(|filter| {
604 assert_eq!(
605 filtered_clone(
606 scratch.path(),
607 &s.canon_url,
608 &format!("canon-{filter}"),
609 filter
610 ),
611 filtered_clone(
612 scratch.path(),
613 &s.knot_url,
614 &format!("knot-{filter}"),
615 filter
616 ),
617 "{format:?} a --filter={filter} clone transfers canonical object set"
618 );
619 });
620
621 let deep = must(&work, &["rev-parse", "HEAD:deep"]);
622 let far = must(&work, &["rev-parse", "HEAD:deep/mid/bottom/far.txt"]);
623 [
624 (&deep, "tree:0"),
625 (&deep, "tree:1"),
626 (&deep, "tree:2"),
627 (&deep, "tree:3"),
628 (&deep, "blob:none"),
629 (&deep, "blob:limit=10"),
630 (&far, "blob:none"),
631 (&far, "blob:limit=10"),
632 ]
633 .into_iter()
634 .enumerate()
635 .for_each(|(case, (oid, filter))| {
636 assert_eq!(
637 fetched_set(
638 scratch.path(),
639 &s.canon_url,
640 &format!("canon-{case}"),
641 oid,
642 filter,
643 format
644 ),
645 fetched_set(
646 scratch.path(),
647 &s.knot_url,
648 &format!("knot-{case}"),
649 oid,
650 filter,
651 format
652 ),
653 "{format:?} explicit want {oid} --filter={filter} transfers canonical object set"
654 );
655 });
656
657 assert_eq!(
658 blobless_checkout(scratch.path(), &s.canon_url, "canon"),
659 blobless_checkout(scratch.path(), &s.knot_url, "knot"),
660 "{format:?} a blobless clone faults its checkout blobs in like canonical git"
661 );
662}
663
664#[tokio::test(flavor = "multi_thread")]
665async fn differential_partial_clone_matches_canonical() {
666 partial_clone(ObjectFormat::SHA1, "did:plc:anemone", "barnacle").await;
667 partial_clone(ObjectFormat::SHA256, "did:plc:cuttle", "uni").await;
668}
669
670#[tokio::test(flavor = "multi_thread")]
671async fn an_empty_sha256_repo_clones_to_sha256_over_v2_and_v0() {
672 let scan = tempfile::tempdir().unwrap();
673 let did = RepoDid::new("did:plc:limpet").unwrap();
674 let layout = Layout::new(scan.path()).with_object_format(ObjectFormat::SHA256);
675 layout.create(&did).unwrap();
676 let knot = spawn(
677 knot_pack::router(
678 layout,
679 serve_dids(),
680 std::sync::Arc::new(knot_runtime::SystemClock),
681 ),
682 "[::1]:0",
683 )
684 .await;
685 let url = format!("http://{knot}/{}", did.as_str());
686
687 let scratch = tempfile::tempdir().unwrap();
688 [("v2", "2"), ("v0", "0")].into_iter().for_each(|(label, version)| {
689 let dst = scratch.path().join(format!("clone-{label}"));
690 must(scratch.path(), &["-c", &format!("protocol.version={version}"), "clone", "-q", &url, dst.to_str().unwrap()]);
691 assert_eq!(
692 must(&dst, &["rev-parse", "--show-object-format"]),
693 "sha256",
694 "{label}: an empty knot sha256 repo clones to sha256 from its advertised capabilities"
695 );
696 });
697}