Now let's take a silly one
0

Configure Feed

Select the types of activity you want to include in your feed.

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