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 / git_client.rs
31 kB 931 lines
1use std::net::SocketAddr; 2use std::path::Path; 3use std::sync::Arc; 4 5use axum::body::Body; 6use axum::http::header; 7use knot_git::{Layout, RefUpdate}; 8use knot_pack::{RepoLookup, RepoResolver, RepoTarget}; 9use knot_types::{OwnerDid, RefName, RepoDid, RepoRkey}; 10 11mod common; 12use common::{commit, git, must, pkt, serve_dids, spawn, unsideband}; 13 14fn seed_repo(work: &Path, bare: &str, file: &str, contents: &str) { 15 std::fs::create_dir_all(work).unwrap(); 16 must(work, &["init", "-q", "-b", "main"]); 17 commit(work, file, contents, "initial"); 18 must(work, &["push", "-q", bare, "main"]); 19 must( 20 Path::new(bare), 21 &["symbolic-ref", "HEAD", "refs/heads/main"], 22 ); 23} 24 25fn url(addr: SocketAddr, did: &RepoDid, _name: &RepoRkey) -> String { 26 format!("http://{addr}/{}", did.as_str()) 27} 28 29fn pack_object_oids(pack: &[u8]) -> std::collections::BTreeSet<String> { 30 use std::io::Write; 31 use std::process::Stdio; 32 33 let bare = tempfile::tempdir().unwrap(); 34 let path = bare.path().to_str().unwrap(); 35 assert!( 36 knot_fixtures::command(bare.path()) 37 .args(["init", "--bare", "-q", path]) 38 .output() 39 .unwrap() 40 .status 41 .success() 42 ); 43 let mut child = knot_fixtures::command(bare.path()) 44 .args(["index-pack", "--stdin"]) 45 .stdin(Stdio::piped()) 46 .stdout(Stdio::piped()) 47 .stderr(Stdio::piped()) 48 .spawn() 49 .unwrap(); 50 child.stdin.take().unwrap().write_all(pack).unwrap(); 51 let indexed = child.wait_with_output().unwrap(); 52 assert!( 53 indexed.status.success(), 54 "index-pack of our pack failed:\n{}", 55 String::from_utf8_lossy(&indexed.stderr) 56 ); 57 let listed = knot_fixtures::command(bare.path()) 58 .args([ 59 "cat-file", 60 "--batch-all-objects", 61 "--batch-check=%(objectname)", 62 ]) 63 .output() 64 .unwrap(); 65 String::from_utf8_lossy(&listed.stdout) 66 .lines() 67 .map(|line| line.trim().to_string()) 68 .filter(|line| !line.is_empty()) 69 .collect() 70} 71 72fn canonical_pack(work: &Path, revs: &[String]) -> Vec<u8> { 73 use std::io::Write; 74 use std::process::Stdio; 75 76 let mut child = knot_fixtures::command(work) 77 .args(["pack-objects", "--revs", "--stdout", "-q"]) 78 .stdin(Stdio::piped()) 79 .stdout(Stdio::piped()) 80 .stderr(Stdio::piped()) 81 .spawn() 82 .unwrap(); 83 child 84 .stdin 85 .take() 86 .unwrap() 87 .write_all(revs.join("\n").as_bytes()) 88 .unwrap(); 89 let out = child.wait_with_output().unwrap(); 90 assert!(out.status.success(), "canonical pack-objects failed"); 91 out.stdout 92} 93 94fn v2_fetch_body(wants: &[String], haves: &[String]) -> Vec<u8> { 95 let mut body = pkt(b"command=fetch\n"); 96 body.extend_from_slice(b"0001"); 97 wants 98 .iter() 99 .for_each(|want| body.extend(pkt(format!("want {want}\n").as_bytes()))); 100 haves 101 .iter() 102 .for_each(|have| body.extend(pkt(format!("have {have}\n").as_bytes()))); 103 body.extend(pkt(b"done\n")); 104 body.extend_from_slice(b"0000"); 105 body 106} 107 108#[tokio::test(flavor = "multi_thread")] 109async fn http_routing_resolves_owner_rkey_and_dot_git_and_404s_the_unhosted() { 110 let scan = tempfile::tempdir().unwrap(); 111 let layout = Layout::new(scan.path()); 112 let owner = OwnerDid::new("did:plc:nel").unwrap(); 113 let plain_did = RepoDid::new("did:plc:squid").unwrap(); 114 let literal_did = RepoDid::new("did:plc:whelk").unwrap(); 115 layout.create(&plain_did).unwrap(); 116 layout.create(&literal_did).unwrap(); 117 118 let resolver: Arc<dyn RepoResolver> = { 119 let owner = owner.clone(); 120 let plain_did = plain_did.clone(); 121 let literal_did = literal_did.clone(); 122 Arc::new(move |target: &RepoTarget| match target { 123 RepoTarget::OwnerRkey(o, n) if *o == owner && n.as_str() == "anemone" => { 124 RepoLookup::Hosted(plain_did.clone()) 125 } 126 RepoTarget::OwnerRkey(o, n) if *o == owner && n.as_str() == "barnacle.git" => { 127 RepoLookup::Hosted(literal_did.clone()) 128 } 129 _ => RepoLookup::Unhosted, 130 }) 131 }; 132 let addr = spawn( 133 knot_pack::router( 134 layout.clone(), 135 resolver, 136 std::sync::Arc::new(knot_runtime::SystemClock), 137 ), 138 "[::1]:0", 139 ) 140 .await; 141 142 let scratch = tempfile::tempdir().unwrap(); 143 seed_repo( 144 &scratch.path().join("work-plain"), 145 layout.repo_path(&plain_did).unwrap().to_str().unwrap(), 146 "README.md", 147 "plain\n", 148 ); 149 seed_repo( 150 &scratch.path().join("work-literal"), 151 layout.repo_path(&literal_did).unwrap().to_str().unwrap(), 152 "README.md", 153 "literal\n", 154 ); 155 156 let clone_ok = |name: &str, label: &str, expect: &str| { 157 let dest = scratch.path().join(label); 158 let remote = format!("http://{addr}/{}/{name}", owner.as_str()); 159 let (ok, out) = git( 160 scratch.path(), 161 &["clone", "-q", &remote, dest.to_str().unwrap()], 162 ); 163 assert!( 164 ok, 165 "clone of {name} must resolve through the registry:\n{out}" 166 ); 167 assert_eq!( 168 std::fs::read_to_string(dest.join("README.md")).unwrap(), 169 expect 170 ); 171 }; 172 clone_ok("anemone", "clone-plain", "plain\n"); 173 clone_ok("anemone.git", "clone-suffixed", "plain\n"); 174 clone_ok("barnacle.git", "clone-literal", "literal\n"); 175 176 let clone_404 = |remote: String, why: &str| { 177 let dest = scratch.path().join("clone-404"); 178 let (ok, _out) = git( 179 scratch.path(), 180 &["clone", "-q", &remote, dest.to_str().unwrap()], 181 ); 182 assert!(!ok, "{why}"); 183 let _ = std::fs::remove_dir_all(&dest); 184 }; 185 clone_404( 186 format!("http://{addr}/{}/conch", owner.as_str()), 187 "rkey with no registry entry must 404, not route to the wrong repo", 188 ); 189 clone_404( 190 format!("http://{addr}/{}", plain_did.as_str()), 191 "a direct-DID path the resolver doesn't host must 404, even though the repo exists on disk", 192 ); 193} 194 195#[tokio::test(flavor = "multi_thread")] 196async fn http_clone_while_the_index_is_warming_is_unavailable_not_404() { 197 use tower::ServiceExt; 198 199 let scan = tempfile::tempdir().unwrap(); 200 let layout = Layout::new(scan.path()); 201 let resolver: Arc<dyn RepoResolver> = Arc::new(|_target: &RepoTarget| RepoLookup::Unavailable); 202 203 let by_name = axum::http::Request::builder() 204 .uri("/did:plc:nel/anemone/info/refs?service=git-upload-pack") 205 .body(Body::empty()) 206 .unwrap(); 207 let response = knot_pack::router( 208 layout.clone(), 209 Arc::clone(&resolver), 210 std::sync::Arc::new(knot_runtime::SystemClock), 211 ) 212 .oneshot(by_name) 213 .await 214 .unwrap(); 215 assert_eq!( 216 response.status(), 217 axum::http::StatusCode::SERVICE_UNAVAILABLE, 218 "warming registry is a retryable 503 on owner/rkey route, never a 404" 219 ); 220 221 let by_did = axum::http::Request::builder() 222 .uri("/did:plc:squid/info/refs?service=git-upload-pack") 223 .body(Body::empty()) 224 .unwrap(); 225 let response = knot_pack::router( 226 layout, 227 resolver, 228 std::sync::Arc::new(knot_runtime::SystemClock), 229 ) 230 .oneshot(by_did) 231 .await 232 .unwrap(); 233 assert_eq!( 234 response.status(), 235 axum::http::StatusCode::SERVICE_UNAVAILABLE, 236 "warming registry is a retryable 503 on direct-DID route, never a 404" 237 ); 238} 239 240#[tokio::test(flavor = "multi_thread")] 241async fn shallow_clone_over_protocol_v0() { 242 let scan = tempfile::tempdir().unwrap(); 243 let layout = Layout::new(scan.path()); 244 let did = RepoDid::new("did:plc:squid").unwrap(); 245 let name = RepoRkey::new("scallop").unwrap(); 246 layout.create(&did).unwrap(); 247 let bare = layout.repo_path(&did).unwrap(); 248 let addr = spawn( 249 knot_pack::router( 250 layout.clone(), 251 serve_dids(), 252 std::sync::Arc::new(knot_runtime::SystemClock), 253 ), 254 "[::1]:0", 255 ) 256 .await; 257 258 let scratch = tempfile::tempdir().unwrap(); 259 let work = scratch.path().join("work"); 260 seed_three_commits(&work, bare.to_str().unwrap()); 261 commit(&work, "d.txt", "c4\n", "c4"); 262 must(&work, &["push", "-q", bare.to_str().unwrap(), "main"]); 263 let remote = url(addr, &did, &name); 264 265 let clone = scratch.path().join("clone"); 266 let (ok, out) = git( 267 scratch.path(), 268 &[ 269 "-c", 270 "protocol.version=0", 271 "clone", 272 "--depth=1", 273 "-q", 274 &remote, 275 clone.to_str().unwrap(), 276 ], 277 ); 278 assert!(ok, "v0 shallow clone failed:\n{out}"); 279 assert!( 280 clone.join(".git/shallow").exists(), 281 "depth-limited v0 clone must be marked shallow" 282 ); 283 assert_eq!( 284 must(&clone, &["rev-list", "--count", "HEAD"]).trim(), 285 "1", 286 "depth=1 over v0 must yield exactly one commit" 287 ); 288 289 let (ok, out) = git( 290 &clone, 291 &[ 292 "-c", 293 "protocol.version=0", 294 "fetch", 295 "--depth=2", 296 "-q", 297 "origin", 298 ], 299 ); 300 assert!(ok, "v0 deepening fetch failed:\n{out}"); 301 assert_eq!( 302 must(&clone, &["rev-list", "--count", "origin/main"]).trim(), 303 "2", 304 "deepen to depth=2 over v0 must reveal second commit" 305 ); 306} 307 308#[tokio::test(flavor = "multi_thread")] 309async fn shallow_fetch_of_an_annotated_tag() { 310 let scan = tempfile::tempdir().unwrap(); 311 let layout = Layout::new(scan.path()); 312 let did = RepoDid::new("did:plc:squid").unwrap(); 313 let name = RepoRkey::new("whelk").unwrap(); 314 layout.create(&did).unwrap(); 315 let bare = layout.repo_path(&did).unwrap(); 316 let addr = spawn( 317 knot_pack::router( 318 layout.clone(), 319 serve_dids(), 320 std::sync::Arc::new(knot_runtime::SystemClock), 321 ), 322 "[::1]:0", 323 ) 324 .await; 325 326 let scratch = tempfile::tempdir().unwrap(); 327 let work = scratch.path().join("work"); 328 seed_repo(&work, bare.to_str().unwrap(), "a.txt", "c1\n"); 329 commit(&work, "b.txt", "c2\n", "c2"); 330 must(&work, &["tag", "-a", "release", "-m", "release"]); 331 must( 332 &work, 333 &["push", "-q", bare.to_str().unwrap(), "main", "release"], 334 ); 335 let remote = url(addr, &did, &name); 336 337 ["2", "0"].iter().enumerate().for_each(|(index, version)| { 338 let dest = scratch.path().join(format!("tagfetch-{index}")); 339 std::fs::create_dir_all(&dest).unwrap(); 340 must(&dest, &["init", "-q"]); 341 let (ok, out) = git( 342 &dest, 343 &[ 344 "-c", 345 &format!("protocol.version={version}"), 346 "fetch", 347 "--depth=1", 348 "-q", 349 &remote, 350 "refs/tags/release:refs/tags/release", 351 ], 352 ); 353 assert!(ok, "shallow tag fetch over v{version} failed:\n{out}"); 354 assert_eq!( 355 must(&dest, &["cat-file", "-t", "release"]).trim(), 356 "tag", 357 "annotated tag object itself must be transferred over v{version}" 358 ); 359 assert_eq!( 360 must(&dest, &["rev-list", "--count", "release^{commit}"]).trim(), 361 "1", 362 "depth-1 tag fetch over v{version} must contain exactly the tagged commit" 363 ); 364 }); 365} 366 367#[tokio::test(flavor = "multi_thread")] 368async fn pack_slot_limit_serializes_concurrent_clones_without_breaking_them() { 369 let scan = tempfile::tempdir().unwrap(); 370 let layout = Layout::new(scan.path()); 371 let did = RepoDid::new("did:plc:squid").unwrap(); 372 let name = RepoRkey::new("cuttle").unwrap(); 373 layout.create(&did).unwrap(); 374 let bare = layout.repo_path(&did).unwrap(); 375 376 let scratch = tempfile::tempdir().unwrap(); 377 let work = scratch.path().join("work"); 378 seed_repo(&work, bare.to_str().unwrap(), "README.md", "kelp\n"); 379 let tip = must(&work, &["rev-parse", "HEAD"]).trim().to_string(); 380 381 let addr = spawn( 382 knot_pack::router_with_pack_slots( 383 layout, 384 serve_dids(), 385 knot_resource::PackSlots::new(1), 386 std::sync::Arc::new(knot_runtime::SystemClock), 387 ), 388 "[::1]:0", 389 ) 390 .await; 391 let remote = url(addr, &did, &name); 392 393 let children: Vec<(usize, std::path::PathBuf, std::process::Child)> = (0..6) 394 .map(|index| { 395 let dest = scratch.path().join(format!("clone-{index}")); 396 let child = knot_fixtures::command(scratch.path()) 397 .args(["clone", "-q", &remote, dest.to_str().unwrap()]) 398 .spawn() 399 .expect("git clone spawns"); 400 (index, dest, child) 401 }) 402 .collect(); 403 404 children.into_iter().for_each(|(index, dest, mut child)| { 405 assert!( 406 child.wait().unwrap().success(), 407 "single pack slot must still let concurrent clone {index} complete" 408 ); 409 assert_eq!( 410 must(&dest, &["rev-parse", "HEAD"]).trim(), 411 tip, 412 "clone served under a one-slot limit must still check out the right tip" 413 ); 414 }); 415} 416 417#[tokio::test(flavor = "multi_thread")] 418async fn http_upload_archive_serves_a_framed_tar_and_guards_refuse_cob_raw_oids_and_traversal() { 419 use tower::ServiceExt as _; 420 421 let scan = tempfile::tempdir().unwrap(); 422 let layout = Layout::new(scan.path()); 423 let did = RepoDid::new("did:plc:squid").unwrap(); 424 layout.create(&did).unwrap(); 425 let bare = layout.repo_path(&did).unwrap(); 426 427 let scratch = tempfile::tempdir().unwrap(); 428 let work = scratch.path().join("work"); 429 seed_repo(&work, bare.to_str().unwrap(), "README.md", "archive me\n"); 430 431 let mut framed = Vec::new(); 432 framed.extend(pkt(b"argument --format=tar\n")); 433 framed.extend(pkt(b"argument HEAD\n")); 434 framed.extend_from_slice(b"0000"); 435 let response = knot_pack::router( 436 layout.clone(), 437 serve_dids(), 438 std::sync::Arc::new(knot_runtime::SystemClock), 439 ) 440 .oneshot( 441 axum::http::Request::builder() 442 .method("POST") 443 .uri(format!("/{}/git-upload-archive", did.as_str())) 444 .header( 445 header::CONTENT_TYPE, 446 "application/x-git-upload-archive-request", 447 ) 448 .body(Body::from(framed)) 449 .unwrap(), 450 ) 451 .await 452 .unwrap(); 453 assert_eq!(response.status(), axum::http::StatusCode::OK); 454 assert_eq!( 455 response 456 .headers() 457 .get(header::CONTENT_TYPE) 458 .and_then(|value| value.to_str().ok()), 459 Some("application/x-git-upload-archive-result"), 460 ); 461 let body = http_body_util::BodyExt::collect(response.into_body()) 462 .await 463 .unwrap() 464 .to_bytes(); 465 assert!( 466 body.starts_with(b"0008ACK\n"), 467 "archive response opens with the ACK pkt-line" 468 ); 469 assert!( 470 body.windows("README.md".len()) 471 .any(|window| window == b"README.md"), 472 "framed archive contains README.md entry" 473 ); 474 475 let repo = layout.open(&did).unwrap(); 476 let head = repo.head().expect("seeded head").target; 477 let tree = repo.find_commit(head).unwrap().tree; 478 let archive = |args: &[&str]| { 479 let mut request = Vec::new(); 480 args.iter() 481 .for_each(|arg| request.extend(pkt(arg.as_bytes()))); 482 request.extend_from_slice(b"0000"); 483 knot_pack::upload_archive(&repo, &request).unwrap() 484 }; 485 486 let raw_arg = format!("argument {}\n", tree.to_hex()); 487 let raw_oid = archive(&["argument --format=tar\n", raw_arg.as_str()]); 488 assert!( 489 String::from_utf8_lossy(&raw_oid).contains("NACK"), 490 "raw tree oid must be declined like uploadArchive.allowUnreachable=false" 491 ); 492 493 let traversal = archive(&[ 494 "argument --format=tar\n", 495 "argument --prefix=../evil/\n", 496 "argument HEAD\n", 497 ]); 498 assert!( 499 String::from_utf8_lossy(&traversal).contains("NACK"), 500 "traversal prefix must be declined" 501 ); 502 503 repo.update_ref(&RefUpdate::Create { 504 name: RefName::new("refs/cobs/sh.tangled.repo.collaborator/secret").unwrap(), 505 new: head, 506 }) 507 .unwrap(); 508 repo.update_ref(&RefUpdate::Delete { 509 name: RefName::new("refs/heads/main").unwrap(), 510 old: head, 511 }) 512 .unwrap(); 513 let cob = archive(&[ 514 "argument --format=tar\n", 515 "argument refs/cobs/sh.tangled.repo.collaborator/secret\n", 516 ]); 517 assert!( 518 String::from_utf8_lossy(&cob).contains("NACK"), 519 "archiving cob-only tree must be refused" 520 ); 521 assert!( 522 !cob.windows("README.md".len()) 523 .any(|window| window == b"README.md"), 524 "refused archive mustn't leak the hidden tree's contents" 525 ); 526} 527 528#[tokio::test(flavor = "multi_thread")] 529async fn push_over_http_is_refused() { 530 let scan = tempfile::tempdir().unwrap(); 531 let layout = Layout::new(scan.path()); 532 let did = RepoDid::new("did:plc:squid").unwrap(); 533 let name = RepoRkey::new("conch").unwrap(); 534 layout.create(&did).unwrap(); 535 let addr = spawn( 536 knot_pack::router( 537 layout.clone(), 538 serve_dids(), 539 std::sync::Arc::new(knot_runtime::SystemClock), 540 ), 541 "[::1]:0", 542 ) 543 .await; 544 545 let scratch = tempfile::tempdir().unwrap(); 546 let work = scratch.path().join("work"); 547 std::fs::create_dir_all(&work).unwrap(); 548 must(&work, &["init", "-q", "-b", "main"]); 549 commit(&work, "a.txt", "one\n", "one"); 550 let (ok, out) = git(&work, &["push", &url(addr, &did, &name), "main"]); 551 assert!(!ok, "push over HTTP must be refused, got success:\n{out}"); 552} 553 554fn seed_three_commits(work: &Path, bare: &str) { 555 seed_repo(work, bare, "a.txt", "c1\n"); 556 commit(work, "b.txt", "c2\n", "c2"); 557 must(work, &["push", "-q", bare, "main"]); 558 commit(work, "c.txt", "c3\n", "c3"); 559 must(work, &["push", "-q", bare, "main"]); 560} 561 562fn commit_dated(work: &Path, file: &str, contents: &str, message: &str, iso_date: &str) { 563 std::fs::write(work.join(file), contents).unwrap(); 564 must(work, &["add", "-A"]); 565 let out = knot_fixtures::command_at(work, iso_date) 566 .args(["commit", "-q", "-m", message]) 567 .output() 568 .unwrap(); 569 assert!(out.status.success(), "dated commit failed"); 570} 571 572#[tokio::test(flavor = "multi_thread")] 573async fn shallow_clone_depth_exclude_since() { 574 let did = RepoDid::new("did:plc:squid").unwrap(); 575 let s = common::stand(&did).await; 576 let bare = &s.bare; 577 let scratch = s.scratch.path(); 578 579 let work = scratch.join("work"); 580 std::fs::create_dir_all(&work).unwrap(); 581 must(&work, &["init", "-q", "-b", "main"]); 582 commit_dated(&work, "a.txt", "c1\n", "c1", "2020-01-01T00:00:00 +0000"); 583 must(&work, &["tag", "base"]); 584 commit_dated(&work, "b.txt", "c2\n", "c2", "2021-01-01T00:00:00 +0000"); 585 commit_dated(&work, "c.txt", "c3\n", "c3", "2022-01-01T00:00:00 +0000"); 586 commit_dated(&work, "d.txt", "c4\n", "c4", "2024-01-01T00:00:00 +0000"); 587 must( 588 &work, 589 &["push", "-q", bare.to_str().unwrap(), "main", "base"], 590 ); 591 must(bare, &["symbolic-ref", "HEAD", "refs/heads/main"]); 592 let remote = format!("http://{}/{}", s.addr, did.as_str()); 593 594 let depth = scratch.join("clone-depth"); 595 must( 596 scratch, 597 &["clone", "--depth=1", "-q", &remote, depth.to_str().unwrap()], 598 ); 599 assert!( 600 depth.join(".git/shallow").exists(), 601 "depth-limited clone must be marked shallow" 602 ); 603 assert_eq!( 604 must(&depth, &["rev-list", "--count", "HEAD"]).trim(), 605 "1", 606 "depth=1 must yield exactly one commit" 607 ); 608 let (ok, out) = git(&depth, &["fetch", "--depth=2", "-q", "origin"]); 609 assert!(ok, "deepening fetch failed:\n{out}"); 610 assert_eq!( 611 must(&depth, &["rev-list", "--count", "origin/main"]).trim(), 612 "2", 613 "deepen to depth=2 must reveal second commit" 614 ); 615 let (ok, out) = git(&depth, &["fetch", "--deepen=1", "-q", "origin"]); 616 assert!(ok, "relative deepen fetch failed:\n{out}"); 617 assert_eq!( 618 must(&depth, &["rev-list", "--count", "origin/main"]).trim(), 619 "3", 620 "--deepen=1 from depth 2 must reveal third commit" 621 ); 622 let (ok, out) = git(&depth, &["fetch", "--unshallow", "-q", "origin"]); 623 assert!(ok, "unshallow fetch failed:\n{out}"); 624 assert!( 625 !depth.join(".git/shallow").exists(), 626 "unshallow fetch must drop the shallow marker" 627 ); 628 assert_eq!( 629 must(&depth, &["rev-list", "--count", "origin/main"]).trim(), 630 "4", 631 "unshallow must restore full history" 632 ); 633 634 let exclude = scratch.join("clone-exclude"); 635 let (ok, out) = git( 636 scratch, 637 &[ 638 "clone", 639 "--shallow-exclude=base", 640 "-q", 641 &remote, 642 exclude.to_str().unwrap(), 643 ], 644 ); 645 assert!(ok, "shallow-exclude clone failed:\n{out}"); 646 assert_eq!( 647 must(&exclude, &["rev-list", "--count", "HEAD"]).trim(), 648 "3", 649 "shallow-exclude=base must drop excluded commit and its ancestors" 650 ); 651 652 let since = scratch.join("clone-since"); 653 let (ok, out) = git( 654 scratch, 655 &[ 656 "clone", 657 "--shallow-since=2023-01-01", 658 "-q", 659 &remote, 660 since.to_str().unwrap(), 661 ], 662 ); 663 assert!(ok, "shallow-since clone failed:\n{out}"); 664 assert_eq!( 665 must(&since, &["rev-list", "--count", "HEAD"]).trim(), 666 "1", 667 "shallow-since must keep only commits at or after cutoff" 668 ); 669} 670 671#[test] 672fn upload_pack_object_set_matches_canonical_git() { 673 let scan = tempfile::tempdir().unwrap(); 674 let layout = Layout::new(scan.path()); 675 let did = RepoDid::new("did:plc:squid").unwrap(); 676 layout.create(&did).unwrap(); 677 let bare = layout.repo_path(&did).unwrap(); 678 679 let scratch = tempfile::tempdir().unwrap(); 680 let work = scratch.path().join("work"); 681 std::fs::create_dir_all(&work).unwrap(); 682 must(&work, &["init", "-q", "-b", "main"]); 683 commit(&work, "a.txt", "one\n", "c1"); 684 let c1 = must(&work, &["rev-parse", "HEAD"]).trim().to_string(); 685 commit(&work, "a.txt", "two\n", "c2"); 686 let c2 = must(&work, &["rev-parse", "HEAD"]).trim().to_string(); 687 must(&work, &["checkout", "-q", "-b", "dev", &c1]); 688 commit(&work, "b.txt", "three\n", "c3"); 689 must(&work, &["checkout", "-q", "main"]); 690 must(&work, &["tag", "-a", "v1", "-m", "release", &c2]); 691 must( 692 &work, 693 &["push", "-q", bare.to_str().unwrap(), "main", "dev", "v1"], 694 ); 695 696 let repo = layout.open(&did).unwrap(); 697 let tips: Vec<String> = repo 698 .advertised_refs() 699 .unwrap() 700 .iter() 701 .map(|record| record.target.to_hex().to_string()) 702 .collect(); 703 704 let clone = unsideband(&knot_pack::upload_pack(&repo, &v2_fetch_body(&tips, &[])).unwrap()); 705 assert_eq!( 706 pack_object_oids(&clone), 707 pack_object_oids(&canonical_pack(&work, &tips)), 708 "full clone must transfer exactly the object set canonical git packs" 709 ); 710 711 let incremental = unsideband( 712 &knot_pack::upload_pack( 713 &repo, 714 &v2_fetch_body(std::slice::from_ref(&c2), std::slice::from_ref(&c1)), 715 ) 716 .unwrap(), 717 ); 718 assert_eq!( 719 pack_object_oids(&incremental), 720 pack_object_oids(&canonical_pack(&work, &[c2, format!("^{c1}")])), 721 "incremental fetch must transfer only the objects missing from the client" 722 ); 723} 724 725#[tokio::test(flavor = "multi_thread")] 726async fn http_boundary_encoding_and_streaming() { 727 use flate2::Compression; 728 use flate2::write::GzEncoder; 729 use http_body_util::BodyExt; 730 use std::io::Write; 731 use tower::ServiceExt; 732 733 let scan = tempfile::tempdir().unwrap(); 734 let layout = Layout::new(scan.path()); 735 let did = RepoDid::new("did:plc:squid").unwrap(); 736 layout.create(&did).unwrap(); 737 let bare = layout.repo_path(&did).unwrap(); 738 let scratch = tempfile::tempdir().unwrap(); 739 let work = scratch.path().join("work"); 740 seed_repo(&work, bare.to_str().unwrap(), "README.md", "boundary\n"); 741 let tip = must(&work, &["rev-parse", "HEAD"]).trim().to_string(); 742 743 let router = knot_pack::router( 744 layout, 745 serve_dids(), 746 std::sync::Arc::new(knot_runtime::SystemClock), 747 ); 748 let upload_uri = format!("/{}/git-upload-pack", did.as_str()); 749 750 let oversized = router 751 .clone() 752 .oneshot( 753 axum::http::Request::builder() 754 .method("POST") 755 .uri(upload_uri.clone()) 756 .body(Body::from(vec![0u8; 17 * 1024 * 1024])) 757 .unwrap(), 758 ) 759 .await 760 .unwrap(); 761 assert_eq!( 762 oversized.status(), 763 axum::http::StatusCode::PAYLOAD_TOO_LARGE, 764 "oversized request body must be refused at the HTTP boundary before buffering" 765 ); 766 767 let unsupported = router 768 .clone() 769 .oneshot( 770 axum::http::Request::builder() 771 .method("POST") 772 .uri(upload_uri.clone()) 773 .header("content-encoding", "br") 774 .body(Body::from(vec![0u8; 16])) 775 .unwrap(), 776 ) 777 .await 778 .unwrap(); 779 assert_eq!( 780 unsupported.status(), 781 axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE, 782 "a body the knot cannot decode is rejected before parsing, never mis-read as identity" 783 ); 784 785 let mut plain = pkt(b"command=ls-refs\n"); 786 plain.extend_from_slice(b"0001"); 787 plain.extend(pkt(b"ref-prefix refs/heads/\n")); 788 plain.extend_from_slice(b"0000"); 789 let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); 790 encoder.write_all(&plain).unwrap(); 791 let gzipped = encoder.finish().unwrap(); 792 let decoded = router 793 .clone() 794 .oneshot( 795 axum::http::Request::builder() 796 .method("POST") 797 .uri(upload_uri.clone()) 798 .header("content-encoding", "gzip") 799 .body(Body::from(gzipped)) 800 .unwrap(), 801 ) 802 .await 803 .unwrap(); 804 assert_eq!(decoded.status(), axum::http::StatusCode::OK); 805 let body = decoded.into_body().collect().await.unwrap().to_bytes(); 806 assert!( 807 String::from_utf8_lossy(&body).contains("refs/heads/main"), 808 "gzip-encoded ls-refs request must be transparently decoded and answered" 809 ); 810 811 let mut fetch = pkt(b"command=fetch\n"); 812 fetch.extend_from_slice(b"0001"); 813 fetch.extend(pkt(format!("want {tip}\n").as_bytes())); 814 fetch.extend(pkt(b"done\n")); 815 fetch.extend_from_slice(b"0000"); 816 let streamed = router 817 .oneshot( 818 axum::http::Request::builder() 819 .method("POST") 820 .uri(upload_uri) 821 .body(Body::from(fetch)) 822 .unwrap(), 823 ) 824 .await 825 .unwrap(); 826 assert_eq!(streamed.status(), axum::http::StatusCode::OK); 827 assert!( 828 streamed.headers().get(header::CONTENT_LENGTH).is_none(), 829 "streamed pack response mustn't be buffered into a length-delimited body" 830 ); 831 let collected = streamed.into_body().collect().await.unwrap().to_bytes(); 832 assert!( 833 collected.windows(4).any(|window| window == b"PACK"), 834 "streamed response must contain a real PACK" 835 ); 836} 837 838#[tokio::test(flavor = "multi_thread")] 839async fn the_knot_meta_repo_is_never_served_over_http() { 840 use tower::ServiceExt; 841 842 let scan = tempfile::tempdir().unwrap(); 843 let knot = knot_types::KnotId::new("did:web:oyster.cafe").unwrap(); 844 let layout = Layout::new(scan.path()).reserving_meta(&knot).unwrap(); 845 layout.bootstrap_meta(&knot).unwrap(); 846 assert!( 847 layout.meta_path(&knot).unwrap().exists(), 848 "meta-repo must exist on disk so this tests the guard, not mere absence" 849 ); 850 851 let visible = RepoDid::new("did:plc:squid").unwrap(); 852 layout.create(&visible).unwrap(); 853 854 let router = knot_pack::router( 855 layout, 856 serve_dids(), 857 std::sync::Arc::new(knot_runtime::SystemClock), 858 ); 859 860 let status = |method: &'static str, uri: &'static str| { 861 let router = router.clone(); 862 async move { 863 router 864 .oneshot( 865 axum::http::Request::builder() 866 .method(method) 867 .uri(uri) 868 .body(Body::empty()) 869 .unwrap(), 870 ) 871 .await 872 .unwrap() 873 .status() 874 } 875 }; 876 877 let not_found = axum::http::StatusCode::NOT_FOUND; 878 assert_eq!( 879 status( 880 "GET", 881 "/did:web:oyster.cafe/info/refs?service=git-upload-pack" 882 ) 883 .await, 884 not_found, 885 "knot DID is refused on GET info/refs" 886 ); 887 assert_eq!( 888 status("POST", "/did:web:oyster.cafe/git-upload-pack").await, 889 not_found, 890 "knot DID is refused on POST upload-pack" 891 ); 892 assert_eq!( 893 status( 894 "GET", 895 "/did:web:oyster.cafe/anemone/info/refs?service=git-upload-pack" 896 ) 897 .await, 898 not_found, 899 "knot DID is refused on named info/refs route" 900 ); 901 assert_eq!( 902 status("POST", "/did:web:oyster.cafe/anemone/git-upload-pack").await, 903 not_found, 904 "knot DID is refused on named upload-pack route" 905 ); 906 assert_eq!( 907 status("POST", "/did:web:oyster.cafe/git-upload-archive").await, 908 not_found, 909 "knot DID is refused on upload-archive route" 910 ); 911 assert_eq!( 912 status("POST", "/did:web:oyster.cafe/anemone/git-upload-archive").await, 913 not_found, 914 "knot DID is refused on named upload-archive route" 915 ); 916 assert_eq!( 917 status( 918 "GET", 919 "/did:web:OYSTER.cafe/info/refs?service=git-upload-pack" 920 ) 921 .await, 922 not_found, 923 "case-variant of the knot DID canonicalizes to the same reserved repo" 924 ); 925 926 assert_eq!( 927 status("GET", "/did:plc:squid/info/refs?service=git-upload-pack").await, 928 axum::http::StatusCode::OK, 929 "ordinary repo path still serves its advertisement" 930 ); 931}