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 / hardening.rs
51 kB 1484 lines
1use std::io::Write; 2use std::process::Stdio; 3 4use knot_git::{Layout, RefUpdate}; 5use knot_pack::{DeltaDepth, PackLimits}; 6use knot_types::{ObjectCount, ObjectFormat, Oid, RefName, RepoDid}; 7 8mod common; 9use common::{ 10 commit, contains, delta_bomb_pack, index_into_bare, must, pack_objects, pack_objects_tuned, 11 pkt, receive_request, seeded, unsideband, 12}; 13 14fn generous() -> PackLimits { 15 PackLimits { 16 max_objects: ObjectCount::new(1_000_000), 17 max_object_bytes: knot_pack::MaxObjectBytes::new(1 << 30), 18 max_total_bytes: knot_pack::MaxTotalBytes::new(1 << 31), 19 max_delta_depth: DeltaDepth::new(50), 20 } 21} 22 23fn v2_fetch(wants: &[&str], haves: &[&str], done: bool) -> Vec<u8> { 24 let mut req = pkt(b"command=fetch\n"); 25 req.extend_from_slice(b"0001"); 26 wants 27 .iter() 28 .for_each(|want| req.extend(pkt(format!("want {want}\n").as_bytes()))); 29 haves 30 .iter() 31 .for_each(|have| req.extend(pkt(format!("have {have}\n").as_bytes()))); 32 if done { 33 req.extend(pkt(b"done\n")); 34 } 35 req.extend_from_slice(b"0000"); 36 req 37} 38 39fn thin_resolves_against_base(base_pack: &[u8], thin: &[u8]) -> bool { 40 let bare = tempfile::tempdir().unwrap(); 41 knot_fixtures::must( 42 bare.path(), 43 &["init", "--bare", "-q", bare.path().to_str().unwrap()], 44 ); 45 let feed = |args: &[&str], pack: &[u8]| knot_fixtures::feed(bare.path(), args, pack).0; 46 feed(&["index-pack", "--stdin"], base_pack) 47 && feed(&["index-pack", "--stdin", "--fix-thin"], thin) 48} 49 50fn v2_fetch_thin(want: &str, have: &str) -> Vec<u8> { 51 let mut req = pkt(b"command=fetch\n"); 52 req.extend_from_slice(b"0001"); 53 req.extend(pkt(b"thin-pack\n")); 54 req.extend(pkt(format!("want {want}\n").as_bytes())); 55 req.extend(pkt(format!("have {have}\n").as_bytes())); 56 req.extend(pkt(b"done\n")); 57 req.extend_from_slice(b"0000"); 58 req 59} 60 61fn has_band(resp: &[u8], band: u8) -> bool { 62 let mut pos = 0usize; 63 while pos + 4 <= resp.len() { 64 let len = std::str::from_utf8(&resp[pos..pos + 4]) 65 .ok() 66 .and_then(|hex| usize::from_str_radix(hex, 16).ok()) 67 .unwrap_or(0); 68 pos += 4; 69 if len < 4 { 70 continue; 71 } 72 let end = (pos + len - 4).min(resp.len()); 73 if resp[pos..end].first() == Some(&band) { 74 return true; 75 } 76 pos = end; 77 } 78 false 79} 80 81fn created_refs(bare: &knot_git::Repo) -> Vec<String> { 82 bare.references() 83 .unwrap() 84 .into_iter() 85 .map(|record| record.name.as_str().to_string()) 86 .collect() 87} 88 89#[test] 90fn pack_with_missing_parent_is_now_rejected() { 91 let scan = tempfile::tempdir().unwrap(); 92 let layout = Layout::new(scan.path()); 93 let did = RepoDid::new("did:plc:squid").unwrap(); 94 let bare = layout.create(&did).unwrap(); 95 96 let work_dir = tempfile::tempdir().unwrap(); 97 let work = work_dir.path(); 98 must(work, &["init", "-q", "-b", "main"]); 99 commit(work, "old.txt", "old\n", "c1"); 100 let c1 = must(work, &["rev-parse", "HEAD"]); 101 must(work, &["rm", "-q", "old.txt"]); 102 commit(work, "new.txt", "new\n", "c2"); 103 let c2 = must(work, &["rev-parse", "HEAD"]); 104 105 let oids: Vec<String> = must(work, &["rev-list", "--objects", &c2, "--not", &c1]) 106 .lines() 107 .map(|line| line.split_whitespace().next().unwrap().to_string()) 108 .collect(); 109 let pack = pack_objects(work, &oids); 110 111 let mut first = format!("{} {} refs/heads/dangle", Oid::null().to_hex(), c2).into_bytes(); 112 first.push(0); 113 first.extend_from_slice(b"report-status\n"); 114 let mut req = pkt(&first); 115 req.extend_from_slice(b"0000"); 116 req.extend_from_slice(&pack); 117 118 let report = knot_pack::receive_pack(&bare, &req).unwrap(); 119 let text = String::from_utf8_lossy(&report).replace('\0', ""); 120 121 let created = layout 122 .open(&did) 123 .unwrap() 124 .references() 125 .unwrap() 126 .into_iter() 127 .any(|r| r.name.as_str() == "refs/heads/dangle"); 128 129 assert!( 130 text.contains("ng refs/heads/dangle missing necessary objects"), 131 "pack whose tip has a missing parent must be rejected:\n{text}" 132 ); 133 assert!(!created, "dangling ref mustn't have been created"); 134} 135 136#[test] 137fn ls_refs_prefix_and_cob_hiding() { 138 let scan = tempfile::tempdir().unwrap(); 139 let layout = Layout::new(scan.path()); 140 let did = RepoDid::new("did:plc:squid").unwrap(); 141 let (bare, _work, c1, pack) = seeded(&layout, &did); 142 knot_pack::receive_pack( 143 &bare, 144 &receive_request("refs/heads/main", &Oid::null().to_hex(), &c1, &pack), 145 ) 146 .unwrap(); 147 bare.update_ref(&RefUpdate::Create { 148 name: RefName::new("refs/cobs/sh.tangled.repo.collaborator/secret").unwrap(), 149 new: Oid::from_hex(&c1).unwrap(), 150 }) 151 .unwrap(); 152 153 let ls = |prefix: Option<&str>| { 154 let mut req = pkt(b"command=ls-refs\n"); 155 if let Some(prefix) = prefix { 156 req.extend_from_slice(b"0001"); 157 req.extend(pkt(format!("ref-prefix {prefix}\n").as_bytes())); 158 } 159 req.extend_from_slice(b"0000"); 160 String::from_utf8_lossy(&knot_pack::upload_pack(&bare, &req).unwrap()).into_owned() 161 }; 162 163 let tags = ls(Some("refs/tags/")); 164 assert!( 165 !tags.contains("refs/heads/main"), 166 "tags-only ref-prefix must exclude heads:\n{tags}" 167 ); 168 169 let heads = ls(Some("refs/heads/")); 170 assert!( 171 heads.contains("refs/heads/main"), 172 "heads ref-prefix must keep heads:\n{heads}" 173 ); 174 175 let cobs = ls(Some("refs/cobs/")); 176 assert!( 177 !cobs.contains("refs/cobs"), 178 "explicit ref-prefix refs/cobs/ must still return nothing:\n{cobs}" 179 ); 180 181 let all = ls(None); 182 assert!( 183 all.contains("refs/heads/main"), 184 "unfiltered ls-refs must still advertise heads:\n{all}" 185 ); 186 assert!( 187 !all.contains("refs/cobs"), 188 "unfiltered ls-refs must never advertise cob refs:\n{all}" 189 ); 190} 191 192#[test] 193fn push_namespace_gating_accepts_only_unreserved_refs() { 194 let scan = tempfile::tempdir().unwrap(); 195 let layout = Layout::new(scan.path()); 196 let did = RepoDid::new("did:plc:squid").unwrap(); 197 let (bare, _work, c1, pack) = seeded(&layout, &did); 198 199 let cases: [(&str, &str, bool); 3] = [ 200 ( 201 "refs/cobs/sh.tangled.repo.collaborator/evil", 202 "ng refs/cobs/sh.tangled.repo.collaborator/evil", 203 false, 204 ), 205 ( 206 "refs/hidden/feature/main", 207 "ng refs/hidden/feature/main", 208 false, 209 ), 210 ("refs/notes/commits", "ok refs/notes/commits", true), 211 ]; 212 cases.iter().for_each(|(refname, verdict, lands)| { 213 let req = receive_request(refname, &Oid::null().to_hex(), &c1, &pack); 214 let report = String::from_utf8_lossy(&knot_pack::receive_pack(&bare, &req).unwrap()) 215 .replace('\0', ""); 216 assert!(report.contains(*verdict), "{verdict}:\n{report}"); 217 assert_eq!( 218 created_refs(&bare) 219 .iter() 220 .any(|name| name.as_str() == *refname), 221 *lands, 222 "ref landing mismatch for {refname}", 223 ); 224 }); 225} 226 227#[test] 228fn pack_missing_a_blob_is_rejected() { 229 let scan = tempfile::tempdir().unwrap(); 230 let layout = Layout::new(scan.path()); 231 let did = RepoDid::new("did:plc:squid").unwrap(); 232 let bare = layout.create(&did).unwrap(); 233 234 let work_dir = tempfile::tempdir().unwrap(); 235 let work = work_dir.path(); 236 must(work, &["init", "-q", "-b", "main"]); 237 commit(work, "a.txt", "secret\n", "c1"); 238 let c1 = must(work, &["rev-parse", "HEAD"]); 239 let tree = must(work, &["rev-parse", "HEAD^{tree}"]); 240 let pack = pack_objects(work, &[c1.clone(), tree]); 241 242 let req = receive_request("refs/heads/main", &Oid::null().to_hex(), &c1, &pack); 243 let report = 244 String::from_utf8_lossy(&knot_pack::receive_pack(&bare, &req).unwrap()).replace('\0', ""); 245 assert!( 246 report.contains("ng refs/heads/main missing necessary objects"), 247 "pack whose tree references a missing blob must be rejected:\n{report}" 248 ); 249 assert!( 250 created_refs(&bare).is_empty(), 251 "ref to an object-incomplete commit mustn't be created" 252 ); 253} 254 255#[test] 256fn pack_with_a_submodule_gitlink_is_accepted() { 257 let scan = tempfile::tempdir().unwrap(); 258 let layout = Layout::new(scan.path()); 259 let did = RepoDid::new("did:plc:squid").unwrap(); 260 let bare = layout.create(&did).unwrap(); 261 262 let work_dir = tempfile::tempdir().unwrap(); 263 let work = work_dir.path(); 264 must(work, &["init", "-q", "-b", "main"]); 265 std::fs::write(work.join("a.txt"), "alpha\n").unwrap(); 266 must(work, &["add", "a.txt"]); 267 let absent = "0123456789abcdef0123456789abcdef01234567"; 268 let cacheinfo = format!("160000,{absent},vendor"); 269 must( 270 work, 271 &["update-index", "--add", "--cacheinfo", cacheinfo.as_str()], 272 ); 273 let tree = must(work, &["write-tree"]); 274 let head = must(work, &["commit-tree", &tree, "-m", "adds a submodule"]); 275 let oids: Vec<String> = must(work, &["rev-list", "--objects", &head]) 276 .lines() 277 .map(|line| line.split_whitespace().next().unwrap().to_string()) 278 .collect(); 279 let pack = pack_objects(work, &oids); 280 281 let req = receive_request("refs/heads/main", &Oid::null().to_hex(), &head, &pack); 282 let report = 283 String::from_utf8_lossy(&knot_pack::receive_pack(&bare, &req).unwrap()).replace('\0', ""); 284 assert!( 285 report.contains("ok refs/heads/main"), 286 "a gitlink names a submodule commit the host needn't hold, so the push must land:\n{report}" 287 ); 288 assert!( 289 created_refs(&bare) 290 .iter() 291 .any(|name| name.as_str() == "refs/heads/main"), 292 "the submodule-bearing ref must be created" 293 ); 294} 295 296#[test] 297fn empty_root_commit_over_the_virtual_tree_is_accepted() { 298 let scan = tempfile::tempdir().unwrap(); 299 let layout = Layout::new(scan.path()); 300 let did = RepoDid::new("did:plc:squid").unwrap(); 301 let bare = layout.create(&did).unwrap(); 302 303 let work_dir = tempfile::tempdir().unwrap(); 304 let work = work_dir.path(); 305 must(work, &["init", "-q", "-b", "main"]); 306 must(work, &["commit", "-q", "--allow-empty", "-m", "empty root"]); 307 let head = must(work, &["rev-parse", "HEAD"]); 308 let pack = pack_objects(work, std::slice::from_ref(&head)); 309 310 let req = receive_request("refs/heads/main", &Oid::null().to_hex(), &head, &pack); 311 let report = 312 String::from_utf8_lossy(&knot_pack::receive_pack(&bare, &req).unwrap()).replace('\0', ""); 313 assert!( 314 report.contains("ok refs/heads/main"), 315 "an empty root commit points at git's virtual empty tree, so the push must land:\n{report}" 316 ); 317 assert!( 318 created_refs(&bare) 319 .iter() 320 .any(|name| name.as_str() == "refs/heads/main"), 321 "the empty root ref must be created" 322 ); 323} 324 325fn sole_idx(pack_dir: std::path::PathBuf) -> Vec<u8> { 326 let idx = std::fs::read_dir(&pack_dir) 327 .unwrap() 328 .filter_map(Result::ok) 329 .map(|entry| entry.path()) 330 .find(|path| path.extension().is_some_and(|ext| ext == "idx")) 331 .expect("exactly one idx file"); 332 std::fs::read(idx).unwrap() 333} 334 335fn folded_index_matches_canonical_git(format: ObjectFormat) { 336 let fmt = format!("--object-format={}", format.capability()); 337 let work_dir = tempfile::tempdir().unwrap(); 338 let work = work_dir.path(); 339 must(work, &["init", &fmt, "-q", "-b", "main"]); 340 std::fs::create_dir_all(work.join("dir/nested")).unwrap(); 341 let grow = |lines: usize| { 342 (0..lines) 343 .map(|n| format!("line {n}\n")) 344 .collect::<String>() 345 }; 346 (0..40).for_each(|revision| { 347 std::fs::write(work.join("dir/nested/a.txt"), grow(revision * 50 + 10)).unwrap(); 348 std::fs::write(work.join("b.txt"), grow(revision * 30 + 5)).unwrap(); 349 must(work, &["add", "-A"]); 350 must(work, &["commit", "-q", "-m", &format!("rev {revision}")]); 351 }); 352 must(work, &["tag", "-a", "v1", "-m", "release one"]); 353 let head = must(work, &["rev-parse", "HEAD"]); 354 let oids: Vec<String> = must(work, &["rev-list", "--objects", "--all"]) 355 .lines() 356 .map(|line| line.split_whitespace().next().unwrap().to_string()) 357 .collect(); 358 let pack = pack_objects_tuned(work, &oids, true); 359 360 let scan = tempfile::tempdir().unwrap(); 361 let layout = Layout::new(scan.path()).with_object_format(format); 362 let did = RepoDid::new("did:plc:squid").unwrap(); 363 let bare = layout.create(&did).unwrap(); 364 let report = String::from_utf8_lossy( 365 &knot_pack::receive_pack( 366 &bare, 367 &receive_request("refs/heads/main", &format.null_oid().to_hex(), &head, &pack), 368 ) 369 .unwrap(), 370 ) 371 .replace('\0', ""); 372 assert!( 373 report.contains("ok refs/heads/main"), 374 "fold push must land:\n{report}" 375 ); 376 let mine = sole_idx(bare.objects_dir().join("pack")); 377 378 let scratch = tempfile::tempdir().unwrap(); 379 must(scratch.path(), &["init", "--bare", &fmt, "-q"]); 380 let mut child = knot_fixtures::command(scratch.path()) 381 .args(["index-pack", "--stdin"]) 382 .stdin(Stdio::piped()) 383 .stdout(Stdio::null()) 384 .stderr(Stdio::null()) 385 .spawn() 386 .unwrap(); 387 child.stdin.take().unwrap().write_all(&pack).unwrap(); 388 assert!( 389 child.wait().unwrap().success(), 390 "canonical index-pack failed" 391 ); 392 let canonical = sole_idx(scratch.path().join("objects/pack")); 393 394 assert_eq!( 395 mine, 396 canonical, 397 "folded ingest index must be byte-identical to canonical git index-pack for {}", 398 format.capability() 399 ); 400} 401 402fn bushy_delta_repo(work: &std::path::Path, fmt: &str) -> String { 403 must(work, &["init", fmt, "-q", "-b", "main"]); 404 let body: String = (0..400).map(|n| format!("shared line {n}\n")).collect(); 405 (0..10).for_each(|revision| { 406 (0..150).for_each(|file| { 407 let contents = format!( 408 "{body}unique {file} rev {revision}\ntail {}\n", 409 file * 7 + revision 410 ); 411 std::fs::write(work.join(format!("f{file:03}.txt")), contents).unwrap(); 412 }); 413 must(work, &["add", "-A"]); 414 must(work, &["commit", "-q", "-m", &format!("rev {revision}")]); 415 }); 416 must(work, &["rev-parse", "HEAD"]) 417} 418 419#[test] 420fn folded_bushy_push_matches_git_through_the_parallel_delta_path() { 421 let format = ObjectFormat::SHA1; 422 let fmt = format!("--object-format={}", format.capability()); 423 let work_dir = tempfile::tempdir().unwrap(); 424 let work = work_dir.path(); 425 let head = bushy_delta_repo(work, &fmt); 426 let oids: Vec<String> = must(work, &["rev-list", "--objects", "--all"]) 427 .lines() 428 .map(|line| line.split_whitespace().next().unwrap().to_string()) 429 .collect(); 430 let pack = pack_objects_tuned(work, &oids, true); 431 432 let scan = tempfile::tempdir().unwrap(); 433 let layout = Layout::new(scan.path()).with_object_format(format); 434 let did = RepoDid::new("did:plc:squid").unwrap(); 435 let bare = layout.create(&did).unwrap(); 436 let report = String::from_utf8_lossy( 437 &knot_pack::receive_pack( 438 &bare, 439 &receive_request("refs/heads/main", &format.null_oid().to_hex(), &head, &pack), 440 ) 441 .unwrap(), 442 ) 443 .replace('\0', ""); 444 assert!( 445 report.contains("ok refs/heads/main"), 446 "bushy push must land:\n{report}" 447 ); 448 let mine = sole_idx(bare.objects_dir().join("pack")); 449 450 let scratch = tempfile::tempdir().unwrap(); 451 must(scratch.path(), &["init", "--bare", &fmt, "-q"]); 452 let mut child = knot_fixtures::command(scratch.path()) 453 .args(["index-pack", "--stdin"]) 454 .stdin(Stdio::piped()) 455 .stdout(Stdio::null()) 456 .stderr(Stdio::null()) 457 .spawn() 458 .unwrap(); 459 child.stdin.take().unwrap().write_all(&pack).unwrap(); 460 assert!( 461 child.wait().unwrap().success(), 462 "canonical index-pack failed" 463 ); 464 let canonical = sole_idx(scratch.path().join("objects/pack")); 465 assert_eq!( 466 mine, canonical, 467 "a bushy delta pack drives the work-stealing traversal; its folded index must match canonical git" 468 ); 469} 470 471#[test] 472fn a_forced_base_spill_folds_the_bushy_pack_to_the_canonical_index() { 473 let format = ObjectFormat::SHA1; 474 let fmt = format!("--object-format={}", format.capability()); 475 let work_dir = tempfile::tempdir().unwrap(); 476 let work = work_dir.path(); 477 let _head = bushy_delta_repo(work, &fmt); 478 let oids: Vec<String> = must(work, &["rev-list", "--objects", "--all"]) 479 .lines() 480 .map(|line| line.split_whitespace().next().unwrap().to_string()) 481 .collect(); 482 let pack = pack_objects_tuned(work, &oids, true); 483 484 let staged = tempfile::NamedTempFile::new().unwrap(); 485 std::fs::write(staged.path(), &pack).unwrap(); 486 let objects = tempfile::tempdir().unwrap(); 487 let folded = knot_pack::bench_ingest_with_base_budget( 488 objects.path(), 489 staged.path(), 490 format.kind(), 491 Some(4096), 492 ) 493 .unwrap(); 494 assert!( 495 folded, 496 "the bushy pack is self-contained, so a forced spill must still fold it" 497 ); 498 let mine = sole_idx(objects.path().join("pack")); 499 500 let scratch = tempfile::tempdir().unwrap(); 501 must(scratch.path(), &["init", "--bare", &fmt, "-q"]); 502 let mut child = knot_fixtures::command(scratch.path()) 503 .args(["index-pack", "--stdin"]) 504 .stdin(Stdio::piped()) 505 .stdout(Stdio::null()) 506 .stderr(Stdio::null()) 507 .spawn() 508 .unwrap(); 509 child.stdin.take().unwrap().write_all(&pack).unwrap(); 510 assert!( 511 child.wait().unwrap().success(), 512 "canonical index-pack failed" 513 ); 514 let canonical = sole_idx(scratch.path().join("objects/pack")); 515 assert_eq!( 516 mine, canonical, 517 "paging the delta-base working set to disk mustn't change the folded index" 518 ); 519} 520 521#[test] 522fn the_streaming_connectivity_verify_agrees_with_the_in_ram_map() { 523 let kind = ObjectFormat::SHA1.kind(); 524 let stage = |pack: &[u8]| -> Option<bool> { 525 let staged = tempfile::NamedTempFile::new().unwrap(); 526 std::fs::write(staged.path(), pack).unwrap(); 527 let objects = tempfile::tempdir().unwrap(); 528 knot_pack::bench_ingest_external(objects.path(), staged.path(), kind).unwrap() 529 }; 530 531 let work_dir = tempfile::tempdir().unwrap(); 532 let work = work_dir.path(); 533 must(work, &["init", "-q", "-b", "main"]); 534 commit(work, "a.txt", "hello\n", "c1"); 535 let all: Vec<String> = must(work, &["rev-list", "--objects", "--all"]) 536 .lines() 537 .map(|line| line.split_whitespace().next().unwrap().to_string()) 538 .collect(); 539 assert_eq!( 540 stage(&pack_objects(work, &all)), 541 Some(true), 542 "the streaming probe must accept a self-contained pack" 543 ); 544 545 let c1 = must(work, &["rev-parse", "HEAD"]); 546 let tree = must(work, &["rev-parse", "HEAD^{tree}"]); 547 assert_eq!( 548 stage(&pack_objects(work, &[c1, tree])), 549 Some(false), 550 "the streaming probe must reject a pack whose tree references a missing blob" 551 ); 552 553 let sub_dir = tempfile::tempdir().unwrap(); 554 let sub = sub_dir.path(); 555 must(sub, &["init", "-q", "-b", "main"]); 556 std::fs::write(sub.join("x.txt"), "sub\n").unwrap(); 557 must(sub, &["add", "x.txt"]); 558 let cacheinfo = "160000,0123456789abcdef0123456789abcdef01234567,vendor"; 559 must(sub, &["update-index", "--add", "--cacheinfo", cacheinfo]); 560 let subtree = must(sub, &["write-tree"]); 561 let subhead = must(sub, &["commit-tree", &subtree, "-m", "with gitlink"]); 562 let sub_oids: Vec<String> = must(sub, &["rev-list", "--objects", &subhead]) 563 .lines() 564 .map(|line| line.split_whitespace().next().unwrap().to_string()) 565 .collect(); 566 assert_eq!( 567 stage(&pack_objects(sub, &sub_oids)), 568 Some(true), 569 "a gitlink names a submodule commit the host needn't hold, so absence is fine" 570 ); 571} 572 573#[test] 574fn folded_first_push_index_is_byte_identical_to_canonical_git() { 575 folded_index_matches_canonical_git(ObjectFormat::SHA1); 576} 577 578#[test] 579fn folded_first_push_index_is_byte_identical_under_sha256() { 580 folded_index_matches_canonical_git(ObjectFormat::SHA256); 581} 582 583#[test] 584fn garbage_pack_reports_unpack_error() { 585 let scan = tempfile::tempdir().unwrap(); 586 let layout = Layout::new(scan.path()); 587 let did = RepoDid::new("did:plc:squid").unwrap(); 588 let bare = layout.create(&did).unwrap(); 589 590 let fake = "1111111111111111111111111111111111111111"; 591 let req = receive_request( 592 "refs/heads/main", 593 &Oid::null().to_hex(), 594 fake, 595 b"not a packfile", 596 ); 597 let report = 598 String::from_utf8_lossy(&knot_pack::receive_pack(&bare, &req).unwrap()).replace('\0', ""); 599 assert!( 600 !report.contains("unpack ok"), 601 "non-PACK body mustn't report unpack ok:\n{report}" 602 ); 603 assert!( 604 report.contains("unpack pack:") && report.contains("PACK signature"), 605 "malformed pack must surface an unpack error:\n{report}" 606 ); 607 assert!( 608 created_refs(&bare).is_empty(), 609 "no ref may be created when pack is malformed" 610 ); 611} 612 613#[test] 614fn delete_only_push_has_no_pack() { 615 let scan = tempfile::tempdir().unwrap(); 616 let layout = Layout::new(scan.path()); 617 let did = RepoDid::new("did:plc:squid").unwrap(); 618 let (bare, _work, c1, pack) = seeded(&layout, &did); 619 620 let create = receive_request("refs/heads/main", &Oid::null().to_hex(), &c1, &pack); 621 let created = String::from_utf8_lossy(&knot_pack::receive_pack(&bare, &create).unwrap()) 622 .replace('\0', ""); 623 assert!( 624 created.contains("unpack ok") && created.contains("ok refs/heads/main"), 625 "setup push failed:\n{created}" 626 ); 627 628 let delete = receive_request("refs/heads/main", &c1, &Oid::null().to_hex(), b""); 629 let report = String::from_utf8_lossy(&knot_pack::receive_pack(&bare, &delete).unwrap()) 630 .replace('\0', ""); 631 assert!( 632 report.contains("unpack ok"), 633 "delete-only push has no pack and must still report unpack ok:\n{report}" 634 ); 635 assert!( 636 report.contains("ok refs/heads/main"), 637 "deleting head must succeed:\n{report}" 638 ); 639 assert!( 640 created_refs(&bare).is_empty(), 641 "ref must be gone after a delete" 642 ); 643} 644 645#[test] 646fn v2_fetch_negotiation_acks_readies_waits_and_ignores_unknowns() { 647 let scan = tempfile::tempdir().unwrap(); 648 let layout = Layout::new(scan.path()); 649 let did = RepoDid::new("did:plc:squid").unwrap(); 650 let (bare, _path, old, tip, _blob) = pushed_history(&layout, &did); 651 652 let fake_have = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; 653 let nak = String::from_utf8_lossy( 654 &knot_pack::upload_pack(&bare, &v2_fetch(&[&tip], &[fake_have], false)).unwrap(), 655 ) 656 .into_owned(); 657 assert!( 658 nak.contains("acknowledgments"), 659 "must open acknowledgments:\n{nak}" 660 ); 661 assert!(nak.contains("NAK"), "no common commit -> NAK:\n{nak}"); 662 assert!( 663 !nak.contains("packfile"), 664 "with no common commit server mustn't send a pack this round:\n{nak}" 665 ); 666 667 let bytes = knot_pack::upload_pack(&bare, &v2_fetch(&[&tip], &[&old], false)).unwrap(); 668 let ready = String::from_utf8_lossy(&bytes).into_owned(); 669 assert!( 670 ready.contains(&format!("ACK {old}")), 671 "must ACK common commit:\n{ready}" 672 ); 673 assert!( 674 ready.contains("ready"), 675 "must declare ready once a common commit is found" 676 ); 677 assert!( 678 ready.contains("packfile"), 679 "must open packfile section after ready" 680 ); 681 assert!( 682 contains(&bytes, b"PACK"), 683 "side-band payload must contain a real PACK" 684 ); 685 686 let waiting = String::from_utf8_lossy( 687 &knot_pack::upload_pack( 688 &bare, 689 &v2_fetch_with(&[&tip], &[&old], false, &["wait-for-done"]), 690 ) 691 .unwrap(), 692 ) 693 .into_owned(); 694 assert!( 695 waiting.contains(&format!("ACK {old}")), 696 "wait-for-done still acknowledges common commit:\n{waiting}" 697 ); 698 assert!( 699 !waiting.contains("ready"), 700 "wait-for-done mustn't declare ready; it waits for the client's done:\n{waiting}" 701 ); 702 assert!( 703 !waiting.contains("packfile"), 704 "wait-for-done mustn't open pack before done arrives:\n{waiting}" 705 ); 706 let finished = knot_pack::upload_pack( 707 &bare, 708 &v2_fetch_with(&[&tip], &[&old], true, &["wait-for-done"]), 709 ) 710 .unwrap(); 711 let finished_text = String::from_utf8_lossy(&finished).into_owned(); 712 assert!( 713 finished_text.contains("packfile"), 714 "once done arrives server opens the pack:\n{finished_text}" 715 ); 716 assert!( 717 contains(&finished, b"PACK"), 718 "follow-up round must contain a real PACK" 719 ); 720 721 let mut req = pkt(b"command=fetch\n"); 722 req.extend_from_slice(b"0001"); 723 [ 724 "thin-pack\n", 725 "ofs-delta\n", 726 "include-tag\n", 727 "no-progress\n", 728 "some-future-capability-knot-does-not-know\n", 729 ] 730 .iter() 731 .for_each(|arg| req.extend(pkt(arg.as_bytes()))); 732 req.extend(pkt(format!("want {tip}\n").as_bytes())); 733 req.extend(pkt(b"done\n")); 734 req.extend_from_slice(b"0000"); 735 let resp = knot_pack::upload_pack(&bare, &req).unwrap(); 736 let text = String::from_utf8_lossy(&resp); 737 assert!( 738 text.contains("packfile"), 739 "unknown fetch arguments must be ignored and pack still produced:\n{text}" 740 ); 741 assert!( 742 resp.windows(4).any(|window| window == b"PACK"), 743 "response must still contain a real PACK despite unknown arguments" 744 ); 745 746 let progress = knot_pack::upload_pack(&bare, &v2_fetch(&[&tip], &[], true)).unwrap(); 747 assert!( 748 has_band(&progress, 2), 749 "fetch must emit sideband band-2 progress" 750 ); 751 let suppressed = 752 knot_pack::upload_pack(&bare, &v2_fetch_with(&[&tip], &[], true, &["no-progress"])) 753 .unwrap(); 754 assert!( 755 !has_band(&suppressed, 2), 756 "no-progress must suppress band-2 output" 757 ); 758 assert!( 759 suppressed.windows(4).any(|window| window == b"PACK"), 760 "pack itself must still be sent when progress is suppressed" 761 ); 762} 763 764fn v2_fetch_with(wants: &[&str], haves: &[&str], done: bool, extra: &[&str]) -> Vec<u8> { 765 let mut req = pkt(b"command=fetch\n"); 766 req.extend_from_slice(b"0001"); 767 extra 768 .iter() 769 .for_each(|line| req.extend(pkt(format!("{line}\n").as_bytes()))); 770 wants 771 .iter() 772 .for_each(|want| req.extend(pkt(format!("want {want}\n").as_bytes()))); 773 haves 774 .iter() 775 .for_each(|have| req.extend(pkt(format!("have {have}\n").as_bytes()))); 776 if done { 777 req.extend(pkt(b"done\n")); 778 } 779 req.extend_from_slice(b"0000"); 780 req 781} 782 783fn pkt_payloads(resp: &[u8]) -> Vec<Vec<u8>> { 784 let mut out = Vec::new(); 785 let mut pos = 0usize; 786 while pos + 4 <= resp.len() { 787 let len = std::str::from_utf8(&resp[pos..pos + 4]) 788 .ok() 789 .and_then(|hex| usize::from_str_radix(hex, 16).ok()) 790 .unwrap_or(0); 791 pos += 4; 792 if len < 4 { 793 continue; 794 } 795 let end = (pos + len - 4).min(resp.len()); 796 out.push(resp[pos..end].to_vec()); 797 pos = end; 798 } 799 out 800} 801 802fn banded(payloads: &[Vec<u8>], section: &[u8]) -> bool { 803 payloads 804 .iter() 805 .any(|payload| payload.first() == Some(&1) && payload[1..].starts_with(section)) 806} 807 808fn plain(payloads: &[Vec<u8>], section: &[u8]) -> bool { 809 payloads.iter().any(|payload| payload == section) 810} 811 812fn pushed_history( 813 layout: &Layout, 814 did: &RepoDid, 815) -> (knot_git::Repo, std::path::PathBuf, String, String, String) { 816 let bare = layout.create(did).unwrap(); 817 let bare_path = layout.repo_path(did).unwrap(); 818 let work_dir = tempfile::tempdir().unwrap(); 819 let work = work_dir.path(); 820 must(work, &["init", "-q", "-b", "main"]); 821 commit(work, "a.txt", "1\n", "c1"); 822 let old = must(work, &["rev-parse", "HEAD"]); 823 let blob = must(work, &["rev-parse", "HEAD:a.txt"]); 824 commit(work, "b.txt", "2\n", "c2"); 825 let tip = must(work, &["rev-parse", "HEAD"]); 826 must(work, &["push", "-q", bare_path.to_str().unwrap(), "main"]); 827 (bare, bare_path, old, tip, blob) 828} 829 830#[test] 831fn v2_sideband_all_band_frames_negotiation_and_packfile_uris() { 832 let scan = tempfile::tempdir().unwrap(); 833 let layout = Layout::new(scan.path()); 834 let did = RepoDid::new("did:plc:squid").unwrap(); 835 let (bare, bare_path, old, tip, blob) = pushed_history(&layout, &did); 836 837 let plain_resp = 838 knot_pack::upload_pack(&bare, &v2_fetch_with(&[&tip], &[&old], false, &[])).unwrap(); 839 let plain_payloads = pkt_payloads(&plain_resp); 840 assert!( 841 plain(&plain_payloads, b"acknowledgments\n"), 842 "without sideband-all acknowledgments header is a plain pkt-line" 843 ); 844 assert!( 845 !banded(&plain_payloads, b"acknowledgments\n"), 846 "without sideband-all negotiation mustn't be band-framed" 847 ); 848 849 let banded_resp = knot_pack::upload_pack( 850 &bare, 851 &v2_fetch_with(&[&tip], &[&old], false, &["sideband-all"]), 852 ) 853 .unwrap(); 854 let banded_payloads = pkt_payloads(&banded_resp); 855 assert!( 856 banded(&banded_payloads, b"acknowledgments\n"), 857 "sideband-all moves acknowledgments header into band 1" 858 ); 859 assert!( 860 banded(&banded_payloads, &format!("ACK {old}\n").into_bytes()), 861 "sideband-all wraps ACK lines too" 862 ); 863 assert!( 864 !plain(&banded_payloads, b"acknowledgments\n"), 865 "under sideband-all nothing in negotiation is sent as a plain pkt-line" 866 ); 867 868 let packhash = "0123456789abcdef0123456789abcdef01234567"; 869 let uri = format!("https://cdn.nel.pet/{packhash}.pack"); 870 must( 871 &bare_path, 872 &[ 873 "config", 874 "uploadpack.blobPackfileUri", 875 &format!("{blob} {packhash} {uri}"), 876 ], 877 ); 878 let bare = layout.open(&did).unwrap(); 879 880 let plain_uris = knot_pack::upload_pack( 881 &bare, 882 &v2_fetch_with(&[&tip], &[], false, &["packfile-uris https"]), 883 ) 884 .unwrap(); 885 let plain_uri_payloads = pkt_payloads(&plain_uris); 886 assert!( 887 plain(&plain_uri_payloads, b"packfile-uris\n"), 888 "without sideband-all packfile-uris header is a plain pkt-line, not band 1" 889 ); 890 assert!( 891 plain( 892 &plain_uri_payloads, 893 format!("{packhash} {uri}\n").as_bytes() 894 ), 895 "configured blob uri must be advertised verbatim" 896 ); 897 898 let banded_uris = knot_pack::upload_pack( 899 &bare, 900 &v2_fetch_with( 901 &[&tip], 902 &[], 903 false, 904 &["packfile-uris https", "sideband-all"], 905 ), 906 ) 907 .unwrap(); 908 let banded_uri_payloads = pkt_payloads(&banded_uris); 909 assert!( 910 banded(&banded_uri_payloads, b"packfile-uris\n"), 911 "sideband-all moves packfile-uris header into band 1" 912 ); 913 assert!( 914 !plain(&banded_uri_payloads, b"packfile-uris\n"), 915 "under sideband-all packfile-uris header is never a plain pkt-line" 916 ); 917} 918 919fn big_blob_repo( 920 layout: &Layout, 921 did: &RepoDid, 922 size: usize, 923) -> (knot_git::Repo, String, Vec<u8>, usize) { 924 let bare = layout.create(did).unwrap(); 925 let work_dir = tempfile::tempdir().unwrap(); 926 let work = work_dir.path(); 927 must(work, &["init", "-q", "-b", "main"]); 928 let filler: String = std::iter::repeat_n('a', size).collect(); 929 commit(work, "big.txt", &filler, "c1"); 930 let c1 = must(work, &["rev-parse", "HEAD"]); 931 let oids: Vec<String> = must(work, &["rev-list", "--objects", &c1]) 932 .lines() 933 .map(|line| line.split_whitespace().next().unwrap().to_string()) 934 .collect(); 935 let count = oids.len(); 936 let pack = pack_objects(work, &oids); 937 (bare, c1, pack, count) 938} 939 940#[test] 941fn pack_limits_reject_oversized_and_overdeep_packs() { 942 let scan = tempfile::tempdir().unwrap(); 943 let layout = Layout::new(scan.path()); 944 945 let (size_bare, size_tip, size_pack, count) = 946 big_blob_repo(&layout, &RepoDid::new("did:plc:squid").unwrap(), 256 * 1024); 947 assert!(count >= 2, "commit packs at least a commit and a tree"); 948 949 let ofs_bare = layout 950 .create(&RepoDid::new("did:plc:whelk").unwrap()) 951 .unwrap(); 952 let ofs_work = tempfile::tempdir().unwrap(); 953 let ow = ofs_work.path(); 954 must(ow, &["init", "-q", "-b", "main"]); 955 let base: String = std::iter::repeat_n('a', 128 * 1024).collect(); 956 commit(ow, "big.txt", &base, "c1"); 957 let oc1 = must(ow, &["rev-parse", "HEAD"]); 958 let mut tweaked = base.clone(); 959 tweaked.push('b'); 960 commit(ow, "big.txt", &tweaked, "c2"); 961 let oc2 = must(ow, &["rev-parse", "HEAD"]); 962 let ofs_oids: Vec<String> = must(ow, &["rev-list", "--objects", &oc1, &oc2]) 963 .lines() 964 .map(|line| line.split_whitespace().next().unwrap().to_string()) 965 .collect(); 966 let ofs_pack = pack_objects_tuned(ow, &ofs_oids, true); 967 968 let (ref_bare, ref_tip, ref_pack) = 969 ref_delta_chain_repo(&layout, &RepoDid::new("did:plc:limpet").unwrap(), 4); 970 971 let cases: [(&knot_git::Repo, &[u8], &str, PackLimits, &str); 5] = [ 972 ( 973 &size_bare, 974 size_pack.as_slice(), 975 size_tip.as_str(), 976 PackLimits { 977 max_object_bytes: knot_pack::MaxObjectBytes::new(4096), 978 ..generous() 979 }, 980 "unpack pack exceeds per-object size limit", 981 ), 982 ( 983 &size_bare, 984 size_pack.as_slice(), 985 size_tip.as_str(), 986 PackLimits { 987 max_total_bytes: knot_pack::MaxTotalBytes::new(4096), 988 ..generous() 989 }, 990 "unpack pack exceeds total decompressed size limit", 991 ), 992 ( 993 &size_bare, 994 size_pack.as_slice(), 995 size_tip.as_str(), 996 PackLimits { 997 max_objects: ObjectCount::new(count - 1), 998 ..generous() 999 }, 1000 "unpack pack exceeds object count limit", 1001 ), 1002 ( 1003 &ofs_bare, 1004 ofs_pack.as_slice(), 1005 oc2.as_str(), 1006 PackLimits { 1007 max_delta_depth: DeltaDepth::new(0), 1008 ..generous() 1009 }, 1010 "unpack pack exceeds delta chain depth limit", 1011 ), 1012 ( 1013 &ref_bare, 1014 ref_pack.as_slice(), 1015 ref_tip.as_str(), 1016 PackLimits { 1017 max_delta_depth: DeltaDepth::new(0), 1018 ..generous() 1019 }, 1020 "unpack pack exceeds delta chain depth limit", 1021 ), 1022 ]; 1023 cases 1024 .into_iter() 1025 .for_each(|(bare, pack, tip, limits, message)| { 1026 let req = receive_request("refs/heads/main", &Oid::null().to_hex(), tip, pack); 1027 let report = String::from_utf8_lossy( 1028 &knot_pack::receive_pack_with_limits(bare, &req, &limits).unwrap(), 1029 ) 1030 .replace('\0', ""); 1031 assert!(report.contains(message), "{message}:\n{report}"); 1032 assert!( 1033 created_refs(bare).is_empty(), 1034 "refused pack must create no ref" 1035 ); 1036 }); 1037} 1038 1039#[test] 1040fn a_delta_declaring_an_oversized_result_is_rejected_on_both_ingest_paths() { 1041 let scan = tempfile::tempdir().unwrap(); 1042 let layout = Layout::new(scan.path()); 1043 let limits = generous(); 1044 let bomb = delta_bomb_pack(1 << 40); 1045 let absent_tip = "f".repeat(40); 1046 1047 let empty = layout 1048 .create(&RepoDid::new("did:plc:cuttle").unwrap()) 1049 .unwrap(); 1050 let fresh_req = receive_request("refs/heads/main", &Oid::null().to_hex(), &absent_tip, &bomb); 1051 let fresh_report = String::from_utf8_lossy( 1052 &knot_pack::receive_pack_with_limits(&empty, &fresh_req, &limits).unwrap(), 1053 ) 1054 .replace('\0', ""); 1055 assert!( 1056 fresh_report.contains("reconstructed size"), 1057 "the fold traversal must refuse a delta declaring an oversized result:\n{fresh_report}" 1058 ); 1059 assert!( 1060 created_refs(&empty).is_empty(), 1061 "refused bomb must create no ref" 1062 ); 1063 1064 let (seeded_bare, tip, seed_pack, _) = 1065 big_blob_repo(&layout, &RepoDid::new("did:plc:scallop").unwrap(), 1024); 1066 let seed_req = receive_request("refs/heads/main", &Oid::null().to_hex(), &tip, &seed_pack); 1067 let seed_report = String::from_utf8_lossy( 1068 &knot_pack::receive_pack_with_limits(&seeded_bare, &seed_req, &limits).unwrap(), 1069 ) 1070 .replace('\0', ""); 1071 assert!( 1072 seed_report.contains("unpack ok"), 1073 "seeding the repo must succeed:\n{seed_report}" 1074 ); 1075 1076 let bomb_req = receive_request("refs/heads/bomb", &Oid::null().to_hex(), &absent_tip, &bomb); 1077 let meter_report = String::from_utf8_lossy( 1078 &knot_pack::receive_pack_with_limits(&seeded_bare, &bomb_req, &limits).unwrap(), 1079 ) 1080 .replace('\0', ""); 1081 assert!( 1082 meter_report.contains("per-object size"), 1083 "the meter gate must refuse the bomb on the buffered path:\n{meter_report}" 1084 ); 1085} 1086 1087fn ref_delta_chain_repo( 1088 layout: &Layout, 1089 did: &RepoDid, 1090 revisions: usize, 1091) -> (knot_git::Repo, String, Vec<u8>) { 1092 let bare = layout.create(did).unwrap(); 1093 let work_dir = tempfile::tempdir().unwrap(); 1094 let work = work_dir.path(); 1095 must(work, &["init", "-q", "-b", "main"]); 1096 let tips: Vec<String> = (0..revisions) 1097 .map(|step| { 1098 let body: String = std::iter::repeat_n('a', 64 * 1024).collect(); 1099 commit( 1100 work, 1101 "big.txt", 1102 &format!("{body}{step}\n"), 1103 &format!("c{step}"), 1104 ); 1105 must(work, &["rev-parse", "HEAD"]) 1106 }) 1107 .collect(); 1108 let tip = tips.last().unwrap().clone(); 1109 let oids: Vec<String> = must(work, &["rev-list", "--objects", "HEAD"]) 1110 .lines() 1111 .map(|line| line.split_whitespace().next().unwrap().to_string()) 1112 .collect(); 1113 let pack = pack_objects_tuned(work, &oids, false); 1114 (bare, tip, pack) 1115} 1116 1117#[test] 1118fn ref_delta_with_in_pack_base_is_resolved() { 1119 let scan = tempfile::tempdir().unwrap(); 1120 let layout = Layout::new(scan.path()); 1121 let did = RepoDid::new("did:plc:squid").unwrap(); 1122 let (bare, tip, pack) = ref_delta_chain_repo(&layout, &did, 4); 1123 1124 let req = receive_request("refs/heads/main", &Oid::null().to_hex(), &tip, &pack); 1125 let report = 1126 String::from_utf8_lossy(&knot_pack::receive_pack(&bare, &req).unwrap()).replace('\0', ""); 1127 assert!( 1128 report.contains("unpack ok") && report.contains("ok refs/heads/main"), 1129 "self-contained ref-delta pack, which gix alone cannot index, must be resolved natively:\n{report}" 1130 ); 1131 assert_eq!( 1132 bare.find_ref(&RefName::new("refs/heads/main").unwrap()) 1133 .unwrap(), 1134 Some(Oid::from_hex(&tip).unwrap()), 1135 "ref must point at the pushed tip" 1136 ); 1137 let reopened = layout.open(&did).unwrap(); 1138 assert!( 1139 reopened.find_commit(Oid::from_hex(&tip).unwrap()).is_ok(), 1140 "every resolved object must be readable from the odb after push" 1141 ); 1142} 1143 1144#[test] 1145fn crafted_stale_old_oid_is_rejected_by_server_cas() { 1146 let scan = tempfile::tempdir().unwrap(); 1147 let layout = Layout::new(scan.path()); 1148 let did = RepoDid::new("did:plc:squid").unwrap(); 1149 let bare = layout.create(&did).unwrap(); 1150 1151 let work_dir = tempfile::tempdir().unwrap(); 1152 let work = work_dir.path(); 1153 must(work, &["init", "-q", "-b", "main"]); 1154 commit(work, "a.txt", "one\n", "c1"); 1155 let c1 = must(work, &["rev-parse", "HEAD"]); 1156 commit(work, "a.txt", "two\n", "c2"); 1157 let c2 = must(work, &["rev-parse", "HEAD"]); 1158 1159 let oids1: Vec<String> = must(work, &["rev-list", "--objects", &c1]) 1160 .lines() 1161 .map(|line| line.split_whitespace().next().unwrap().to_string()) 1162 .collect(); 1163 let create = receive_request( 1164 "refs/heads/main", 1165 &Oid::null().to_hex(), 1166 &c1, 1167 &pack_objects(work, &oids1), 1168 ); 1169 let created = String::from_utf8_lossy(&knot_pack::receive_pack(&bare, &create).unwrap()) 1170 .replace('\0', ""); 1171 assert!( 1172 created.contains("ok refs/heads/main"), 1173 "setup push failed:\n{created}" 1174 ); 1175 1176 let wrong = "1234567812345678123456781234567812345678"; 1177 let oids2: Vec<String> = must(work, &["rev-list", "--objects", &c2, "--not", &c1]) 1178 .lines() 1179 .map(|line| line.split_whitespace().next().unwrap().to_string()) 1180 .collect(); 1181 let stale = receive_request("refs/heads/main", wrong, &c2, &pack_objects(work, &oids2)); 1182 let report = 1183 String::from_utf8_lossy(&knot_pack::receive_pack(&bare, &stale).unwrap()).replace('\0', ""); 1184 assert!( 1185 report.contains("unpack ok"), 1186 "pack itself is valid and must unpack:\n{report}" 1187 ); 1188 assert!( 1189 report.contains("ng refs/heads/main"), 1190 "crafted stale old-oid must be refused by the server-side compare-and-swap:\n{report}" 1191 ); 1192 assert_eq!( 1193 bare.find_ref(&RefName::new("refs/heads/main").unwrap()) 1194 .unwrap(), 1195 Some(Oid::from_hex(&c1).unwrap()), 1196 "ref must stay at its original tip after a rejected stale update" 1197 ); 1198} 1199 1200#[test] 1201fn fetch_emits_a_thin_pack_against_client_haves() { 1202 let scan = tempfile::tempdir().unwrap(); 1203 let layout = Layout::new(scan.path()); 1204 let did = RepoDid::new("did:plc:squid").unwrap(); 1205 let bare = layout.create(&did).unwrap(); 1206 1207 let work_dir = tempfile::tempdir().unwrap(); 1208 let work = work_dir.path(); 1209 must(work, &["init", "-q", "-b", "main"]); 1210 let big: String = (0..20_000).map(|line| format!("line {line}\n")).collect(); 1211 let small: String = (0..5_000).map(|line| format!("line {line}\n")).collect(); 1212 commit(work, "f.txt", &big, "c1"); 1213 let c1 = must(work, &["rev-parse", "HEAD"]); 1214 commit(work, "f.txt", &small, "c2"); 1215 let c2 = must(work, &["rev-parse", "HEAD"]); 1216 1217 let oids: Vec<String> = must(work, &["rev-list", "--objects", &c2]) 1218 .lines() 1219 .map(|line| line.split_whitespace().next().unwrap().to_string()) 1220 .collect(); 1221 let pack = pack_objects_tuned(work, &oids, true); 1222 let report = String::from_utf8_lossy( 1223 &knot_pack::receive_pack( 1224 &bare, 1225 &receive_request("refs/heads/main", &Oid::null().to_hex(), &c2, &pack), 1226 ) 1227 .unwrap(), 1228 ) 1229 .replace('\0', ""); 1230 assert!( 1231 report.contains("ok refs/heads/main"), 1232 "setup push failed:\n{report}" 1233 ); 1234 1235 let thin = unsideband(&knot_pack::upload_pack(&bare, &v2_fetch_thin(&c2, &c1)).unwrap()); 1236 assert!( 1237 !index_into_bare(&[], &thin), 1238 "thin-pack fetch must delta against the client's have and omit it, so it cannot resolve standalone" 1239 ); 1240 1241 let base_oids: Vec<String> = must(work, &["rev-list", "--objects", &c1]) 1242 .lines() 1243 .map(|line| line.split_whitespace().next().unwrap().to_string()) 1244 .collect(); 1245 assert!( 1246 thin_resolves_against_base(&pack_objects(work, &base_oids), &thin), 1247 "client that already has the base must resolve the thin pack w/ --fix-thin" 1248 ); 1249 1250 let fat = unsideband(&knot_pack::upload_pack(&bare, &v2_fetch(&[&c2], &[&c1], true)).unwrap()); 1251 assert!( 1252 index_into_bare(&[], &fat), 1253 "without thin-pack same fetch must be self-contained" 1254 ); 1255} 1256 1257#[test] 1258fn valid_pack_passes_default_limits() { 1259 let scan = tempfile::tempdir().unwrap(); 1260 let layout = Layout::new(scan.path()); 1261 let did = RepoDid::new("did:plc:squid").unwrap(); 1262 let (bare, c1, pack, _) = big_blob_repo(&layout, &did, 64 * 1024); 1263 1264 let req = receive_request("refs/heads/main", &Oid::null().to_hex(), &c1, &pack); 1265 let report = 1266 String::from_utf8_lossy(&knot_pack::receive_pack(&bare, &req).unwrap()).replace('\0', ""); 1267 assert!( 1268 report.contains("unpack ok") && report.contains("ok refs/heads/main"), 1269 "valid pack within the default limits must be accepted:\n{report}" 1270 ); 1271} 1272 1273#[test] 1274fn selection_and_full_clone_expansion_abort_past_object_limit_and_deadline() { 1275 use std::time::Duration; 1276 1277 use knot_bench::{ChurnCount, CommitCount, HistorySpec, PathCount, build_history}; 1278 use knot_git::{Filter, PackBudget, SelectionLimit}; 1279 1280 let history = build_history(HistorySpec { 1281 commits: CommitCount::new(8), 1282 paths: PathCount::new(16), 1283 churn: ChurnCount::new(2), 1284 }); 1285 let repo = history.repo(); 1286 let tips = history.tips(); 1287 let stall = Duration::from_secs(60); 1288 1289 let selected = repo 1290 .select_pack_objects_filtered( 1291 knot_git::Wants::new(&tips), 1292 knot_git::Haves::new(&[]), 1293 Filter::None, 1294 PackBudget::unbounded(), 1295 ) 1296 .unwrap() 1297 .send 1298 .len(); 1299 assert!( 1300 selected > 4, 1301 "fixture must contain more objects than the limit under test, has {selected}" 1302 ); 1303 assert!( 1304 matches!( 1305 repo.select_pack_objects_filtered( 1306 knot_git::Wants::new(&tips), 1307 knot_git::Haves::new(&[]), 1308 Filter::None, 1309 PackBudget::new(ObjectCount::new(4), stall) 1310 ), 1311 Err(knot_git::GitError::Selection(SelectionLimit::Objects)) 1312 ), 1313 "selection past the object-set limit must abort, freeing its core within the budget" 1314 ); 1315 assert!( 1316 matches!( 1317 repo.select_pack_objects_filtered( 1318 knot_git::Wants::new(&tips), 1319 knot_git::Haves::new(&[]), 1320 Filter::None, 1321 PackBudget::new(ObjectCount::new(usize::MAX), Duration::ZERO) 1322 ), 1323 Err(knot_git::GitError::Selection(SelectionLimit::Time)) 1324 ), 1325 "selection that makes no progress within its stall window must abort with a time limit, not a partial pack" 1326 ); 1327 1328 let dir = repo.objects_dir(); 1329 let fmt = repo.object_format().kind(); 1330 let roots = repo.clone_roots(&tips, PackBudget::unbounded()).unwrap(); 1331 let expanded = knot_pack::count_expanded( 1332 &dir, 1333 roots.clone(), 1334 ObjectCount::new(usize::MAX), 1335 stall, 1336 fmt, 1337 ) 1338 .unwrap() 1339 .len(); 1340 assert!( 1341 expanded > 4, 1342 "fixture must contain more objects than the limit under test, has {expanded}" 1343 ); 1344 assert!( 1345 matches!( 1346 knot_pack::count_expanded(&dir, roots.clone(), ObjectCount::new(4), stall, fmt), 1347 Err(knot_pack::PackError::SelectionTooLarge) 1348 ), 1349 "full-clone expansion past the object limit must abort before the entry stream opens" 1350 ); 1351 assert!( 1352 matches!( 1353 knot_pack::count_expanded( 1354 &dir, 1355 roots, 1356 ObjectCount::new(usize::MAX), 1357 Duration::ZERO, 1358 fmt 1359 ), 1360 Err(knot_pack::PackError::SelectionTimeout) 1361 ), 1362 "full-clone expansion that makes no progress within its stall window must abort with a time limit, \ 1363 not a partial pack" 1364 ); 1365} 1366 1367#[test] 1368fn parallel_selection_matches_the_oracle_and_honors_its_budget() { 1369 use std::time::Duration; 1370 1371 use knot_bench::{ChurnCount, CommitCount, HistorySpec, PathCount, build_history}; 1372 use knot_git::{Filter, PackBudget, SelectionLimit}; 1373 1374 let history = build_history(HistorySpec { 1375 commits: CommitCount::new(5000), 1376 paths: PathCount::new(2), 1377 churn: ChurnCount::new(1), 1378 }); 1379 let repo = history.repo(); 1380 let tips = history.tips(); 1381 let stall = Duration::from_secs(60); 1382 1383 let selected = repo 1384 .select_pack_objects_filtered( 1385 knot_git::Wants::new(&tips), 1386 knot_git::Haves::new(&[]), 1387 Filter::None, 1388 PackBudget::unbounded(), 1389 ) 1390 .unwrap() 1391 .send; 1392 1393 let dir = repo.objects_dir(); 1394 let fmt = repo.object_format().kind(); 1395 let roots = repo.clone_roots(&tips, PackBudget::unbounded()).unwrap(); 1396 let expanded = knot_pack::count_expanded(&dir, roots, ObjectCount::new(usize::MAX), stall, fmt) 1397 .unwrap() 1398 .len(); 1399 assert_eq!( 1400 selected.len(), 1401 expanded, 1402 "the parallel selection walk must reach the same object set as the expansion oracle" 1403 ); 1404 1405 assert!( 1406 matches!( 1407 repo.select_pack_objects_filtered( 1408 knot_git::Wants::new(&tips), 1409 knot_git::Haves::new(&[]), 1410 Filter::None, 1411 PackBudget::new(ObjectCount::new(4), stall) 1412 ), 1413 Err(knot_git::GitError::Selection(SelectionLimit::Objects)) 1414 ), 1415 "the parallel walk must honor its object limit" 1416 ); 1417 assert!( 1418 matches!( 1419 repo.select_pack_objects_filtered( 1420 knot_git::Wants::new(&tips), 1421 knot_git::Haves::new(&[]), 1422 Filter::None, 1423 PackBudget::new(ObjectCount::new(usize::MAX), Duration::ZERO) 1424 ), 1425 Err(knot_git::GitError::Selection(SelectionLimit::Time)) 1426 ), 1427 "the parallel walk with no progress budget must abort on its stall window" 1428 ); 1429} 1430 1431#[test] 1432fn full_clone_roots_expand_a_directly_wanted_tree() { 1433 use std::time::Duration; 1434 1435 use knot_bench::{ChurnCount, CommitCount, HistorySpec, PathCount, build_history}; 1436 use knot_git::{Filter, PackBudget}; 1437 use knot_types::Oid; 1438 1439 let history = build_history(HistorySpec { 1440 commits: CommitCount::new(4), 1441 paths: PathCount::new(16), 1442 churn: ChurnCount::new(2), 1443 }); 1444 let repo = history.repo(); 1445 let tip = history.tip(); 1446 let tree = Oid::from( 1447 repo.git() 1448 .rev_parse_single(format!("{}^{{tree}}", tip.to_hex()).as_bytes()) 1449 .unwrap() 1450 .detach(), 1451 ); 1452 1453 let slow = repo 1454 .select_pack_objects_filtered( 1455 knot_git::Wants::new(&[tree]), 1456 knot_git::Haves::new(&[]), 1457 Filter::None, 1458 PackBudget::unbounded(), 1459 ) 1460 .unwrap() 1461 .send 1462 .len(); 1463 assert!( 1464 slow > 1, 1465 "directly-wanted tree must include its blobs and subtrees, the selection walk found {slow}" 1466 ); 1467 1468 let stall = Duration::from_secs(60); 1469 let roots = repo.clone_roots(&[tree], PackBudget::unbounded()).unwrap(); 1470 let fast = knot_pack::count_expanded( 1471 &repo.objects_dir(), 1472 roots, 1473 ObjectCount::new(usize::MAX), 1474 stall, 1475 repo.object_format().kind(), 1476 ) 1477 .unwrap() 1478 .len(); 1479 assert_eq!( 1480 fast, slow, 1481 "full-clone fast path must enumerate the same object set as the selection walk \ 1482 for a directly-wanted tree" 1483 ); 1484}