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