Now let's take a silly one
1use std::collections::HashMap;
2use std::path::Path;
3use std::process::Command;
4use std::sync::Arc;
5
6use knot_atproto::Atproto;
7use knot_cob::{CobHome, CobStore};
8use knot_cobs::{CollaboratorsChange, Grant, MembersChange, Registration, RegistryChange};
9use knot_git::{Layout, Repo};
10use knot_index::Index;
11use knot_runtime::{
12 FakeHttp, HttpResponse, K256Signer, ManualClock, SeededEntropy, Signer, UnixMicros,
13};
14use knot_types::{AccountDid, KnotId, OwnerDid, RepoDid, RepoName, RepoRkey, UnixSeconds};
15use tempfile::TempDir;
16use tokio::net::TcpListener;
17use url::Url;
18
19const REPO_DID: &str = "did:plc:squid";
20const REPO_NAME: &str = "anemone";
21const OWNER_DID: &str = "did:plc:nel";
22const PDS_HOST: &str = "pds.oyster.cafe";
23
24fn git(cwd: &Path, env: &[(&str, &str)], args: &[&str]) -> (bool, String) {
25 let mut command = Command::new("git");
26 command
27 .args(args)
28 .current_dir(cwd)
29 .env("GIT_CONFIG_GLOBAL", "/dev/null")
30 .env("GIT_CONFIG_SYSTEM", "/dev/null")
31 .env("GIT_TERMINAL_PROMPT", "0")
32 .env("GIT_AUTHOR_NAME", "nel")
33 .env("GIT_AUTHOR_EMAIL", "nel@oyster.cafe")
34 .env("GIT_COMMITTER_NAME", "nel")
35 .env("GIT_COMMITTER_EMAIL", "nel@oyster.cafe");
36 env.iter().for_each(|(key, value)| {
37 command.env(key, value);
38 });
39 let out = command.output().expect("git runs");
40 let combined = format!(
41 "{}{}",
42 String::from_utf8_lossy(&out.stdout),
43 String::from_utf8_lossy(&out.stderr)
44 );
45 (out.status.success(), combined)
46}
47
48fn keygen(dir: &Path, name: &str) -> (String, String) {
49 let path = dir.join(name);
50 let out = Command::new("ssh-keygen")
51 .args([
52 "-t",
53 "ed25519",
54 "-N",
55 "",
56 "-C",
57 "nel@oyster.cafe",
58 "-f",
59 path.to_str().unwrap(),
60 ])
61 .output()
62 .expect("ssh-keygen runs");
63 assert!(
64 out.status.success(),
65 "ssh-keygen failed: {}",
66 String::from_utf8_lossy(&out.stderr)
67 );
68 let public_line = std::fs::read_to_string(dir.join(format!("{name}.pub")))
69 .unwrap()
70 .trim()
71 .to_string();
72 (path.to_str().unwrap().to_string(), public_line)
73}
74
75fn did_document(signer: &K256Signer, did: &str) -> Vec<u8> {
76 let multikey = knot_types::crypto::multikey(0xe7, signer.public_key().as_bytes());
77 serde_json::to_vec(&serde_json::json!({
78 "id": did,
79 "alsoKnownAs": ["at://nel.pet"],
80 "verificationMethod": [{
81 "id": format!("{did}#atproto"),
82 "type": "Multikey",
83 "controller": did,
84 "publicKeyMultibase": multikey
85 }],
86 "service": [{
87 "id": "#atproto_pds",
88 "type": "AtprotoPersonalDataServer",
89 "serviceEndpoint": format!("https://{PDS_HOST}")
90 }]
91 }))
92 .unwrap()
93}
94
95fn list_records_body(public_line: &str) -> Vec<u8> {
96 serde_json::to_vec(&serde_json::json!({
97 "records": [{
98 "uri": format!("at://{OWNER_DID}/sh.tangled.publicKey/1"),
99 "value": {
100 "$type": "sh.tangled.publicKey",
101 "key": public_line,
102 "name": "laptop",
103 "createdAt": "2026-06-08T00:00:00Z"
104 }
105 }]
106 }))
107 .unwrap()
108}
109
110fn fake_http(published_line: String) -> impl knot_runtime::HttpTransport {
111 let signer = K256Signer::generate(&SeededEntropy::new(1));
112 FakeHttp::new(move |request| {
113 let host = request.url.host_str().unwrap_or_default().to_string();
114 let path = request.url.path().to_string();
115 let body = if host == PDS_HOST {
116 list_records_body(&published_line)
117 } else if path.ends_with(REPO_DID) {
118 did_document(&signer, REPO_DID)
119 } else if path.ends_with(OWNER_DID) {
120 did_document(&signer, OWNER_DID)
121 } else {
122 return Ok(HttpResponse {
123 status: http::StatusCode::NOT_FOUND,
124 headers: http::HeaderMap::new(),
125 body: bytes::Bytes::new(),
126 });
127 };
128 Ok(HttpResponse {
129 status: http::StatusCode::OK,
130 headers: http::HeaderMap::new(),
131 body: bytes::Bytes::from(body),
132 })
133 })
134}
135
136fn actor_for_seed(seed: u64) -> knot_types::ActorId {
137 knot_types::ActorId::from_secp256k1(
138 K256Signer::generate(&SeededEntropy::new(seed))
139 .public_key()
140 .as_bytes(),
141 )
142}
143
144struct Server {
145 _scan: TempDir,
146 layout: Layout,
147 repo_did: RepoDid,
148 port: u16,
149 events: Arc<knot_events::EventLog<ManualClock>>,
150}
151
152async fn spawn_server(published_line: String, max_pack_bytes: usize) -> (Server, Arc<Index>) {
153 spawn_server_with(published_line, max_pack_bytes, true).await
154}
155
156async fn spawn_server_with(
157 published_line: String,
158 max_pack_bytes: usize,
159 warm: bool,
160) -> (Server, Arc<Index>) {
161 let scan = tempfile::tempdir().unwrap();
162 let meta_path = scan.path().join("meta");
163 Repo::create(&meta_path).unwrap();
164 let layout = Layout::new(scan.path().join("repos"));
165 let repo_did = RepoDid::new(REPO_DID).unwrap();
166 layout.create(&repo_did).unwrap();
167
168 let signer = K256Signer::generate(&SeededEntropy::new(2));
169 let meta = Repo::open(&meta_path).unwrap();
170 let store = CobStore::new(&meta);
171 store
172 .create(
173 &CobHome::from(&KnotId::new("did:web:nel.pet").unwrap()),
174 &RegistryChange::Register(Registration {
175 owner: OwnerDid::new(OWNER_DID).unwrap(),
176 rkey: RepoRkey::new(REPO_NAME).unwrap(),
177 name: RepoName::new(REPO_NAME).unwrap(),
178 repo: repo_did.clone(),
179 created_at: UnixSeconds::new(1),
180 }),
181 &signer,
182 UnixSeconds::new(1),
183 )
184 .unwrap();
185
186 let index = Arc::new(Index::new(meta_path, layout.clone()));
187 if warm {
188 index.rebuild().unwrap();
189 }
190
191 let atproto = Arc::new(Atproto::new(
192 fake_http(published_line),
193 ManualClock::new(UnixMicros::new(1_000_000_000)),
194 KnotId::new("did:web:nel.pet").unwrap(),
195 Url::parse("https://plc.directory/").unwrap(),
196 ));
197
198 let key_dir = scan.path().join("hostkey");
199 std::fs::create_dir_all(&key_dir).unwrap();
200 let host_key = knot_ssh::load_or_create_host_key(&key_dir.join("host")).unwrap();
201
202 let events = Arc::new(knot_events::EventLog::new(
203 ManualClock::new(UnixMicros::new(1_000_000_000)),
204 64,
205 ));
206 let state = Arc::new(knot_ssh::SshState::new(
207 layout.clone(),
208 Arc::clone(&index),
209 atproto,
210 actor_for_seed(1),
211 Arc::clone(&events),
212 "knot.test".to_string(),
213 "https://tangled.test".to_string(),
214 std::collections::BTreeSet::new(),
215 knot_types::AdmissionPolicy::Closed,
216 max_pack_bytes,
217 std::time::Duration::from_secs(2),
218 ));
219
220 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
221 let port = listener.local_addr().unwrap().port();
222 tokio::spawn(async move {
223 let _ = knot_ssh::serve_on_socket(listener, host_key, state).await;
224 });
225
226 (
227 Server {
228 _scan: scan,
229 layout,
230 repo_did,
231 port,
232 events,
233 },
234 index,
235 )
236}
237
238fn ssh_command(key_path: &str) -> String {
239 format!(
240 "ssh -i {key_path} -o IdentitiesOnly=yes -o StrictHostKeyChecking=no \
241 -o UserKnownHostsFile=/dev/null -o PreferredAuthentications=publickey -o BatchMode=yes"
242 )
243}
244
245fn seed_work(work: &Path) -> String {
246 std::fs::create_dir_all(work).unwrap();
247 git(work, &[], &["init", "-q", "-b", "main"]);
248 std::fs::write(work.join("README.md"), "hello over ssh\n").unwrap();
249 git(work, &[], &["add", "-A"]);
250 git(work, &[], &["commit", "-q", "-m", "initial"]);
251 let (ok, head) = git(work, &[], &["rev-parse", "HEAD"]);
252 assert!(ok);
253 head.trim().to_string()
254}
255
256fn seed_commits(work: &Path, count: usize) {
257 std::fs::create_dir_all(work).unwrap();
258 git(work, &[], &["init", "-q", "-b", "main"]);
259 (0..count).for_each(|i| {
260 std::fs::write(work.join("log.txt"), format!("line {i}\n")).unwrap();
261 git(work, &[], &["add", "-A"]);
262 git(work, &[], &["commit", "-q", "-m", &format!("c{i}")]);
263 });
264}
265
266fn fetch_main_exit(clone_dir: &Path, ssh: &str, extra_git: &[&str]) -> Option<i32> {
267 let mut args = vec!["-k", "3", "20", "git"];
268 args.extend_from_slice(extra_git);
269 args.extend_from_slice(&["fetch", "origin", "main"]);
270 Command::new("timeout")
271 .args(&args)
272 .current_dir(clone_dir)
273 .env("GIT_CONFIG_GLOBAL", "/dev/null")
274 .env("GIT_CONFIG_SYSTEM", "/dev/null")
275 .env("GIT_TERMINAL_PROMPT", "0")
276 .env("GIT_SSH_COMMAND", ssh)
277 .status()
278 .expect("timeout/git runs")
279 .code()
280}
281
282async fn incremental_fetch_exit(
283 seed_count: usize,
284 extra_git: &'static [&'static str],
285) -> Option<i32> {
286 let scratch = tempfile::tempdir().unwrap();
287 let (key_path, public_line) = keygen(scratch.path(), "client");
288 let (server, _index) = spawn_server(public_line, 1 << 30).await;
289 let url = format!(
290 "ssh://git@127.0.0.1:{}/{OWNER_DID}/{REPO_NAME}",
291 server.port
292 );
293 let ssh = ssh_command(&key_path);
294
295 let work = scratch.path().join("work");
296 seed_commits(&work, seed_count);
297 let (ok, out) = {
298 let (work, url, ssh) = (work.clone(), url.clone(), ssh.clone());
299 tokio::task::spawn_blocking(move || {
300 git(
301 &work,
302 &[("GIT_SSH_COMMAND", &ssh)],
303 &["push", "-q", &url, "main"],
304 )
305 })
306 .await
307 .unwrap()
308 };
309 assert!(ok, "seeding push must land:\n{out}");
310
311 let clone_dir = scratch.path().join("clone");
312 let dest = clone_dir.to_str().unwrap().to_string();
313 let (ok, out) = {
314 let (url, ssh) = (url.clone(), ssh.clone());
315 tokio::task::spawn_blocking(move || {
316 git(
317 Path::new("/tmp"),
318 &[("GIT_SSH_COMMAND", &ssh)],
319 &["clone", "-q", &url, &dest],
320 )
321 })
322 .await
323 .unwrap()
324 };
325 assert!(ok, "clone over ssh must succeed:\n{out}");
326
327 git(
328 &work,
329 &[],
330 &["commit", "-q", "--allow-empty", "-m", "advance"],
331 );
332 let (ok, out) = {
333 let (work, url, ssh) = (work.clone(), url.clone(), ssh.clone());
334 tokio::task::spawn_blocking(move || {
335 git(
336 &work,
337 &[("GIT_SSH_COMMAND", &ssh)],
338 &["push", "-q", &url, "main"],
339 )
340 })
341 .await
342 .unwrap()
343 };
344 assert!(ok, "advancing server tip must succeed:\n{out}");
345
346 let exit = tokio::task::spawn_blocking(move || fetch_main_exit(&clone_dir, &ssh, extra_git))
347 .await
348 .unwrap();
349 drop(server);
350 exit
351}
352
353#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
354async fn a_diverged_v0_incremental_fetch_over_ssh_completes_without_deadlock() {
355 assert_eq!(
356 incremental_fetch_exit(50, &["-c", "protocol.version=0"]).await,
357 Some(0),
358 "diverged v0 fetch sends more than 32 haves and blocks on an ACK/NAK. Upload loop \
359 answers each have-batch flush with a NAK instead of waiting for done, so it never hangs"
360 );
361}
362
363#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
364async fn a_default_protocol_incremental_fetch_over_ssh_completes() {
365 assert_eq!(
366 incremental_fetch_exit(50, &[]).await,
367 Some(0),
368 "git forwards GIT_PROTOCOL over ssh, so default fetch path negotiates with the v2 loop"
369 );
370}
371
372#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
373async fn a_push_addressed_by_owner_and_name_resolves_through_the_registry() {
374 let scratch = tempfile::tempdir().unwrap();
375 let (key_path, public_line) = keygen(scratch.path(), "client");
376 let (server, _index) = spawn_server(public_line, 1 << 30).await;
377 let url = format!(
378 "ssh://git@127.0.0.1:{}/{OWNER_DID}/{REPO_NAME}",
379 server.port
380 );
381 let ssh = ssh_command(&key_path);
382
383 let work = scratch.path().join("work");
384 let head = seed_work(&work);
385
386 let (ok, out) = tokio::task::spawn_blocking(move || {
387 git(
388 &work,
389 &[("GIT_SSH_COMMAND", &ssh)],
390 &["push", "-q", &url, "main"],
391 )
392 })
393 .await
394 .unwrap();
395 assert!(ok, "owner/name addressing must resolve and push:\n{out}");
396 let stored = server
397 .layout
398 .open(&server.repo_did)
399 .unwrap()
400 .find_ref(&knot_types::RefName::new("refs/heads/main").unwrap())
401 .unwrap();
402 assert_eq!(stored, Some(knot_types::Oid::from_hex(&head).unwrap()));
403}
404
405#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
406async fn a_push_addressed_with_a_conventional_git_suffix_resolves() {
407 let scratch = tempfile::tempdir().unwrap();
408 let (key_path, public_line) = keygen(scratch.path(), "client");
409 let (server, _index) = spawn_server(public_line, 1 << 30).await;
410 let url = format!(
411 "ssh://git@127.0.0.1:{}/{OWNER_DID}/{REPO_NAME}.git",
412 server.port
413 );
414 let ssh = ssh_command(&key_path);
415
416 let work = scratch.path().join("work");
417 let head = seed_work(&work);
418
419 let (ok, out) = tokio::task::spawn_blocking(move || {
420 git(
421 &work,
422 &[("GIT_SSH_COMMAND", &ssh)],
423 &["push", "-q", &url, "main"],
424 )
425 })
426 .await
427 .unwrap();
428 assert!(ok, ".git-suffixed name must resolve to the rkey:\n{out}");
429 let stored = server
430 .layout
431 .open(&server.repo_did)
432 .unwrap()
433 .find_ref(&knot_types::RefName::new("refs/heads/main").unwrap())
434 .unwrap();
435 assert_eq!(stored, Some(knot_types::Oid::from_hex(&head).unwrap()));
436}
437
438#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
439async fn a_push_while_the_index_is_warming_is_refused() {
440 let scratch = tempfile::tempdir().unwrap();
441 let (key_path, public_line) = keygen(scratch.path(), "client");
442 let (server, _index) = spawn_server_with(public_line, 1 << 30, false).await;
443 let url = format!(
444 "ssh://git@127.0.0.1:{}/{OWNER_DID}/{REPO_NAME}",
445 server.port
446 );
447 let ssh = ssh_command(&key_path);
448
449 let work = scratch.path().join("work");
450 seed_work(&work);
451
452 let (ok, out) = tokio::task::spawn_blocking(move || {
453 git(
454 &work,
455 &[("GIT_SSH_COMMAND", &ssh)],
456 &["push", "-q", &url, "main"],
457 )
458 })
459 .await
460 .unwrap();
461 assert!(
462 !ok,
463 "warming index must fail closed at the SSH boundary:\n{out}"
464 );
465 let stored = server
466 .layout
467 .open(&server.repo_did)
468 .unwrap()
469 .find_ref(&knot_types::RefName::new("refs/heads/main").unwrap())
470 .unwrap();
471 assert_eq!(stored, None, "no ref lands while index is warming");
472}
473
474#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
475async fn a_push_addressed_by_a_bare_repo_did_succeeds() {
476 let scratch = tempfile::tempdir().unwrap();
477 let (key_path, public_line) = keygen(scratch.path(), "client");
478 let (server, _index) = spawn_server(public_line, 1 << 30).await;
479 let url = format!("ssh://git@127.0.0.1:{}/{REPO_DID}", server.port);
480 let ssh = ssh_command(&key_path);
481
482 let work = scratch.path().join("work");
483 let head = seed_work(&work);
484
485 let (ok, out) = tokio::task::spawn_blocking(move || {
486 git(
487 &work,
488 &[("GIT_SSH_COMMAND", &ssh)],
489 &["push", "-q", &url, "main"],
490 )
491 })
492 .await
493 .unwrap();
494 assert!(ok, "bare repo-DID must address the repo directly:\n{out}");
495 let stored = server
496 .layout
497 .open(&server.repo_did)
498 .unwrap()
499 .find_ref(&knot_types::RefName::new("refs/heads/main").unwrap())
500 .unwrap();
501 assert_eq!(stored, Some(knot_types::Oid::from_hex(&head).unwrap()));
502}
503
504#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
505async fn an_unregistered_owner_and_name_is_not_found() {
506 let scratch = tempfile::tempdir().unwrap();
507 let (key_path, public_line) = keygen(scratch.path(), "client");
508 let (server, _index) = spawn_server(public_line, 1 << 30).await;
509 let url = format!("ssh://git@127.0.0.1:{}/{OWNER_DID}/conch", server.port);
510 let ssh = ssh_command(&key_path);
511
512 let work = scratch.path().join("work");
513 seed_work(&work);
514
515 let (ok, out) = tokio::task::spawn_blocking(move || {
516 git(&work, &[("GIT_SSH_COMMAND", &ssh)], &["push", &url, "main"])
517 })
518 .await
519 .unwrap();
520 assert!(
521 !ok,
522 "name with no registry entry must be rejected instead of silently routed:\n{out}"
523 );
524}
525
526#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
527async fn an_authorized_push_over_ssh_succeeds_and_a_clone_reads_it_back() {
528 let scratch = tempfile::tempdir().unwrap();
529 let (key_path, public_line) = keygen(scratch.path(), "client");
530 let (server, _index) = spawn_server(public_line, 1 << 30).await;
531 let url = format!(
532 "ssh://git@127.0.0.1:{}/{OWNER_DID}/{REPO_NAME}",
533 server.port
534 );
535 let ssh = ssh_command(&key_path);
536
537 let work = scratch.path().join("work");
538 let head = seed_work(&work);
539
540 let (ok, out) = tokio::task::spawn_blocking(move || {
541 git(
542 &work,
543 &[("GIT_SSH_COMMAND", &ssh)],
544 &["push", "-q", &url, "main"],
545 )
546 })
547 .await
548 .unwrap();
549 assert!(ok, "authorized push over ssh must succeed:\n{out}");
550
551 let stored = server
552 .layout
553 .open(&server.repo_did)
554 .unwrap()
555 .find_ref(&knot_types::RefName::new("refs/heads/main").unwrap())
556 .unwrap();
557 assert_eq!(
558 stored,
559 Some(knot_types::Oid::from_hex(&head).unwrap()),
560 "pushed commit must be the repository's main tip"
561 );
562
563 let clone_dir = scratch.path().join("clone");
564 let url = format!(
565 "ssh://git@127.0.0.1:{}/{OWNER_DID}/{REPO_NAME}",
566 server.port
567 );
568 let ssh = ssh_command(&key_path);
569 let dest = clone_dir.to_str().unwrap().to_string();
570 let (ok, out) = tokio::task::spawn_blocking(move || {
571 git(
572 Path::new("/tmp"),
573 &[("GIT_SSH_COMMAND", &ssh)],
574 &["clone", "-q", &url, &dest],
575 )
576 })
577 .await
578 .unwrap();
579 assert!(ok, "clone over ssh must succeed:\n{out}");
580 assert!(
581 clone_dir.join("README.md").exists(),
582 "clone must check out the pushed file"
583 );
584}
585
586#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
587async fn an_authorized_push_emits_a_ref_update_event() {
588 let scratch = tempfile::tempdir().unwrap();
589 let (key_path, public_line) = keygen(scratch.path(), "client");
590 let (server, _index) = spawn_server(public_line, 1 << 30).await;
591 let url = format!(
592 "ssh://git@127.0.0.1:{}/{OWNER_DID}/{REPO_NAME}",
593 server.port
594 );
595 let ssh = ssh_command(&key_path);
596 let work = scratch.path().join("work");
597 let head = seed_work(&work);
598
599 let (ok, out) = tokio::task::spawn_blocking(move || {
600 git(
601 &work,
602 &[("GIT_SSH_COMMAND", &ssh)],
603 &["push", "-q", &url, "main"],
604 )
605 })
606 .await
607 .unwrap();
608 assert!(ok, "authorized push over ssh must succeed:\n{out}");
609
610 let event = poll_for_event(&server.events, "sh.tangled.git.refUpdate").await;
611 assert_eq!(event["ref"], "refs/heads/main");
612 assert_eq!(event["newSha"], head);
613 assert_eq!(event["committerDid"], OWNER_DID);
614 assert_eq!(event["ownerDid"], OWNER_DID);
615 assert_eq!(event["meta"]["isDefaultRef"], true);
616}
617
618async fn poll_for_event(
619 events: &knot_events::EventLog<ManualClock>,
620 nsid: &str,
621) -> serde_json::Value {
622 for _ in 0..50 {
623 if let Some(payload) = events
624 .replay(knot_events::EventCursor::START, 32)
625 .into_iter()
626 .find(|event| event.nsid == nsid)
627 .map(|event| serde_json::to_value(&event).unwrap()["event"].clone())
628 {
629 return payload;
630 }
631 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
632 }
633 panic!("no {nsid} event was published within the polling window");
634}
635
636#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
637async fn a_push_with_an_unregistered_key_is_rejected() {
638 let scratch = tempfile::tempdir().unwrap();
639 let (_registered_path, registered_line) = keygen(scratch.path(), "registered");
640 let (attacker_path, _attacker_line) = keygen(scratch.path(), "attacker");
641 let (server, _index) = spawn_server(registered_line, 1 << 30).await;
642 let url = format!(
643 "ssh://git@127.0.0.1:{}/{OWNER_DID}/{REPO_NAME}",
644 server.port
645 );
646 let ssh = ssh_command(&attacker_path);
647
648 let work = scratch.path().join("work");
649 seed_work(&work);
650
651 let (ok, out) = tokio::task::spawn_blocking(move || {
652 git(&work, &[("GIT_SSH_COMMAND", &ssh)], &["push", &url, "main"])
653 })
654 .await
655 .unwrap();
656 assert!(
657 !ok,
658 "push offering a key no DID publishes must be rejected:\n{out}"
659 );
660 assert!(
661 server
662 .layout
663 .open(&server.repo_did)
664 .unwrap()
665 .references()
666 .unwrap()
667 .is_empty(),
668 "rejected push must not create any ref"
669 );
670}
671
672#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
673async fn an_oversized_push_is_refused_at_the_ssh_boundary() {
674 let scratch = tempfile::tempdir().unwrap();
675 let (key_path, public_line) = keygen(scratch.path(), "client");
676 let (server, _index) = spawn_server(public_line, 64).await;
677 let url = format!(
678 "ssh://git@127.0.0.1:{}/{OWNER_DID}/{REPO_NAME}",
679 server.port
680 );
681 let ssh = ssh_command(&key_path);
682
683 let work = scratch.path().join("work");
684 seed_work(&work);
685
686 let (ok, out) = tokio::task::spawn_blocking(move || {
687 git(&work, &[("GIT_SSH_COMMAND", &ssh)], &["push", &url, "main"])
688 })
689 .await
690 .unwrap();
691 assert!(
692 !ok,
693 "push larger than configured cap must be refused:\n{out}"
694 );
695 assert!(
696 server
697 .layout
698 .open(&server.repo_did)
699 .unwrap()
700 .references()
701 .unwrap()
702 .is_empty(),
703 "oversized push must not land any ref"
704 );
705}
706
707#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
708async fn an_up_to_date_push_over_ssh_is_accepted() {
709 let scratch = tempfile::tempdir().unwrap();
710 let (key_path, public_line) = keygen(scratch.path(), "client");
711 let (server, _index) = spawn_server(public_line, 1 << 30).await;
712 let url = format!(
713 "ssh://git@127.0.0.1:{}/{OWNER_DID}/{REPO_NAME}",
714 server.port
715 );
716
717 let work = scratch.path().join("work");
718 seed_work(&work);
719
720 let ssh = ssh_command(&key_path);
721 let first_url = url.clone();
722 let first_work = work.clone();
723 let (ok, out) = tokio::task::spawn_blocking(move || {
724 git(
725 &first_work,
726 &[("GIT_SSH_COMMAND", &ssh)],
727 &["push", "-q", &first_url, "main"],
728 )
729 })
730 .await
731 .unwrap();
732 assert!(ok, "first push must land:\n{out}");
733
734 let ssh = ssh_command(&key_path);
735 let (ok, out) = tokio::task::spawn_blocking(move || {
736 git(&work, &[("GIT_SSH_COMMAND", &ssh)], &["push", &url, "main"])
737 })
738 .await
739 .unwrap();
740 assert!(
741 ok,
742 "up-to-date no-op push must succeed instead of failing with a stream error:\n{out}"
743 );
744 assert!(
745 out.contains("up-to-date") || out.contains("up to date"),
746 "git must report branch is up to date:\n{out}"
747 );
748}
749
750#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
751async fn a_denied_push_over_ssh_leaves_no_objects_in_the_live_odb() {
752 let scratch = tempfile::tempdir().unwrap();
753 let (_registered_path, registered_line) = keygen(scratch.path(), "registered");
754 let (attacker_path, _attacker_line) = keygen(scratch.path(), "attacker");
755 let (server, _index) = spawn_server(registered_line, 1 << 30).await;
756 let url = format!(
757 "ssh://git@127.0.0.1:{}/{OWNER_DID}/{REPO_NAME}",
758 server.port
759 );
760 let ssh = ssh_command(&attacker_path);
761
762 let work = scratch.path().join("work");
763 let head = seed_work(&work);
764
765 let (ok, out) = tokio::task::spawn_blocking(move || {
766 git(&work, &[("GIT_SSH_COMMAND", &ssh)], &["push", &url, "main"])
767 })
768 .await
769 .unwrap();
770 assert!(!ok, "unauthorized push must be rejected:\n{out}");
771
772 let repo = server.layout.open(&server.repo_did).unwrap();
773 assert!(
774 repo.references().unwrap().is_empty(),
775 "denied push must create no ref"
776 );
777 assert!(
778 !repo.contains(knot_types::Oid::from_hex(&head).unwrap()),
779 "denied push must migrate no objects into the live odb"
780 );
781}
782
783#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
784async fn a_push_to_a_hidden_ref_is_rejected() {
785 let scratch = tempfile::tempdir().unwrap();
786 let (key_path, public_line) = keygen(scratch.path(), "client");
787 let (server, _index) = spawn_server(public_line, 1 << 30).await;
788 let url = format!(
789 "ssh://git@127.0.0.1:{}/{OWNER_DID}/{REPO_NAME}",
790 server.port
791 );
792 let ssh = ssh_command(&key_path);
793
794 let work = scratch.path().join("work");
795 seed_work(&work);
796
797 let (ok, out) = tokio::task::spawn_blocking(move || {
798 git(
799 &work,
800 &[("GIT_SSH_COMMAND", &ssh)],
801 &["push", &url, "main:refs/hidden/feature/main"],
802 )
803 })
804 .await
805 .unwrap();
806 assert!(!ok, "push to refs/hidden/* must be rejected:\n{out}");
807 assert!(
808 server
809 .layout
810 .open(&server.repo_did)
811 .unwrap()
812 .references()
813 .unwrap()
814 .is_empty(),
815 "forbidden-ref push must land nothing"
816 );
817}
818
819#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
820async fn a_push_to_an_arbitrary_namespace_is_accepted() {
821 let scratch = tempfile::tempdir().unwrap();
822 let (key_path, public_line) = keygen(scratch.path(), "client");
823 let (server, _index) = spawn_server(public_line, 1 << 30).await;
824 let url = format!(
825 "ssh://git@127.0.0.1:{}/{OWNER_DID}/{REPO_NAME}",
826 server.port
827 );
828 let ssh = ssh_command(&key_path);
829
830 let work = scratch.path().join("work");
831 seed_work(&work);
832
833 let (ok, out) = tokio::task::spawn_blocking(move || {
834 git(
835 &work,
836 &[("GIT_SSH_COMMAND", &ssh)],
837 &["push", &url, "main:refs/notes/commits"],
838 )
839 })
840 .await
841 .unwrap();
842 assert!(
843 ok,
844 "push to any non-reserved namespace must be accepted:\n{out}"
845 );
846 assert!(
847 server
848 .layout
849 .open(&server.repo_did)
850 .unwrap()
851 .references()
852 .unwrap()
853 .iter()
854 .any(|record| record.name.as_str() == "refs/notes/commits"),
855 "pushed ref must land"
856 );
857}
858
859#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
860async fn a_cob_ref_verifies_through_the_guard_over_ssh() {
861 let scratch = tempfile::tempdir().unwrap();
862 let (key_path, public_line) = keygen(scratch.path(), "client");
863 let (server, _index) = spawn_server(public_line, 1 << 30).await;
864 let url = format!(
865 "ssh://git@127.0.0.1:{}/{OWNER_DID}/{REPO_NAME}",
866 server.port
867 );
868
869 let work = scratch.path().join("work");
870 seed_work(&work);
871 let repo = Repo::open(&work).unwrap();
872 let store = CobStore::new(&repo);
873
874 let owner_signer = K256Signer::generate(&SeededEntropy::new(1));
875 let owned = store
876 .create(
877 &CobHome::from(&RepoDid::new(REPO_DID).unwrap()),
878 &MembersChange::Add(Grant {
879 subject: AccountDid::new("did:plc:limpet").unwrap(),
880 added_by: AccountDid::new(OWNER_DID).unwrap(),
881 created_at: UnixSeconds::new(1),
882 }),
883 &owner_signer,
884 UnixSeconds::new(1),
885 )
886 .unwrap();
887 let owned_ref = format!(
888 "refs/cobs/sh.tangled.knot.member/{}",
889 owned.object.oid().to_hex()
890 );
891
892 let stranger_signer = K256Signer::generate(&SeededEntropy::new(9));
893 let forged = store
894 .create(
895 &CobHome::from(&RepoDid::new(REPO_DID).unwrap()),
896 &MembersChange::Add(Grant {
897 subject: AccountDid::new("did:plc:whelk").unwrap(),
898 added_by: AccountDid::new(OWNER_DID).unwrap(),
899 created_at: UnixSeconds::new(2),
900 }),
901 &stranger_signer,
902 UnixSeconds::new(2),
903 )
904 .unwrap();
905 let forged_ref = format!(
906 "refs/cobs/sh.tangled.knot.member/{}",
907 forged.object.oid().to_hex()
908 );
909
910 let ssh = ssh_command(&key_path);
911 let owned_url = url.clone();
912 let owned_work = work.clone();
913 let owned_spec = format!("{owned_ref}:{owned_ref}");
914 let (ok, out) = tokio::task::spawn_blocking(move || {
915 git(
916 &owned_work,
917 &[("GIT_SSH_COMMAND", &ssh)],
918 &["push", &owned_url, &owned_spec],
919 )
920 })
921 .await
922 .unwrap();
923 assert!(
924 ok,
925 "COB ref signed by repository key must verify and land over ssh:\n{out}"
926 );
927
928 let ssh = ssh_command(&key_path);
929 let forged_spec = format!("{forged_ref}:{forged_ref}");
930 let (ok, out) = tokio::task::spawn_blocking(move || {
931 git(
932 &work,
933 &[("GIT_SSH_COMMAND", &ssh)],
934 &["push", &url, &forged_spec],
935 )
936 })
937 .await
938 .unwrap();
939 assert!(
940 !ok,
941 "COB ref signed by stranger must be refused at receive boundary:\n{out}"
942 );
943
944 let names: Vec<String> = server
945 .layout
946 .open(&server.repo_did)
947 .unwrap()
948 .references()
949 .unwrap()
950 .iter()
951 .map(|record| record.name.as_str().to_string())
952 .collect();
953 assert!(
954 names.contains(&owned_ref),
955 "owner-signed COB ref must be stored: {names:?}"
956 );
957 assert!(
958 !names.contains(&forged_ref),
959 "stranger-signed COB ref must be absent: {names:?}"
960 );
961}
962
963#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
964async fn a_knot_signed_cob_minted_for_another_repo_is_refused_on_transplant() {
965 let scratch = tempfile::tempdir().unwrap();
966 let (key_path, public_line) = keygen(scratch.path(), "client");
967 let (server, _index) = spawn_server(public_line, 1 << 30).await;
968 let url = format!(
969 "ssh://git@127.0.0.1:{}/{OWNER_DID}/{REPO_NAME}",
970 server.port
971 );
972
973 let work = scratch.path().join("work");
974 seed_work(&work);
975 let repo = Repo::open(&work).unwrap();
976 let store = CobStore::new(&repo);
977
978 let authority_signer = K256Signer::generate(&SeededEntropy::new(1));
979 let homed = store
980 .create(
981 &CobHome::from(&RepoDid::new(REPO_DID).unwrap()),
982 &MembersChange::Add(Grant {
983 subject: AccountDid::new("did:plc:limpet").unwrap(),
984 added_by: AccountDid::new(OWNER_DID).unwrap(),
985 created_at: UnixSeconds::new(1),
986 }),
987 &authority_signer,
988 UnixSeconds::new(1),
989 )
990 .unwrap();
991 let homed_ref = format!(
992 "refs/cobs/sh.tangled.knot.member/{}",
993 homed.object.oid().to_hex()
994 );
995
996 let foreign_home = RepoDid::new("did:plc:whelk").unwrap();
997 let transplanted = store
998 .create(
999 &CobHome::from(&foreign_home),
1000 &MembersChange::Add(Grant {
1001 subject: AccountDid::new("did:plc:mussel").unwrap(),
1002 added_by: AccountDid::new(OWNER_DID).unwrap(),
1003 created_at: UnixSeconds::new(2),
1004 }),
1005 &authority_signer,
1006 UnixSeconds::new(2),
1007 )
1008 .unwrap();
1009 let transplanted_ref = format!(
1010 "refs/cobs/sh.tangled.knot.member/{}",
1011 transplanted.object.oid().to_hex()
1012 );
1013
1014 let ssh = ssh_command(&key_path);
1015 let homed_url = url.clone();
1016 let homed_work = work.clone();
1017 let homed_spec = format!("{homed_ref}:{homed_ref}");
1018 let (ok, out) = tokio::task::spawn_blocking(move || {
1019 git(
1020 &homed_work,
1021 &[("GIT_SSH_COMMAND", &ssh)],
1022 &["push", &homed_url, &homed_spec],
1023 )
1024 })
1025 .await
1026 .unwrap();
1027 assert!(
1028 ok,
1029 "same key signing for this repo's home must verify and land:\n{out}"
1030 );
1031
1032 let ssh = ssh_command(&key_path);
1033 let transplanted_spec = format!("{transplanted_ref}:{transplanted_ref}");
1034 let (ok, out) = tokio::task::spawn_blocking(move || {
1035 git(
1036 &work,
1037 &[("GIT_SSH_COMMAND", &ssh)],
1038 &["push", &url, &transplanted_spec],
1039 )
1040 })
1041 .await
1042 .unwrap();
1043 assert!(
1044 !ok,
1045 "same key signing for another repo's home must be refused on transplant here:\n{out}"
1046 );
1047
1048 let names: Vec<String> = server
1049 .layout
1050 .open(&server.repo_did)
1051 .unwrap()
1052 .references()
1053 .unwrap()
1054 .iter()
1055 .map(|record| record.name.as_str().to_string())
1056 .collect();
1057 assert!(
1058 names.contains(&homed_ref),
1059 "correctly homed COB ref must be stored: {names:?}"
1060 );
1061 assert!(
1062 !names.contains(&transplanted_ref),
1063 "transplanted COB ref must be absent: {names:?}"
1064 );
1065}
1066
1067#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1068async fn an_unregistered_key_offered_first_falls_back_to_the_registered_key() {
1069 let scratch = tempfile::tempdir().unwrap();
1070 let (registered_path, registered_line) = keygen(scratch.path(), "registered");
1071 let (unregistered_path, _unregistered_line) = keygen(scratch.path(), "unregistered");
1072 let (server, _index) = spawn_server(registered_line, 1 << 30).await;
1073 let url = format!(
1074 "ssh://git@127.0.0.1:{}/{OWNER_DID}/{REPO_NAME}",
1075 server.port
1076 );
1077 let ssh = format!(
1078 "ssh -i {unregistered_path} -i {registered_path} -o IdentitiesOnly=yes \
1079 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
1080 -o PreferredAuthentications=publickey -o BatchMode=yes"
1081 );
1082
1083 let work = scratch.path().join("work");
1084 let head = seed_work(&work);
1085 let (ok, out) = tokio::task::spawn_blocking(move || {
1086 git(
1087 &work,
1088 &[("GIT_SSH_COMMAND", &ssh)],
1089 &["push", "-q", &url, "main"],
1090 )
1091 })
1092 .await
1093 .unwrap();
1094 assert!(
1095 ok,
1096 "rejecting unregistered key must let client cycle to the registered one:\n{out}"
1097 );
1098 let stored = server
1099 .layout
1100 .open(&server.repo_did)
1101 .unwrap()
1102 .find_ref(&knot_types::RefName::new("refs/heads/main").unwrap())
1103 .unwrap();
1104 assert_eq!(stored, Some(knot_types::Oid::from_hex(&head).unwrap()));
1105}
1106
1107#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1108async fn a_squatted_key_cache_entry_does_not_deny_the_legitimate_owner() {
1109 let scratch = tempfile::tempdir().unwrap();
1110 let (key_path, public_line) = keygen(scratch.path(), "client");
1111 let (server, index) = spawn_server(public_line, 1 << 30).await;
1112 let url = format!(
1113 "ssh://git@127.0.0.1:{}/{OWNER_DID}/{REPO_NAME}",
1114 server.port
1115 );
1116 let ssh = ssh_command(&key_path);
1117
1118 let blob = russh::keys::ssh_key::PublicKey::from_openssh(
1119 &std::fs::read_to_string(scratch.path().join("client.pub")).unwrap(),
1120 )
1121 .unwrap()
1122 .to_bytes()
1123 .unwrap();
1124 index.cache_key(
1125 knot_types::OfferedKey::from_bytes(blob),
1126 &AccountDid::new("did:plc:whelk").unwrap(),
1127 );
1128
1129 let work = scratch.path().join("work");
1130 let head = seed_work(&work);
1131 let (ok, out) = tokio::task::spawn_blocking(move || {
1132 git(
1133 &work,
1134 &[("GIT_SSH_COMMAND", &ssh)],
1135 &["push", "-q", &url, "main"],
1136 )
1137 })
1138 .await
1139 .unwrap();
1140 assert!(
1141 ok,
1142 "stranger who published the owner's key must not deny the owner's push:\n{out}"
1143 );
1144 let stored = server
1145 .layout
1146 .open(&server.repo_did)
1147 .unwrap()
1148 .find_ref(&knot_types::RefName::new("refs/heads/main").unwrap())
1149 .unwrap();
1150 assert_eq!(stored, Some(knot_types::Oid::from_hex(&head).unwrap()));
1151}
1152
1153#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1154async fn a_cob_ref_delete_is_refused_as_append_only() {
1155 let scratch = tempfile::tempdir().unwrap();
1156 let (key_path, public_line) = keygen(scratch.path(), "client");
1157 let (server, _index) = spawn_server(public_line, 1 << 30).await;
1158 let url = format!(
1159 "ssh://git@127.0.0.1:{}/{OWNER_DID}/{REPO_NAME}",
1160 server.port
1161 );
1162
1163 let work = scratch.path().join("work");
1164 seed_work(&work);
1165 let repo = Repo::open(&work).unwrap();
1166 let store = CobStore::new(&repo);
1167 let owner_signer = K256Signer::generate(&SeededEntropy::new(1));
1168 let owned = store
1169 .create(
1170 &CobHome::from(&RepoDid::new(REPO_DID).unwrap()),
1171 &MembersChange::Add(Grant {
1172 subject: AccountDid::new("did:plc:limpet").unwrap(),
1173 added_by: AccountDid::new(OWNER_DID).unwrap(),
1174 created_at: UnixSeconds::new(1),
1175 }),
1176 &owner_signer,
1177 UnixSeconds::new(1),
1178 )
1179 .unwrap();
1180 let owned_ref = format!(
1181 "refs/cobs/sh.tangled.knot.member/{}",
1182 owned.object.oid().to_hex()
1183 );
1184
1185 let ssh = ssh_command(&key_path);
1186 let spec = format!("{owned_ref}:{owned_ref}");
1187 let (ok, out) = {
1188 let (work, url, ssh) = (work.clone(), url.clone(), ssh.clone());
1189 tokio::task::spawn_blocking(move || {
1190 git(&work, &[("GIT_SSH_COMMAND", &ssh)], &["push", &url, &spec])
1191 })
1192 .await
1193 .unwrap()
1194 };
1195 assert!(ok, "owner-signed COB ref must land first:\n{out}");
1196
1197 let ssh = ssh_command(&key_path);
1198 let del = format!(":{owned_ref}");
1199 let (ok, out) = {
1200 let (work, url, ssh) = (work.clone(), url.clone(), ssh.clone());
1201 tokio::task::spawn_blocking(move || {
1202 git(&work, &[("GIT_SSH_COMMAND", &ssh)], &["push", &url, &del])
1203 })
1204 .await
1205 .unwrap()
1206 };
1207 assert!(!ok, "deleting COB ref must be refused:\n{out}");
1208 assert!(
1209 out.contains("append-only"),
1210 "rejection must name the append-only rule instead of a verification failure:\n{out}"
1211 );
1212
1213 let names: Vec<String> = server
1214 .layout
1215 .open(&server.repo_did)
1216 .unwrap()
1217 .references()
1218 .unwrap()
1219 .iter()
1220 .map(|record| record.name.as_str().to_string())
1221 .collect();
1222 assert!(
1223 names.contains(&owned_ref),
1224 "COB ref must survive the refused delete: {names:?}"
1225 );
1226}
1227
1228#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1229async fn a_bare_did_for_an_unregistered_on_disk_repo_is_not_found() {
1230 let scratch = tempfile::tempdir().unwrap();
1231 let (key_path, public_line) = keygen(scratch.path(), "client");
1232 let (server, _index) = spawn_server(public_line, 1 << 30).await;
1233
1234 let ghost = RepoDid::new("did:plc:clam").unwrap();
1235 server.layout.create(&ghost).unwrap();
1236
1237 let url = format!("ssh://git@127.0.0.1:{}/did:plc:clam", server.port);
1238 let ssh = ssh_command(&key_path);
1239 let dest = scratch.path().join("ghost").to_str().unwrap().to_string();
1240 let (ok, out) = tokio::task::spawn_blocking(move || {
1241 git(
1242 Path::new("/tmp"),
1243 &[("GIT_SSH_COMMAND", &ssh)],
1244 &["clone", "-q", &url, &dest],
1245 )
1246 })
1247 .await
1248 .unwrap();
1249 assert!(
1250 !ok,
1251 "repo present on disk but absent from registry must not be served by bare DID:\n{out}"
1252 );
1253}
1254
1255#[test]
1256fn a_group_or_other_readable_host_key_is_refused_on_load() {
1257 use std::os::unix::fs::PermissionsExt;
1258 let dir = tempfile::tempdir().unwrap();
1259 let path = dir.path().join("host");
1260 knot_ssh::load_or_create_host_key(&path).unwrap();
1261 assert_eq!(
1262 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1263 0o600,
1264 "freshly created host key is 0600"
1265 );
1266
1267 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1268 let refused = knot_ssh::load_or_create_host_key(&path);
1269 assert!(
1270 matches!(refused, Err(knot_ssh::SshError::HostKey { .. })),
1271 "world-readable existing host key must be refused on load: {refused:?}"
1272 );
1273}
1274
1275fn ok_body(body: Vec<u8>) -> HttpResponse {
1276 HttpResponse {
1277 status: http::StatusCode::OK,
1278 headers: http::HeaderMap::new(),
1279 body: bytes::Bytes::from(body),
1280 }
1281}
1282
1283fn did_document_at(signer: &K256Signer, did: &str, pds: &str) -> Vec<u8> {
1284 let multikey = knot_types::crypto::multikey(0xe7, signer.public_key().as_bytes());
1285 serde_json::to_vec(&serde_json::json!({
1286 "id": did,
1287 "alsoKnownAs": ["at://nel.pet"],
1288 "verificationMethod": [{
1289 "id": format!("{did}#atproto"),
1290 "type": "Multikey",
1291 "controller": did,
1292 "publicKeyMultibase": multikey
1293 }],
1294 "service": [{
1295 "id": "#atproto_pds",
1296 "type": "AtprotoPersonalDataServer",
1297 "serviceEndpoint": pds
1298 }]
1299 }))
1300 .unwrap()
1301}
1302
1303fn list_records_lines(lines: &[String]) -> Vec<u8> {
1304 let records: Vec<_> = lines
1305 .iter()
1306 .map(|line| {
1307 serde_json::json!({
1308 "value": {
1309 "$type": "sh.tangled.publicKey",
1310 "key": line,
1311 "name": "laptop",
1312 "createdAt": "2026-06-08T00:00:00Z"
1313 }
1314 })
1315 })
1316 .collect();
1317 serde_json::to_vec(&serde_json::json!({ "records": records })).unwrap()
1318}
1319
1320fn multi_http(identities: HashMap<String, Vec<String>>) -> impl knot_runtime::HttpTransport {
1321 let signer = K256Signer::generate(&SeededEntropy::new(77));
1322 FakeHttp::new(move |request| {
1323 let host = request.url.host_str().unwrap_or_default().to_string();
1324 if host == "plc.directory" {
1325 let did = request.url.path().trim_start_matches('/').to_string();
1326 return Ok(ok_body(did_document_at(&signer, &did, "https://pds.test")));
1327 }
1328 if host == "pds.test" {
1329 let repo = request
1330 .url
1331 .query_pairs()
1332 .find(|(key, _)| key == "repo")
1333 .map(|(_, value)| value.into_owned())
1334 .unwrap_or_default();
1335 let lines = identities.get(&repo).cloned().unwrap_or_default();
1336 return Ok(ok_body(list_records_lines(&lines)));
1337 }
1338 Ok(HttpResponse {
1339 status: http::StatusCode::NOT_FOUND,
1340 headers: http::HeaderMap::new(),
1341 body: bytes::Bytes::new(),
1342 })
1343 })
1344}
1345
1346async fn launch(
1347 host_key_dir: &Path,
1348 layout: Layout,
1349 index: Arc<Index>,
1350 identities: HashMap<String, Vec<String>>,
1351) -> u16 {
1352 let atproto = Arc::new(Atproto::new(
1353 multi_http(identities),
1354 ManualClock::new(UnixMicros::new(1_000_000_000)),
1355 KnotId::new("did:web:nel.pet").unwrap(),
1356 Url::parse("https://plc.directory/").unwrap(),
1357 ));
1358 std::fs::create_dir_all(host_key_dir).unwrap();
1359 let host_key = knot_ssh::load_or_create_host_key(&host_key_dir.join("host")).unwrap();
1360 let events = Arc::new(knot_events::EventLog::new(
1361 ManualClock::new(UnixMicros::new(1_000_000_000)),
1362 64,
1363 ));
1364 let state = Arc::new(knot_ssh::SshState::new(
1365 layout,
1366 index,
1367 atproto,
1368 actor_for_seed(77),
1369 events,
1370 "knot.test".to_string(),
1371 "https://tangled.test".to_string(),
1372 std::collections::BTreeSet::new(),
1373 knot_types::AdmissionPolicy::Closed,
1374 1 << 30,
1375 std::time::Duration::from_secs(2),
1376 ));
1377 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1378 let port = listener.local_addr().unwrap().port();
1379 tokio::spawn(async move {
1380 let _ = knot_ssh::serve_on_socket(listener, host_key, state).await;
1381 });
1382 port
1383}
1384
1385async fn push_main(work: &Path, url: &str, key_path: &str) -> (bool, String) {
1386 let ssh = ssh_command(key_path);
1387 let (work, url) = (work.to_path_buf(), url.to_string());
1388 tokio::task::spawn_blocking(move || {
1389 git(&work, &[("GIT_SSH_COMMAND", &ssh)], &["push", &url, "main"])
1390 })
1391 .await
1392 .unwrap()
1393}
1394
1395fn main_tip(layout: &Layout, repo: &RepoDid) -> Option<knot_types::Oid> {
1396 layout
1397 .open(repo)
1398 .unwrap()
1399 .find_ref(&knot_types::RefName::new("refs/heads/main").unwrap())
1400 .unwrap()
1401}
1402
1403#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1404async fn a_collaborator_pushes_its_repo_but_a_recognized_key_is_denied_on_a_repo_it_has_no_grant_on()
1405 {
1406 const REPO_A: &str = "did:plc:squid";
1407 const REPO_B: &str = "did:plc:clam";
1408 const OWNER: &str = "did:plc:nel";
1409 const COLLAB: &str = "did:plc:olaren";
1410
1411 let scratch = tempfile::tempdir().unwrap();
1412 let (owner_key, owner_line) = keygen(scratch.path(), "owner");
1413 let (collab_key, collab_line) = keygen(scratch.path(), "collab");
1414
1415 let meta_path = scratch.path().join("meta");
1416 Repo::create(&meta_path).unwrap();
1417 let layout = Layout::new(scratch.path().join("repos"));
1418 let repo_a = RepoDid::new(REPO_A).unwrap();
1419 let repo_b = RepoDid::new(REPO_B).unwrap();
1420 let git_a = layout.create(&repo_a).unwrap();
1421 layout.create(&repo_b).unwrap();
1422
1423 let signer = K256Signer::generate(&SeededEntropy::new(2));
1424 let meta = Repo::open(&meta_path).unwrap();
1425 let store = CobStore::new(&meta);
1426 let knot_home = CobHome::from(&KnotId::new("did:web:nel.pet").unwrap());
1427 let reg = store
1428 .create(
1429 &knot_home,
1430 &RegistryChange::Register(Registration {
1431 owner: OwnerDid::new(OWNER).unwrap(),
1432 rkey: RepoRkey::new("anemone").unwrap(),
1433 name: RepoName::new("anemone").unwrap(),
1434 repo: repo_a.clone(),
1435 created_at: UnixSeconds::new(1),
1436 }),
1437 &signer,
1438 UnixSeconds::new(1),
1439 )
1440 .unwrap();
1441 store
1442 .update(
1443 &knot_home,
1444 reg.object,
1445 &RegistryChange::Register(Registration {
1446 owner: OwnerDid::new(OWNER).unwrap(),
1447 rkey: RepoRkey::new("barnacle").unwrap(),
1448 name: RepoName::new("barnacle").unwrap(),
1449 repo: repo_b.clone(),
1450 created_at: UnixSeconds::new(2),
1451 }),
1452 &signer,
1453 UnixSeconds::new(2),
1454 )
1455 .unwrap();
1456 store
1457 .create(
1458 &knot_home,
1459 &MembersChange::Add(Grant {
1460 subject: AccountDid::new(COLLAB).unwrap(),
1461 added_by: AccountDid::new(OWNER).unwrap(),
1462 created_at: UnixSeconds::new(1),
1463 }),
1464 &signer,
1465 UnixSeconds::new(1),
1466 )
1467 .unwrap();
1468 CobStore::new(&git_a)
1469 .create(
1470 &CobHome::from(&repo_a),
1471 &CollaboratorsChange::Add(Grant {
1472 subject: AccountDid::new(COLLAB).unwrap(),
1473 added_by: AccountDid::new(OWNER).unwrap(),
1474 created_at: UnixSeconds::new(1),
1475 }),
1476 &signer,
1477 UnixSeconds::new(1),
1478 )
1479 .unwrap();
1480
1481 let index = Arc::new(Index::new(meta_path, layout.clone()));
1482 index.rebuild().unwrap();
1483 index.warm_collaborators();
1484
1485 let identities = HashMap::from([
1486 (OWNER.to_string(), vec![owner_line]),
1487 (COLLAB.to_string(), vec![collab_line]),
1488 ]);
1489 let port = launch(
1490 &scratch.path().join("hostkey"),
1491 layout.clone(),
1492 Arc::clone(&index),
1493 identities,
1494 )
1495 .await;
1496
1497 let work_a = scratch.path().join("work_a");
1498 let head_a = seed_work(&work_a);
1499 let url_a = format!("ssh://git@127.0.0.1:{port}/{REPO_A}");
1500 let (ok, out) = push_main(&work_a, &url_a, &collab_key).await;
1501 assert!(
1502 ok,
1503 "collaborator must push to the repo it collaborates on:\n{out}"
1504 );
1505 assert_eq!(
1506 main_tip(&layout, &repo_a),
1507 Some(knot_types::Oid::from_hex(&head_a).unwrap()),
1508 "collaborator's commit must be repo A's main tip"
1509 );
1510
1511 let work_b = scratch.path().join("work_b");
1512 seed_work(&work_b);
1513 let url_b = format!("ssh://git@127.0.0.1:{port}/{REPO_B}");
1514 let (denied, out) = push_main(&work_b, &url_b, &collab_key).await;
1515 assert!(
1516 !denied,
1517 "key recognized via repo A, a knot member but neither owner nor collaborator of \
1518 repo B, must be denied because roster recognition is not push authorization:\n{out}"
1519 );
1520 assert!(
1521 main_tip(&layout, &repo_b).is_none(),
1522 "denied cross-repo push must land nothing on repo B"
1523 );
1524
1525 let work_owner = scratch.path().join("work_owner_b");
1526 let head_owner = seed_work(&work_owner);
1527 let (ok, out) = push_main(&work_owner, &url_b, &owner_key).await;
1528 assert!(
1529 ok,
1530 "owner must push to repo B, proving it is a live, reachable, pushable repo:\n{out}"
1531 );
1532 assert_eq!(
1533 main_tip(&layout, &repo_b),
1534 Some(knot_types::Oid::from_hex(&head_owner).unwrap()),
1535 "owner's push to repo B must land, isolating the collaborator's denial as authorization"
1536 );
1537}
1538
1539#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1540async fn an_existing_cob_ref_cannot_be_clobbered_or_rolled_forward_from_the_wire() {
1541 let scratch = tempfile::tempdir().unwrap();
1542 let (key_path, public_line) = keygen(scratch.path(), "client");
1543 let (server, _index) = spawn_server(public_line, 1 << 30).await;
1544 let url = format!(
1545 "ssh://git@127.0.0.1:{}/{OWNER_DID}/{REPO_NAME}",
1546 server.port
1547 );
1548
1549 let work = scratch.path().join("work");
1550 seed_work(&work);
1551 let repo = Repo::open(&work).unwrap();
1552 let store = CobStore::new(&repo);
1553 let owner_signer = K256Signer::generate(&SeededEntropy::new(1));
1554 let created = store
1555 .create(
1556 &CobHome::from(&RepoDid::new(REPO_DID).unwrap()),
1557 &MembersChange::Add(Grant {
1558 subject: AccountDid::new("did:plc:teq").unwrap(),
1559 added_by: AccountDid::new(OWNER_DID).unwrap(),
1560 created_at: UnixSeconds::new(1),
1561 }),
1562 &owner_signer,
1563 UnixSeconds::new(1),
1564 )
1565 .unwrap();
1566 let cob_ref = format!(
1567 "refs/cobs/sh.tangled.knot.member/{}",
1568 created.object.oid().to_hex()
1569 );
1570 let cob_name = knot_types::RefName::new(&cob_ref).unwrap();
1571 let spec = format!("{cob_ref}:{cob_ref}");
1572
1573 let ssh = ssh_command(&key_path);
1574 let (ok, out) = {
1575 let (work, url, ssh, spec) = (work.clone(), url.clone(), ssh.clone(), spec.clone());
1576 tokio::task::spawn_blocking(move || {
1577 git(&work, &[("GIT_SSH_COMMAND", &ssh)], &["push", &url, &spec])
1578 })
1579 .await
1580 .unwrap()
1581 };
1582 assert!(ok, "owner-signed COB ref must land first:\n{out}");
1583 assert_eq!(
1584 server
1585 .layout
1586 .open(&server.repo_did)
1587 .unwrap()
1588 .find_ref(&cob_name)
1589 .unwrap(),
1590 Some(created.tip.oid())
1591 );
1592
1593 store
1594 .update(
1595 &CobHome::from(&RepoDid::new(REPO_DID).unwrap()),
1596 created.object,
1597 &MembersChange::Add(Grant {
1598 subject: AccountDid::new("did:plc:bailey").unwrap(),
1599 added_by: AccountDid::new(OWNER_DID).unwrap(),
1600 created_at: UnixSeconds::new(2),
1601 }),
1602 &owner_signer,
1603 UnixSeconds::new(2),
1604 )
1605 .unwrap();
1606 let advanced = repo.find_ref(&cob_name).unwrap();
1607 assert_ne!(
1608 advanced,
1609 Some(created.tip.oid()),
1610 "local COB ref now points at a new, equally valid tip"
1611 );
1612
1613 let ssh = ssh_command(&key_path);
1614 let (ok, out) = {
1615 let (work, url, ssh, spec) = (work.clone(), url.clone(), ssh.clone(), spec.clone());
1616 tokio::task::spawn_blocking(move || {
1617 git(&work, &[("GIT_SSH_COMMAND", &ssh)], &["push", &url, &spec])
1618 })
1619 .await
1620 .unwrap()
1621 };
1622 assert!(
1623 !ok,
1624 "re-pushing moved COB ref must be refused instead of silently clobbered:\n{out}"
1625 );
1626 assert_eq!(
1627 server
1628 .layout
1629 .open(&server.repo_did)
1630 .unwrap()
1631 .find_ref(&cob_name)
1632 .unwrap(),
1633 Some(created.tip.oid()),
1634 "live COB ref must still point at the original tip"
1635 );
1636}
1637
1638#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1639async fn cob_refs_are_absent_from_the_ssh_ref_advertisement() {
1640 let scratch = tempfile::tempdir().unwrap();
1641 let (key_path, public_line) = keygen(scratch.path(), "client");
1642 let (server, _index) = spawn_server(public_line, 1 << 30).await;
1643 let url = format!(
1644 "ssh://git@127.0.0.1:{}/{OWNER_DID}/{REPO_NAME}",
1645 server.port
1646 );
1647
1648 let work = scratch.path().join("work");
1649 seed_work(&work);
1650 let repo = Repo::open(&work).unwrap();
1651 let store = CobStore::new(&repo);
1652 let owner_signer = K256Signer::generate(&SeededEntropy::new(1));
1653 let created = store
1654 .create(
1655 &CobHome::from(&RepoDid::new(REPO_DID).unwrap()),
1656 &MembersChange::Add(Grant {
1657 subject: AccountDid::new("did:plc:teq").unwrap(),
1658 added_by: AccountDid::new(OWNER_DID).unwrap(),
1659 created_at: UnixSeconds::new(1),
1660 }),
1661 &owner_signer,
1662 UnixSeconds::new(1),
1663 )
1664 .unwrap();
1665 let cob_ref = format!(
1666 "refs/cobs/sh.tangled.knot.member/{}",
1667 created.object.oid().to_hex()
1668 );
1669 let spec = format!("{cob_ref}:{cob_ref}");
1670
1671 let ssh = ssh_command(&key_path);
1672 let (ok, out) = {
1673 let (work, url, ssh, spec) = (work.clone(), url.clone(), ssh.clone(), spec.clone());
1674 tokio::task::spawn_blocking(move || {
1675 git(
1676 &work,
1677 &[("GIT_SSH_COMMAND", &ssh)],
1678 &["push", &url, "main", &spec],
1679 )
1680 })
1681 .await
1682 .unwrap()
1683 };
1684 assert!(ok, "head and COB ref must both land:\n{out}");
1685
1686 let ssh = ssh_command(&key_path);
1687 let (ok, out) = {
1688 let (url, ssh) = (url.clone(), ssh.clone());
1689 tokio::task::spawn_blocking(move || {
1690 git(
1691 Path::new("/tmp"),
1692 &[("GIT_SSH_COMMAND", &ssh)],
1693 &["ls-remote", &url],
1694 )
1695 })
1696 .await
1697 .unwrap()
1698 };
1699 assert!(ok, "ls-remote over ssh must succeed:\n{out}");
1700 assert!(
1701 out.contains("refs/heads/main"),
1702 "head must be advertised:\n{out}"
1703 );
1704 assert!(
1705 !out.contains("refs/cobs/"),
1706 "no refs/cobs/* may leak into the ssh advertisement:\n{out}"
1707 );
1708}
1709
1710fn ssh_bare(key_path: &str, port: u16) -> (bool, String) {
1711 let out = Command::new("ssh")
1712 .args([
1713 "-i",
1714 key_path,
1715 "-o",
1716 "IdentitiesOnly=yes",
1717 "-o",
1718 "StrictHostKeyChecking=no",
1719 "-o",
1720 "UserKnownHostsFile=/dev/null",
1721 "-o",
1722 "PreferredAuthentications=publickey",
1723 "-o",
1724 "BatchMode=yes",
1725 "-p",
1726 &port.to_string(),
1727 "git@127.0.0.1",
1728 ])
1729 .output()
1730 .expect("ssh runs");
1731 (
1732 out.status.success(),
1733 format!(
1734 "{}{}",
1735 String::from_utf8_lossy(&out.stdout),
1736 String::from_utf8_lossy(&out.stderr)
1737 ),
1738 )
1739}
1740
1741#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1742async fn a_bare_ssh_session_greets_the_recognized_user() {
1743 let scratch = tempfile::tempdir().unwrap();
1744 let (key_path, public_line) = keygen(scratch.path(), "client");
1745 let (server, _index) = spawn_server(public_line, 1 << 30).await;
1746
1747 let port = server.port;
1748 let (_ok, out) = tokio::task::spawn_blocking(move || ssh_bare(&key_path, port))
1749 .await
1750 .unwrap();
1751 assert!(
1752 out.contains("@nel.pet"),
1753 "greeting resolves and addresses the user by handle:\n{out}"
1754 );
1755 assert!(out.contains("knot.test"), "greeting names the knot:\n{out}");
1756}
1757
1758#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1759async fn a_push_to_a_new_branch_offers_a_pull_request_link() {
1760 let scratch = tempfile::tempdir().unwrap();
1761 let (key_path, public_line) = keygen(scratch.path(), "client");
1762 let (server, _index) = spawn_server(public_line, 1 << 30).await;
1763 let url = format!(
1764 "ssh://git@127.0.0.1:{}/{OWNER_DID}/{REPO_NAME}",
1765 server.port
1766 );
1767
1768 let work = scratch.path().join("work");
1769 seed_work(&work);
1770 let ssh = ssh_command(&key_path);
1771
1772 let main_push = {
1773 let (url, ssh, work) = (url.clone(), ssh.clone(), work.clone());
1774 tokio::task::spawn_blocking(move || {
1775 git(
1776 &work,
1777 &[("GIT_SSH_COMMAND", &ssh)],
1778 &["push", "-q", &url, "main"],
1779 )
1780 })
1781 .await
1782 .unwrap()
1783 };
1784 assert!(main_push.0, "seeding main must land:\n{}", main_push.1);
1785
1786 git(&work, &[], &["checkout", "-q", "-b", "feature"]);
1787 std::fs::write(work.join("feature.txt"), "work\n").unwrap();
1788 git(&work, &[], &["add", "-A"]);
1789 git(&work, &[], &["commit", "-q", "-m", "feature work"]);
1790
1791 let (ok, out) = tokio::task::spawn_blocking(move || {
1792 git(
1793 &work,
1794 &[("GIT_SSH_COMMAND", &ssh)],
1795 &["push", &url, "feature"],
1796 )
1797 })
1798 .await
1799 .unwrap();
1800 assert!(ok, "feature-branch push must land:\n{out}");
1801 assert!(
1802 out.contains("https://tangled.test/nel.pet/anemone/pulls/new"),
1803 "new non-default branch is answered with a pull-request link:\n{out}"
1804 );
1805 assert!(
1806 out.contains("sourceBranch=feature") && out.contains("targetBranch=main"),
1807 "link points the new branch at the default:\n{out}"
1808 );
1809}
1810
1811#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1812async fn a_verbose_ci_push_option_reports_a_clean_pipeline() {
1813 let scratch = tempfile::tempdir().unwrap();
1814 let (key_path, public_line) = keygen(scratch.path(), "client");
1815 let (server, _index) = spawn_server(public_line, 1 << 30).await;
1816 let url = format!(
1817 "ssh://git@127.0.0.1:{}/{OWNER_DID}/{REPO_NAME}",
1818 server.port
1819 );
1820
1821 let work = scratch.path().join("work");
1822 std::fs::create_dir_all(work.join(".tangled/workflows")).unwrap();
1823 git(&work, &[], &["init", "-q", "-b", "main"]);
1824 std::fs::write(
1825 work.join(".tangled/workflows/ci.yml"),
1826 "engine: nixery.dev/x\nwhen:\n - event: push\n branch: ['**']\n",
1827 )
1828 .unwrap();
1829 git(&work, &[], &["add", "-A"]);
1830 git(&work, &[], &["commit", "-q", "-m", "add ci"]);
1831
1832 let ssh = ssh_command(&key_path);
1833 let (ok, out) = tokio::task::spawn_blocking(move || {
1834 git(
1835 &work,
1836 &[("GIT_SSH_COMMAND", &ssh)],
1837 &["push", "--push-option=verbose-ci", &url, "main"],
1838 )
1839 })
1840 .await
1841 .unwrap();
1842 assert!(ok, "push with a push option must land:\n{out}");
1843 assert!(
1844 out.contains("no diagnostics"),
1845 "verbose-ci reports clean compile over the sideband:\n{out}"
1846 );
1847}
1848
1849#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1850async fn git_archive_remote_over_ssh_streams_a_tar_of_the_tree() {
1851 let scratch = tempfile::tempdir().unwrap();
1852 let (key_path, public_line) = keygen(scratch.path(), "client");
1853 let (server, _index) = spawn_server(public_line, 1 << 30).await;
1854 let url = format!(
1855 "ssh://git@127.0.0.1:{}/{OWNER_DID}/{REPO_NAME}",
1856 server.port
1857 );
1858 let ssh = ssh_command(&key_path);
1859
1860 let work = scratch.path().join("work");
1861 seed_work(&work);
1862 let (ok, out) = push_main(&work, &url, &key_path).await;
1863 assert!(ok, "seeding push must land before archiving:\n{out}");
1864
1865 let out_tar = scratch.path().join("archive.tar");
1866 let dest = out_tar.to_str().unwrap().to_string();
1867 let (ok, out) = {
1868 let (work, url, ssh) = (work.clone(), url.clone(), ssh.clone());
1869 tokio::task::spawn_blocking(move || {
1870 git(
1871 &work,
1872 &[("GIT_SSH_COMMAND", &ssh)],
1873 &[
1874 "archive",
1875 "--format=tar",
1876 "--remote",
1877 &url,
1878 "-o",
1879 &dest,
1880 "HEAD",
1881 ],
1882 )
1883 })
1884 .await
1885 .unwrap()
1886 };
1887 assert!(ok, "git archive --remote over ssh must succeed:\n{out}");
1888
1889 let tar = std::fs::read(&out_tar).unwrap();
1890 assert!(
1891 tar.windows(b"README.md".len()).any(|w| w == b"README.md"),
1892 "archived tar must carry the README.md entry"
1893 );
1894 drop(server);
1895}