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