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