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 933 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, contains, 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 contains(&body, b"README.md"), 475 "framed archive contains README.md entry" 476 ); 477 478 let repo = layout.open(&did).unwrap(); 479 let head = repo.head().expect("seeded head").target; 480 let tree = repo.find_commit(head).unwrap().tree; 481 let archive = |args: &[&str]| { 482 let mut request = Vec::new(); 483 args.iter() 484 .for_each(|arg| request.extend(pkt(arg.as_bytes()))); 485 request.extend_from_slice(b"0000"); 486 knot_pack::upload_archive(&repo, &request).unwrap() 487 }; 488 489 let raw_arg = format!("argument {}\n", tree.to_hex()); 490 let raw_oid = archive(&["argument --format=tar\n", raw_arg.as_str()]); 491 assert!( 492 String::from_utf8_lossy(&raw_oid).contains("NACK"), 493 "raw tree oid must be declined like uploadArchive.allowUnreachable=false" 494 ); 495 496 let traversal = archive(&[ 497 "argument --format=tar\n", 498 "argument --prefix=../evil/\n", 499 "argument HEAD\n", 500 ]); 501 assert!( 502 String::from_utf8_lossy(&traversal).contains("NACK"), 503 "traversal prefix must be declined" 504 ); 505 506 repo.update_ref(&RefUpdate::Create { 507 name: RefName::new("refs/cobs/sh.tangled.repo.collaborator/secret").unwrap(), 508 new: head, 509 }) 510 .unwrap(); 511 repo.update_ref(&RefUpdate::Delete { 512 name: RefName::new("refs/heads/main").unwrap(), 513 old: head, 514 }) 515 .unwrap(); 516 let cob = archive(&[ 517 "argument --format=tar\n", 518 "argument refs/cobs/sh.tangled.repo.collaborator/secret\n", 519 ]); 520 assert!( 521 String::from_utf8_lossy(&cob).contains("NACK"), 522 "archiving cob-only tree must be refused" 523 ); 524 assert!( 525 !contains(&cob, b"README.md"), 526 "refused archive mustn't leak the hidden tree's contents" 527 ); 528} 529 530#[tokio::test(flavor = "multi_thread")] 531async fn push_over_http_is_refused() { 532 let scan = tempfile::tempdir().unwrap(); 533 let layout = Layout::new(scan.path()); 534 let did = RepoDid::new("did:plc:squid").unwrap(); 535 let name = RepoRkey::new("conch").unwrap(); 536 layout.create(&did).unwrap(); 537 let addr = spawn( 538 knot_pack::router( 539 layout.clone(), 540 serve_dids(), 541 std::sync::Arc::new(knot_runtime::SystemClock), 542 ), 543 "[::1]:0", 544 ) 545 .await; 546 547 let scratch = tempfile::tempdir().unwrap(); 548 let work = scratch.path().join("work"); 549 std::fs::create_dir_all(&work).unwrap(); 550 must(&work, &["init", "-q", "-b", "main"]); 551 commit(&work, "a.txt", "one\n", "one"); 552 let (ok, out) = git(&work, &["push", &url(addr, &did, &name), "main"]); 553 assert!(!ok, "push over HTTP must be refused, got success:\n{out}"); 554} 555 556fn seed_three_commits(work: &Path, bare: &str) { 557 seed_repo(work, bare, "a.txt", "c1\n"); 558 commit(work, "b.txt", "c2\n", "c2"); 559 must(work, &["push", "-q", bare, "main"]); 560 commit(work, "c.txt", "c3\n", "c3"); 561 must(work, &["push", "-q", bare, "main"]); 562} 563 564fn commit_dated(work: &Path, file: &str, contents: &str, message: &str, iso_date: &str) { 565 std::fs::write(work.join(file), contents).unwrap(); 566 must(work, &["add", "-A"]); 567 let out = knot_fixtures::command_at(work, iso_date) 568 .args(["commit", "-q", "-m", message]) 569 .output() 570 .unwrap(); 571 assert!(out.status.success(), "dated commit failed"); 572} 573 574#[tokio::test(flavor = "multi_thread")] 575async fn shallow_clone_depth_exclude_since() { 576 let did = RepoDid::new("did:plc:squid").unwrap(); 577 let s = common::stand(&did).await; 578 let bare = &s.bare; 579 let scratch = s.scratch.path(); 580 581 let work = scratch.join("work"); 582 std::fs::create_dir_all(&work).unwrap(); 583 must(&work, &["init", "-q", "-b", "main"]); 584 commit_dated(&work, "a.txt", "c1\n", "c1", "2020-01-01T00:00:00 +0000"); 585 must(&work, &["tag", "base"]); 586 commit_dated(&work, "b.txt", "c2\n", "c2", "2021-01-01T00:00:00 +0000"); 587 commit_dated(&work, "c.txt", "c3\n", "c3", "2022-01-01T00:00:00 +0000"); 588 commit_dated(&work, "d.txt", "c4\n", "c4", "2024-01-01T00:00:00 +0000"); 589 must( 590 &work, 591 &["push", "-q", bare.to_str().unwrap(), "main", "base"], 592 ); 593 must(bare, &["symbolic-ref", "HEAD", "refs/heads/main"]); 594 let remote = format!("http://{}/{}", s.addr, did.as_str()); 595 596 let depth = scratch.join("clone-depth"); 597 must( 598 scratch, 599 &["clone", "--depth=1", "-q", &remote, depth.to_str().unwrap()], 600 ); 601 assert!( 602 depth.join(".git/shallow").exists(), 603 "depth-limited clone must be marked shallow" 604 ); 605 assert_eq!( 606 must(&depth, &["rev-list", "--count", "HEAD"]).trim(), 607 "1", 608 "depth=1 must yield exactly one commit" 609 ); 610 let (ok, out) = git(&depth, &["fetch", "--depth=2", "-q", "origin"]); 611 assert!(ok, "deepening fetch failed:\n{out}"); 612 assert_eq!( 613 must(&depth, &["rev-list", "--count", "origin/main"]).trim(), 614 "2", 615 "deepen to depth=2 must reveal second commit" 616 ); 617 let (ok, out) = git(&depth, &["fetch", "--deepen=1", "-q", "origin"]); 618 assert!(ok, "relative deepen fetch failed:\n{out}"); 619 assert_eq!( 620 must(&depth, &["rev-list", "--count", "origin/main"]).trim(), 621 "3", 622 "--deepen=1 from depth 2 must reveal third commit" 623 ); 624 let (ok, out) = git(&depth, &["fetch", "--unshallow", "-q", "origin"]); 625 assert!(ok, "unshallow fetch failed:\n{out}"); 626 assert!( 627 !depth.join(".git/shallow").exists(), 628 "unshallow fetch must drop the shallow marker" 629 ); 630 assert_eq!( 631 must(&depth, &["rev-list", "--count", "origin/main"]).trim(), 632 "4", 633 "unshallow must restore full history" 634 ); 635 636 let exclude = scratch.join("clone-exclude"); 637 let (ok, out) = git( 638 scratch, 639 &[ 640 "clone", 641 "--shallow-exclude=base", 642 "-q", 643 &remote, 644 exclude.to_str().unwrap(), 645 ], 646 ); 647 assert!(ok, "shallow-exclude clone failed:\n{out}"); 648 assert_eq!( 649 must(&exclude, &["rev-list", "--count", "HEAD"]).trim(), 650 "3", 651 "shallow-exclude=base must drop excluded commit and its ancestors" 652 ); 653 654 let since = scratch.join("clone-since"); 655 let (ok, out) = git( 656 scratch, 657 &[ 658 "clone", 659 "--shallow-since=2023-01-01", 660 "-q", 661 &remote, 662 since.to_str().unwrap(), 663 ], 664 ); 665 assert!(ok, "shallow-since clone failed:\n{out}"); 666 assert_eq!( 667 must(&since, &["rev-list", "--count", "HEAD"]).trim(), 668 "1", 669 "shallow-since must keep only commits at or after cutoff" 670 ); 671} 672 673#[test] 674fn upload_pack_object_set_matches_canonical_git() { 675 let scan = tempfile::tempdir().unwrap(); 676 let layout = Layout::new(scan.path()); 677 let did = RepoDid::new("did:plc:squid").unwrap(); 678 layout.create(&did).unwrap(); 679 let bare = layout.repo_path(&did).unwrap(); 680 681 let scratch = tempfile::tempdir().unwrap(); 682 let work = scratch.path().join("work"); 683 std::fs::create_dir_all(&work).unwrap(); 684 must(&work, &["init", "-q", "-b", "main"]); 685 commit(&work, "a.txt", "one\n", "c1"); 686 let c1 = must(&work, &["rev-parse", "HEAD"]).trim().to_string(); 687 commit(&work, "a.txt", "two\n", "c2"); 688 let c2 = must(&work, &["rev-parse", "HEAD"]).trim().to_string(); 689 must(&work, &["checkout", "-q", "-b", "dev", &c1]); 690 commit(&work, "b.txt", "three\n", "c3"); 691 must(&work, &["checkout", "-q", "main"]); 692 must(&work, &["tag", "-a", "v1", "-m", "release", &c2]); 693 must( 694 &work, 695 &["push", "-q", bare.to_str().unwrap(), "main", "dev", "v1"], 696 ); 697 698 let repo = layout.open(&did).unwrap(); 699 let tips: Vec<String> = repo 700 .advertised_refs() 701 .unwrap() 702 .iter() 703 .map(|record| record.target.to_hex().to_string()) 704 .collect(); 705 706 let clone = unsideband(&knot_pack::upload_pack(&repo, &v2_fetch_body(&tips, &[])).unwrap()); 707 assert_eq!( 708 pack_object_oids(&clone), 709 pack_object_oids(&canonical_pack(&work, &tips)), 710 "full clone must transfer exactly the object set canonical git packs" 711 ); 712 713 let incremental = unsideband( 714 &knot_pack::upload_pack( 715 &repo, 716 &v2_fetch_body(std::slice::from_ref(&c2), std::slice::from_ref(&c1)), 717 ) 718 .unwrap(), 719 ); 720 assert_eq!( 721 pack_object_oids(&incremental), 722 pack_object_oids(&canonical_pack(&work, &[c2, format!("^{c1}")])), 723 "incremental fetch must transfer only the objects missing from the client" 724 ); 725} 726 727#[tokio::test(flavor = "multi_thread")] 728async fn http_boundary_encoding_and_streaming() { 729 use flate2::Compression; 730 use flate2::write::GzEncoder; 731 use http_body_util::BodyExt; 732 use std::io::Write; 733 use tower::ServiceExt; 734 735 let scan = tempfile::tempdir().unwrap(); 736 let layout = Layout::new(scan.path()); 737 let did = RepoDid::new("did:plc:squid").unwrap(); 738 layout.create(&did).unwrap(); 739 let bare = layout.repo_path(&did).unwrap(); 740 let scratch = tempfile::tempdir().unwrap(); 741 let work = scratch.path().join("work"); 742 seed_repo(&work, bare.to_str().unwrap(), "README.md", "boundary\n"); 743 let tip = must(&work, &["rev-parse", "HEAD"]).trim().to_string(); 744 745 let router = knot_pack::router( 746 layout, 747 serve_dids(), 748 std::sync::Arc::new(knot_runtime::SystemClock), 749 ); 750 let upload_uri = format!("/{}/git-upload-pack", did.as_str()); 751 752 let oversized = router 753 .clone() 754 .oneshot( 755 axum::http::Request::builder() 756 .method("POST") 757 .uri(upload_uri.clone()) 758 .body(Body::from(vec![0u8; 17 * 1024 * 1024])) 759 .unwrap(), 760 ) 761 .await 762 .unwrap(); 763 assert_eq!( 764 oversized.status(), 765 axum::http::StatusCode::PAYLOAD_TOO_LARGE, 766 "oversized request body must be refused at the HTTP boundary before buffering" 767 ); 768 769 let unsupported = router 770 .clone() 771 .oneshot( 772 axum::http::Request::builder() 773 .method("POST") 774 .uri(upload_uri.clone()) 775 .header("content-encoding", "br") 776 .body(Body::from(vec![0u8; 16])) 777 .unwrap(), 778 ) 779 .await 780 .unwrap(); 781 assert_eq!( 782 unsupported.status(), 783 axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE, 784 "a body the knot cannot decode is rejected before parsing, never mis-read as identity" 785 ); 786 787 let mut plain = pkt(b"command=ls-refs\n"); 788 plain.extend_from_slice(b"0001"); 789 plain.extend(pkt(b"ref-prefix refs/heads/\n")); 790 plain.extend_from_slice(b"0000"); 791 let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); 792 encoder.write_all(&plain).unwrap(); 793 let gzipped = encoder.finish().unwrap(); 794 let decoded = router 795 .clone() 796 .oneshot( 797 axum::http::Request::builder() 798 .method("POST") 799 .uri(upload_uri.clone()) 800 .header("content-encoding", "gzip") 801 .body(Body::from(gzipped)) 802 .unwrap(), 803 ) 804 .await 805 .unwrap(); 806 assert_eq!(decoded.status(), axum::http::StatusCode::OK); 807 let body = decoded.into_body().collect().await.unwrap().to_bytes(); 808 assert!( 809 String::from_utf8_lossy(&body).contains("refs/heads/main"), 810 "gzip-encoded ls-refs request must be transparently decoded and answered" 811 ); 812 813 let mut fetch = pkt(b"command=fetch\n"); 814 fetch.extend_from_slice(b"0001"); 815 fetch.extend(pkt(format!("want {tip}\n").as_bytes())); 816 fetch.extend(pkt(b"done\n")); 817 fetch.extend_from_slice(b"0000"); 818 let streamed = router 819 .oneshot( 820 axum::http::Request::builder() 821 .method("POST") 822 .uri(upload_uri) 823 .body(Body::from(fetch)) 824 .unwrap(), 825 ) 826 .await 827 .unwrap(); 828 assert_eq!(streamed.status(), axum::http::StatusCode::OK); 829 assert!( 830 streamed.headers().get(header::CONTENT_LENGTH).is_none(), 831 "streamed pack response mustn't be buffered into a length-delimited body" 832 ); 833 let collected = streamed.into_body().collect().await.unwrap().to_bytes(); 834 assert!( 835 contains(&collected, b"PACK"), 836 "streamed response must contain a real PACK" 837 ); 838} 839 840#[tokio::test(flavor = "multi_thread")] 841async fn the_knot_meta_repo_is_never_served_over_http() { 842 use tower::ServiceExt; 843 844 let scan = tempfile::tempdir().unwrap(); 845 let knot = knot_types::KnotId::new("did:web:oyster.cafe").unwrap(); 846 let layout = Layout::new(scan.path()).reserving_meta(&knot).unwrap(); 847 layout.bootstrap_meta(&knot).unwrap(); 848 assert!( 849 layout.meta_path(&knot).unwrap().exists(), 850 "meta-repo must exist on disk so this tests the guard, not mere absence" 851 ); 852 853 let visible = RepoDid::new("did:plc:squid").unwrap(); 854 layout.create(&visible).unwrap(); 855 856 let router = knot_pack::router( 857 layout, 858 serve_dids(), 859 std::sync::Arc::new(knot_runtime::SystemClock), 860 ); 861 862 let status = |method: &'static str, uri: &'static str| { 863 let router = router.clone(); 864 async move { 865 router 866 .oneshot( 867 axum::http::Request::builder() 868 .method(method) 869 .uri(uri) 870 .body(Body::empty()) 871 .unwrap(), 872 ) 873 .await 874 .unwrap() 875 .status() 876 } 877 }; 878 879 let not_found = axum::http::StatusCode::NOT_FOUND; 880 assert_eq!( 881 status( 882 "GET", 883 "/did:web:oyster.cafe/info/refs?service=git-upload-pack" 884 ) 885 .await, 886 not_found, 887 "knot DID is refused on GET info/refs" 888 ); 889 assert_eq!( 890 status("POST", "/did:web:oyster.cafe/git-upload-pack").await, 891 not_found, 892 "knot DID is refused on POST upload-pack" 893 ); 894 assert_eq!( 895 status( 896 "GET", 897 "/did:web:oyster.cafe/anemone/info/refs?service=git-upload-pack" 898 ) 899 .await, 900 not_found, 901 "knot DID is refused on named info/refs route" 902 ); 903 assert_eq!( 904 status("POST", "/did:web:oyster.cafe/anemone/git-upload-pack").await, 905 not_found, 906 "knot DID is refused on named upload-pack route" 907 ); 908 assert_eq!( 909 status("POST", "/did:web:oyster.cafe/git-upload-archive").await, 910 not_found, 911 "knot DID is refused on upload-archive route" 912 ); 913 assert_eq!( 914 status("POST", "/did:web:oyster.cafe/anemone/git-upload-archive").await, 915 not_found, 916 "knot DID is refused on named upload-archive route" 917 ); 918 assert_eq!( 919 status( 920 "GET", 921 "/did:web:OYSTER.cafe/info/refs?service=git-upload-pack" 922 ) 923 .await, 924 not_found, 925 "case-variant of the knot DID canonicalizes to the same reserved repo" 926 ); 927 928 assert_eq!( 929 status("GET", "/did:plc:squid/info/refs?service=git-upload-pack").await, 930 axum::http::StatusCode::OK, 931 "ordinary repo path still serves its advertisement" 932 ); 933}