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