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