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