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