This repository has no description
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}