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