This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / knot2 / crates / knot-pack / tests / differential.rs
22 kB 697 lines
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}