This repository has no description
0

Configure Feed

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

core / knot2 / crates / knot-sim / tests / lfs_roundtrip.rs
52 kB 1465 lines
1mod common; 2 3use std::collections::BTreeSet; 4use std::io::Write; 5use std::path::{Path, PathBuf}; 6use std::process::{Command, Stdio}; 7use std::sync::Arc; 8use std::time::Duration; 9 10use base64::Engine; 11use base64::engine::general_purpose::URL_SAFE_NO_PAD; 12use bytes::Bytes; 13use http::Method; 14use knot_atproto::Atproto; 15use knot_cob::{CobHome, CobStore}; 16use knot_cobs::{Registration, RegistryChange}; 17use knot_edge::RequiresFullHandshake; 18use knot_git::{Layout, Repo}; 19use knot_lfs::{FreeSpaceFloor, LfsHandle, LfsOid, LfsSize, LfsStore, LfsStorePath}; 20use knot_runtime::{ 21 FakeHttp, HttpResponse, K256Signer, ManualClock, OsEntropy, Signer, UnixMicros, 22}; 23use knot_secrets::{MasterKey, SealedStore}; 24use knot_types::{ 25 AccountDid, AdmissionPolicy, AuthorName, Email, KnotHostname, KnotId, OwnerDid, RepoDid, 26 RepoName, RepoRkey, UnixSeconds, 27}; 28use sha2::{Digest, Sha256}; 29use tempfile::TempDir; 30use tokio::net::TcpListener; 31use tower::ServiceExt; 32use url::Url; 33 34const REPO_DID: &str = "did:plc:squid"; 35const REPO_NAME: &str = "anemone"; 36const FORK_NAME: &str = "anemone-fork"; 37const OWNER_DID: &str = "did:plc:nel"; 38const PDS_HOST: &str = "pds.oyster.cafe"; 39const KNOT_DID: &str = "did:web:nel.pet"; 40const PINNED_DATE: &str = "2026-07-07T12:00:00+00:00"; 41 42fn require_git_lfs() -> bool { 43 let available = Command::new("git-lfs") 44 .arg("version") 45 .output() 46 .map(|out| out.status.success()) 47 .unwrap_or(false); 48 match (available, std::env::var("KNOT_LFS_ROUNDTRIP").as_deref()) { 49 (true, _) => true, 50 (false, Ok("skip")) => { 51 eprintln!( 52 "skipping lfs round trip gate: git-lfs unavailable and KNOT_LFS_ROUNDTRIP=skip" 53 ); 54 false 55 } 56 (false, _) => panic!( 57 "the lfs round trip gate found no working git-lfs on PATH. \ 58 Install git-lfs or set KNOT_LFS_ROUNDTRIP=skip to skip the gate." 59 ), 60 } 61} 62 63fn media_bytes() -> Vec<u8> { 64 (0..1_048_576u32) 65 .map(|n| (n.wrapping_mul(31) % 251) as u8) 66 .collect() 67} 68 69fn second_media_bytes() -> Vec<u8> { 70 (0..524_288u32) 71 .map(|n| (n.wrapping_mul(97).wrapping_add(13) % 253) as u8) 72 .collect() 73} 74 75fn require_scutiger() -> bool { 76 let available = Command::new("git-lfs-transfer") 77 .arg("--help") 78 .output() 79 .map(|out| out.status.success()) 80 .unwrap_or(false); 81 match (available, std::env::var("KNOT_LFS_CONFORMANCE").as_deref()) { 82 (true, _) => true, 83 (false, Ok("skip")) => { 84 eprintln!( 85 "skipping lfs conformance gate: git-lfs-transfer unavailable and \ 86 KNOT_LFS_CONFORMANCE=skip" 87 ); 88 false 89 } 90 (false, _) => panic!( 91 "the lfs conformance gate found no scutiger git-lfs-transfer on PATH. \ 92 Install it or set KNOT_LFS_CONFORMANCE=skip to skip the gate." 93 ), 94 } 95} 96 97fn git(cwd: &Path, env: &[(String, String)], args: &[&str]) -> (bool, String) { 98 let mut command = knot_fixtures::command_at(cwd, PINNED_DATE); 99 command.args(args); 100 env.iter().for_each(|(key, value)| { 101 command.env(key, value); 102 }); 103 let out = command.output().expect("git runs"); 104 ( 105 out.status.success(), 106 format!( 107 "{}{}", 108 String::from_utf8_lossy(&out.stdout), 109 String::from_utf8_lossy(&out.stderr) 110 ), 111 ) 112} 113 114fn keygen(dir: &Path) -> (String, String) { 115 let path = dir.join("client"); 116 let out = Command::new("ssh-keygen") 117 .args([ 118 "-t", 119 "ed25519", 120 "-N", 121 "", 122 "-C", 123 "nel@oyster.cafe", 124 "-f", 125 path.to_str().unwrap(), 126 ]) 127 .output() 128 .expect("ssh-keygen runs"); 129 assert!(out.status.success()); 130 let public_line = std::fs::read_to_string(dir.join("client.pub")) 131 .unwrap() 132 .trim() 133 .to_string(); 134 (path.to_str().unwrap().to_string(), public_line) 135} 136 137fn actor_signer() -> K256Signer { 138 K256Signer::from_slice(&[9u8; 32]).unwrap() 139} 140 141fn did_document(did: &str) -> Vec<u8> { 142 let multikey = knot_types::crypto::multikey(0xe7, actor_signer().public_key().as_bytes()); 143 serde_json::to_vec(&serde_json::json!({ 144 "id": did, 145 "alsoKnownAs": ["at://nel.pet"], 146 "verificationMethod": [{ 147 "id": format!("{did}#atproto"), 148 "type": "Multikey", 149 "controller": did, 150 "publicKeyMultibase": multikey 151 }], 152 "service": [{ 153 "id": "#atproto_pds", 154 "type": "AtprotoPersonalDataServer", 155 "serviceEndpoint": format!("https://{PDS_HOST}") 156 }] 157 })) 158 .unwrap() 159} 160 161fn list_records_body(public_line: &str) -> Vec<u8> { 162 serde_json::to_vec(&serde_json::json!({ 163 "records": [{ 164 "uri": format!("at://{OWNER_DID}/sh.tangled.publicKey/1"), 165 "value": { 166 "$type": "sh.tangled.publicKey", 167 "key": public_line, 168 "name": "laptop", 169 "createdAt": "2026-07-01T00:00:00Z" 170 } 171 }] 172 })) 173 .unwrap() 174} 175 176fn fake_http( 177 published_line: String, 178) -> FakeHttp< 179 impl Fn(&knot_runtime::HttpRequest) -> Result<HttpResponse, knot_runtime::NetworkError> 180 + Send 181 + Sync, 182> { 183 FakeHttp::new(move |request: &knot_runtime::HttpRequest| { 184 let host = request.url.host_str().unwrap_or_default().to_string(); 185 let path = request.url.path().to_string(); 186 let body = if host == PDS_HOST { 187 list_records_body(&published_line) 188 } else if host == "plc.directory" && request.method == http::Method::POST { 189 b"{}".to_vec() 190 } else if host == "plc.directory" && path.starts_with("/did:") { 191 did_document(path.trim_start_matches('/')) 192 } else { 193 return Ok(HttpResponse { 194 status: http::StatusCode::NOT_FOUND, 195 headers: http::HeaderMap::new(), 196 body: bytes::Bytes::new(), 197 }); 198 }; 199 Ok(HttpResponse { 200 status: http::StatusCode::OK, 201 headers: http::HeaderMap::new(), 202 body: bytes::Bytes::from(body), 203 }) 204 }) 205} 206 207fn service_jwt(nsid: &str, jti: &str) -> String { 208 service_jwt_as(OWNER_DID, nsid, jti) 209} 210 211fn service_jwt_as(iss: &str, nsid: &str, jti: &str) -> String { 212 let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"ES256K","typ":"JWT"}"#); 213 let claims = serde_json::json!({ 214 "iss": iss, 215 "aud": KNOT_DID, 216 "exp": 1_001, 217 "iat": 999, 218 "jti": jti, 219 "lxm": nsid, 220 }); 221 let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap()); 222 let signing_input = format!("{header}.{payload}"); 223 let signature = actor_signer().sign(signing_input.as_bytes()); 224 format!( 225 "{signing_input}.{}", 226 URL_SAFE_NO_PAD.encode(signature.as_bytes()) 227 ) 228} 229 230struct World { 231 _scan: TempDir, 232 lfs: LfsHandle, 233 ssh_port: u16, 234 http_base: String, 235 router: axum::Router, 236 layout: Layout, 237 h3: Option<common::Edge>, 238 _certdir: Option<TempDir>, 239} 240 241async fn spawn_world(published_line: String) -> World { 242 spawn(published_line, false).await 243} 244 245async fn spawn(published_line: String, with_h3: bool) -> World { 246 let scan = tempfile::tempdir().unwrap(); 247 let meta_path = scan.path().join("meta"); 248 Repo::create(&meta_path).unwrap(); 249 let layout = Layout::new(scan.path().join("repos")); 250 let repo_did = RepoDid::new(REPO_DID).unwrap(); 251 layout.create(&repo_did).unwrap(); 252 253 let knot = KnotId::new(KNOT_DID).unwrap(); 254 let secrets = Arc::new( 255 SealedStore::open( 256 scan.path().join("keys.sealed"), 257 &MasterKey::new([7u8; 32]).unwrap(), 258 Box::new(OsEntropy), 259 ) 260 .unwrap(), 261 ); 262 secrets.ensure(&knot).unwrap(); 263 let knot_signer = secrets.signer(&knot).unwrap(); 264 265 let meta = Repo::open(&meta_path).unwrap(); 266 CobStore::new(&meta) 267 .create( 268 &CobHome::from(&knot), 269 &RegistryChange::Register(Registration { 270 owner: OwnerDid::new(OWNER_DID).unwrap(), 271 rkey: RepoRkey::new(REPO_NAME).unwrap(), 272 name: RepoName::new(REPO_NAME).unwrap(), 273 repo: repo_did.clone(), 274 created_at: UnixSeconds::new(1), 275 }), 276 &knot_signer, 277 UnixSeconds::new(1), 278 ) 279 .unwrap(); 280 281 let index = Arc::new(knot_index::Index::new(meta_path.clone(), layout.clone())); 282 index.rebuild().unwrap(); 283 284 let atproto = Arc::new(Atproto::new( 285 fake_http(published_line), 286 ManualClock::new(UnixMicros::new(1_000_000_000)), 287 knot.clone(), 288 knot_atproto::PlcDirectory::new(Url::parse("https://plc.directory/").unwrap()).unwrap(), 289 )); 290 291 let lfs_store_dir = scan.path().join("lfs"); 292 std::fs::create_dir_all(&lfs_store_dir).unwrap(); 293 let lfs = LfsHandle::open( 294 LfsStorePath::new(&lfs_store_dir), 295 LfsSize::new(64 * 1024 * 1024), 296 FreeSpaceFloor::new(0), 297 ) 298 .unwrap(); 299 300 let key_dir = scan.path().join("hostkey"); 301 std::fs::create_dir_all(&key_dir).unwrap(); 302 let host_key = knot_ssh::load_or_create_host_key(&key_dir.join("host")).unwrap(); 303 let events = Arc::new(knot_events::EventLog::new( 304 ManualClock::new(UnixMicros::new(1_000_000_000)), 305 knot_events::ReplayBounds::new( 306 knot_events::ReplayEvents::new(64).unwrap(), 307 knot_events::ReplayBytes::new(16 << 20).unwrap(), 308 ), 309 )); 310 let ssh_state = Arc::new( 311 knot_ssh::SshState::new( 312 layout.clone(), 313 Arc::clone(&index), 314 Arc::clone(&atproto), 315 knot_types::ActorId::from_secp256k1(actor_signer().public_key().as_bytes()), 316 Arc::clone(&events), 317 KnotHostname::new("nel.pet").unwrap(), 318 knot_types::AppviewEndpoint::new("https://tangled.test").unwrap(), 319 BTreeSet::from([AccountDid::new(OWNER_DID).unwrap()]), 320 AdmissionPolicy::Closed, 321 knot_xrpc::MaxWireBytes::new(1 << 30), 322 knot_xrpc::LanguagesPushBudget::new(Duration::from_secs(2)), 323 None, 324 ) 325 .with_lfs(lfs.clone(), 16), 326 ); 327 let ssh_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); 328 let ssh_port = ssh_listener.local_addr().unwrap().port(); 329 tokio::spawn(async move { 330 let _ = knot_ssh::serve_on_socket(ssh_listener, host_key, ssh_state).await; 331 }); 332 333 let http_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); 334 let http_base = format!( 335 "http://127.0.0.1:{}", 336 http_listener.local_addr().unwrap().port() 337 ); 338 339 let xrpc_state = Arc::new(knot_xrpc::XrpcState { 340 ci_logs: None, 341 layout: layout.clone(), 342 index: Arc::clone(&index), 343 atproto, 344 secrets, 345 entropy: Arc::new(OsEntropy), 346 admins: BTreeSet::from([AccountDid::new(OWNER_DID).unwrap()]), 347 admission: AdmissionPolicy::Closed, 348 knot_did: knot, 349 knot_hostname: KnotHostname::new("nel.pet").unwrap(), 350 meta_path, 351 knot_service_url: knot_types::KnotServiceUrl::new(http_base.clone()).unwrap(), 352 limiter: Arc::new(knot_xrpc::PreAuthLimiter::default()), 353 cob_locks: Arc::new(knot_xrpc::CobLocks::default()), 354 reservations: Arc::new(knot_xrpc::Reservations::new( 355 knot_xrpc::ReservationTtl::new(1_000_000), 356 knot_xrpc::PerActorQuota::new(16), 357 knot_xrpc::GlobalQuota::new(16), 358 )), 359 trusted_proxy_header: None, 360 committer: knot_xrpc::Committer { 361 name: AuthorName::new("Tangled"), 362 email: Email::new("noreply@tangled.sh"), 363 }, 364 byte_limits: knot_xrpc::ByteLimits { 365 pack: knot_xrpc::MaxWireBytes::new(1 << 30), 366 ..knot_xrpc::ByteLimits::default() 367 }, 368 budgets: knot_xrpc::Budgets::default(), 369 git_http: Arc::new(FakeHttp::new(|_request: &knot_runtime::HttpRequest| { 370 Err(knot_runtime::NetworkError::Connect( 371 "no remote upstream is served in this gate".to_string(), 372 )) 373 })), 374 pack_limits: knot_pack::PackLimits::default(), 375 service_owner: AccountDid::new(OWNER_DID).unwrap(), 376 events, 377 subscriber_gate: Arc::new(knot_events::SubscriberGate::new( 378 knot_events::GlobalSubscriberLimit::new(16), 379 knot_events::PerPeerSubscriberLimit::new(4), 380 )), 381 maintenance: knot_maintenance::MaintenanceHandle::disabled(), 382 appview: knot_types::AppviewEndpoint::new("https://tangled.test").unwrap(), 383 slots: knot_resource::Slots::testing(8), 384 lfs: Some(knot_xrpc::LfsWeb::new(lfs.clone(), 8)), 385 catalog: Arc::new(knot_messages::Catalog::defaults()), 386 }); 387 388 let resolver: Arc<dyn knot_pack::RepoResolver> = { 389 let index = Arc::clone(&index); 390 Arc::new(move |target: &knot_pack::RepoTarget| match target { 391 knot_pack::RepoTarget::Did(did) => match index.owner_of(did) { 392 knot_index::Resolved::Ready(Some(_)) => knot_pack::RepoLookup::Hosted(did.clone()), 393 knot_index::Resolved::Ready(None) => knot_pack::RepoLookup::Unhosted, 394 knot_index::Resolved::Warming => knot_pack::RepoLookup::Unavailable, 395 }, 396 knot_pack::RepoTarget::OwnerPath(owner, path) => { 397 match index.resolve_clone_path(owner, path) { 398 knot_index::Resolved::Ready(Some(found)) => { 399 knot_pack::RepoLookup::Hosted(found) 400 } 401 knot_index::Resolved::Ready(None) => knot_pack::RepoLookup::Unhosted, 402 knot_index::Resolved::Warming => knot_pack::RepoLookup::Unavailable, 403 } 404 } 405 }) 406 }; 407 let advertiser = knot_xrpc::receive_advertiser(Arc::clone(&xrpc_state)); 408 let (write_routes, advertisement) = knot_pack::edge_routes( 409 layout.clone(), 410 Arc::clone(&resolver), 411 Some(Arc::clone(&advertiser)), 412 None, 413 knot_resource::PackSlots::new(4), 414 knot_pack::CacheConfig::default(), 415 Arc::new(knot_messages::Catalog::defaults()), 416 knot_pack::default_hostname().clone(), 417 Arc::new(knot_runtime::SystemClock), 418 ); 419 let router = write_routes 420 .merge(advertisement.into_router()) 421 .merge(knot_xrpc::router(Arc::clone(&xrpc_state))); 422 let served = router.clone(); 423 tokio::spawn(async move { 424 let _ = axum::serve(http_listener, served).await; 425 }); 426 427 let (h3, certdir) = match with_h3 { 428 true => { 429 let certdir = tempfile::tempdir().unwrap(); 430 let edge = common::serve_edge(certdir.path(), || { 431 let (write_routes, advertisement) = knot_pack::edge_routes( 432 layout.clone(), 433 Arc::clone(&resolver), 434 Some(Arc::clone(&advertiser)), 435 None, 436 knot_resource::PackSlots::new(4), 437 knot_pack::CacheConfig::default(), 438 Arc::new(knot_messages::Catalog::defaults()), 439 knot_pack::default_hostname().clone(), 440 Arc::new(knot_runtime::SystemClock), 441 ); 442 let app = RequiresFullHandshake::new( 443 write_routes.merge(knot_xrpc::router(Arc::clone(&xrpc_state))), 444 ); 445 (app, advertisement) 446 }) 447 .await; 448 (Some(edge), Some(certdir)) 449 } 450 false => (None, None), 451 }; 452 453 World { 454 _scan: scan, 455 lfs, 456 ssh_port, 457 http_base, 458 router, 459 layout, 460 h3, 461 _certdir: certdir, 462 } 463} 464 465async fn in_git_blocking<T: Send + 'static>(task: impl FnOnce() -> T + Send + 'static) -> T { 466 tokio::task::spawn_blocking(task).await.unwrap() 467} 468 469fn seed_lfs_work(work: &Path, env: &[(String, String)], media: &[u8]) { 470 std::fs::create_dir_all(work).unwrap(); 471 let steps: [&[&str]; 2] = [ 472 &["init", "-q", "-b", "main"], 473 &["lfs", "install", "--local"], 474 ]; 475 steps.iter().for_each(|args| { 476 let (ok, out) = git(work, env, args); 477 assert!(ok, "{args:?} failed:\n{out}"); 478 }); 479 let (ok, out) = git(work, env, &["lfs", "track", "*.bin"]); 480 assert!(ok, "lfs track failed:\n{out}"); 481 let (ok, out) = git(work, env, &["config", "lfs.locksverify", "false"]); 482 assert!(ok, "config failed:\n{out}"); 483 std::fs::write(work.join("media.bin"), media).unwrap(); 484 std::fs::write(work.join("README.md"), "media lives in lfs\n").unwrap(); 485 let commit: [&[&str]; 2] = [&["add", "-A"], &["commit", "-q", "-m", "media"]]; 486 commit.iter().for_each(|args| { 487 let (ok, out) = git(work, env, args); 488 assert!(ok, "{args:?} failed:\n{out}"); 489 }); 490} 491 492fn clone_and_pull(base: &Path, url: &str, name: &str, env: &[(String, String)]) -> PathBuf { 493 let skip_smudge: Vec<(String, String)> = env 494 .iter() 495 .cloned() 496 .chain([("GIT_LFS_SKIP_SMUDGE".to_string(), "1".to_string())]) 497 .collect(); 498 let (ok, out) = git(base, &skip_smudge, &["clone", "-q", url, name]); 499 assert!(ok, "anonymous clone of {url} failed:\n{out}"); 500 let dst = base.join(name); 501 let pointer = std::fs::read_to_string(dst.join("media.bin")).unwrap(); 502 assert!( 503 pointer.contains("git-lfs.github.com/spec/v1"), 504 "clone must land the pointer before lfs pull, got:\n{pointer}" 505 ); 506 let (ok, out) = git(&dst, env, &["lfs", "install", "--local"]); 507 assert!(ok, "lfs install in {name} failed:\n{out}"); 508 let (ok, out) = git(&dst, env, &["lfs", "pull"]); 509 assert!(ok, "git lfs pull in {name} failed:\n{out}"); 510 dst 511} 512 513async fn create_fork(world: &World, jti: &str) -> (http::StatusCode, serde_json::Value) { 514 let token = service_jwt("sh.tangled.repo.create", jti); 515 let body = serde_json::json!({ 516 "rkey": FORK_NAME, 517 "name": FORK_NAME, 518 "source": format!("{}/{OWNER_DID}/{REPO_NAME}", world.http_base), 519 }); 520 let request = http::Request::builder() 521 .method("POST") 522 .uri("/xrpc/sh.tangled.repo.create") 523 .header(http::header::CONTENT_TYPE, "application/json") 524 .header(http::header::AUTHORIZATION, format!("Bearer {token}")) 525 .body(axum::body::Body::from(serde_json::to_vec(&body).unwrap())) 526 .unwrap(); 527 let response = world.router.clone().oneshot(request).await.unwrap(); 528 let status = response.status(); 529 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) 530 .await 531 .unwrap(); 532 let value = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); 533 (status, value) 534} 535 536#[tokio::test(flavor = "multi_thread", worker_threads = 4)] 537async fn the_lfs_round_trip_gate_holds_over_both_transports_and_the_fork() { 538 if !require_git_lfs() { 539 return; 540 } 541 let scratch = tempfile::tempdir().unwrap(); 542 let (key_path, public_line) = keygen(scratch.path()); 543 let world = spawn_world(public_line).await; 544 545 let media = media_bytes(); 546 let media_oid = LfsOid::from_digest(Sha256::digest(&media).into()); 547 let ssh = format!( 548 "ssh -i {key_path} -o IdentitiesOnly=yes -o StrictHostKeyChecking=no \ 549 -o UserKnownHostsFile=/dev/null -o PreferredAuthentications=publickey -o BatchMode=yes" 550 ); 551 let path_env = std::env::var("PATH").unwrap_or_default(); 552 let home = scratch.path().to_str().unwrap().to_string(); 553 let env: Vec<(String, String)> = [ 554 ("GIT_SSH_COMMAND", &ssh), 555 ("PATH", &path_env), 556 ("HOME", &home), 557 ] 558 .map(|(key, value)| (key.to_string(), value.clone())) 559 .to_vec(); 560 561 let work = scratch.path().join("work"); 562 seed_lfs_work(&work, &env, &media); 563 564 let push_url = format!( 565 "ssh://git@127.0.0.1:{}/{OWNER_DID}/{REPO_NAME}", 566 world.ssh_port 567 ); 568 let (ok, out) = { 569 let work = work.clone(); 570 let env = env.clone(); 571 in_git_blocking(move || git(&work, &env, &["push", "-q", &push_url, "main"])).await 572 }; 573 assert!(ok, "lfs push over ssh failed:\n{out}"); 574 575 let source_repo = RepoDid::new(REPO_DID).unwrap(); 576 assert_eq!( 577 world.lfs.store.probe(&source_repo, &media_oid).unwrap(), 578 Some(LfsSize::new(media.len() as u64)), 579 "pushed media must be durable in the store" 580 ); 581 582 let clone_url = format!("{}/{OWNER_DID}/{REPO_NAME}", world.http_base); 583 let dst = { 584 let base = scratch.path().to_path_buf(); 585 let env = env.clone(); 586 in_git_blocking(move || clone_and_pull(&base, &clone_url, "reader", &env)).await 587 }; 588 assert_eq!( 589 std::fs::read(dst.join("media.bin")).unwrap(), 590 media, 591 "anonymous http reader must see byte-identical media" 592 ); 593 594 let (status, created) = create_fork(&world, "gate-fork-1").await; 595 assert_eq!( 596 status, 597 http::StatusCode::OK, 598 "fork create failed: {created}" 599 ); 600 assert!( 601 created.get("lfsMissing").is_none(), 602 "local fork must copy every object, got {created}" 603 ); 604 let fork_did = RepoDid::new(created["repoDid"].as_str().unwrap()).unwrap(); 605 assert_eq!( 606 world.lfs.store.probe(&fork_did, &media_oid).unwrap(), 607 Some(LfsSize::new(media.len() as u64)), 608 "fork prefix must hold its own copy of the media" 609 ); 610 611 let fork_url = format!("{}/{OWNER_DID}/{FORK_NAME}", world.http_base); 612 let fork_dst = { 613 let base = scratch.path().to_path_buf(); 614 let env = env.clone(); 615 in_git_blocking(move || clone_and_pull(&base, &fork_url, "fork-reader", &env)).await 616 }; 617 assert_eq!( 618 std::fs::read(fork_dst.join("media.bin")).unwrap(), 619 media, 620 "anonymous clone of the fork must see byte-identical media" 621 ); 622} 623 624fn seed_many_lfs(work: &Path, env: &[(String, String)], count: usize) { 625 std::fs::create_dir_all(work).unwrap(); 626 let steps: [&[&str]; 2] = [ 627 &["init", "-q", "-b", "main"], 628 &["lfs", "install", "--local"], 629 ]; 630 steps.iter().for_each(|args| { 631 let (ok, out) = git(work, env, args); 632 assert!(ok, "{args:?} failed:\n{out}"); 633 }); 634 let (ok, out) = git(work, env, &["lfs", "track", "*.bin"]); 635 assert!(ok, "lfs track failed:\n{out}"); 636 let (ok, out) = git(work, env, &["config", "lfs.locksverify", "false"]); 637 assert!(ok, "config failed:\n{out}"); 638 (0..count).for_each(|index| { 639 let size = 200 + index * 7; 640 let bytes: Vec<u8> = (0..size) 641 .map(|n| (n.wrapping_mul(31).wrapping_add(index) % 251) as u8) 642 .collect(); 643 std::fs::write(work.join(format!("object-{index}.bin")), bytes).unwrap(); 644 }); 645 let commit: [&[&str]; 2] = [&["add", "-A"], &["commit", "-q", "-m", "many media"]]; 646 commit.iter().for_each(|args| { 647 let (ok, out) = git(work, env, args); 648 assert!(ok, "{args:?} failed:\n{out}"); 649 }); 650} 651 652#[tokio::test(flavor = "multi_thread", worker_threads = 4)] 653async fn many_objects_ride_default_git_lfs_concurrency_over_both_transports() { 654 if !require_git_lfs() { 655 return; 656 } 657 let scratch = tempfile::tempdir().unwrap(); 658 let (key_path, public_line) = keygen(scratch.path()); 659 let world = spawn_world(public_line).await; 660 661 let ssh = format!( 662 "ssh -i {key_path} -o IdentitiesOnly=yes -o StrictHostKeyChecking=no \ 663 -o UserKnownHostsFile=/dev/null -o PreferredAuthentications=publickey -o BatchMode=yes" 664 ); 665 let path_env = std::env::var("PATH").unwrap_or_default(); 666 let home = scratch.path().to_str().unwrap().to_string(); 667 let env: Vec<(String, String)> = [ 668 ("GIT_SSH_COMMAND", &ssh), 669 ("PATH", &path_env), 670 ("HOME", &home), 671 ] 672 .map(|(key, value)| (key.to_string(), value.clone())) 673 .to_vec(); 674 675 let count = 25usize; 676 let work = scratch.path().join("work"); 677 seed_many_lfs(&work, &env, count); 678 679 let push_url = format!( 680 "ssh://git@127.0.0.1:{}/{OWNER_DID}/{REPO_NAME}", 681 world.ssh_port 682 ); 683 let (ok, out) = { 684 let work = work.clone(); 685 let env = env.clone(); 686 in_git_blocking(move || git(&work, &env, &["push", "-q", &push_url, "main"])).await 687 }; 688 assert!( 689 ok, 690 "git-lfs at its default concurrency must push {count} objects over ssh without tripping \ 691 the per-peer connection limit:\n{out}" 692 ); 693 694 let clone_url = format!("{}/{OWNER_DID}/{REPO_NAME}", world.http_base); 695 let dst = { 696 let base = scratch.path().to_path_buf(); 697 let env = env.clone(); 698 in_git_blocking(move || { 699 let skip_smudge: Vec<(String, String)> = env 700 .iter() 701 .cloned() 702 .chain([("GIT_LFS_SKIP_SMUDGE".to_string(), "1".to_string())]) 703 .collect(); 704 let (ok, out) = git(&base, &skip_smudge, &["clone", "-q", &clone_url, "reader"]); 705 assert!(ok, "anonymous clone failed:\n{out}"); 706 let dst = base.join("reader"); 707 let (ok, out) = git(&dst, &env, &["lfs", "install", "--local"]); 708 assert!(ok, "lfs install failed:\n{out}"); 709 let (ok, out) = git(&dst, &env, &["lfs", "pull"]); 710 assert!( 711 ok, 712 "anonymous http pull of {count} objects mustn't be throttled by the xrpc \ 713 pre-auth limiter:\n{out}" 714 ); 715 dst 716 }) 717 .await 718 }; 719 (0..count).for_each(|index| { 720 assert_eq!( 721 std::fs::read(work.join(format!("object-{index}.bin"))).unwrap(), 722 std::fs::read(dst.join(format!("object-{index}.bin"))).unwrap(), 723 "object-{index}.bin must be byte-identical over anonymous http" 724 ); 725 }); 726} 727 728fn write_shim(dir: &Path) -> String { 729 let shim = dir.join("local-ssh.sh"); 730 std::fs::write( 731 &shim, 732 "#!/bin/sh\n\ 733 while [ \"$#\" -gt 0 ]; do\n\ 734 case \"$1\" in\n\ 735 -o|-p) shift 2 ;;\n\ 736 -*) shift ;;\n\ 737 *) break ;;\n\ 738 esac\n\ 739 done\n\ 740 shift\n\ 741 eval exec \"$@\"\n", 742 ) 743 .unwrap(); 744 let mut permissions = std::fs::metadata(&shim).unwrap().permissions(); 745 std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o755); 746 std::fs::set_permissions(&shim, permissions).unwrap(); 747 shim.to_str().unwrap().to_string() 748} 749 750fn hex_object_files(root: &Path) -> Vec<(String, u64, PathBuf)> { 751 let entries = match std::fs::read_dir(root) { 752 Ok(entries) => entries, 753 Err(_) => return Vec::new(), 754 }; 755 entries 756 .filter_map(Result::ok) 757 .flat_map(|entry| { 758 let path = entry.path(); 759 if path.is_dir() { 760 return hex_object_files(&path); 761 } 762 path.file_name() 763 .and_then(|name| name.to_str()) 764 .filter(|name| LfsOid::new(*name).is_ok()) 765 .map(|name| { 766 let size = std::fs::metadata(&path).map(|meta| meta.len()).unwrap_or(0); 767 vec![(name.to_string(), size, path.clone())] 768 }) 769 .unwrap_or_default() 770 }) 771 .collect() 772} 773 774fn pull_verdict(base: &Path, url: &str, name: &str, env: &[(String, String)]) -> (bool, String) { 775 let skip_smudge: Vec<(String, String)> = env 776 .iter() 777 .cloned() 778 .chain([("GIT_LFS_SKIP_SMUDGE".to_string(), "1".to_string())]) 779 .collect(); 780 let (ok, out) = git(base, &skip_smudge, &["clone", "-q", url, name]); 781 assert!(ok, "clone of {url} failed:\n{out}"); 782 let dst = base.join(name); 783 let (ok, out) = git(&dst, env, &["lfs", "install", "--local"]); 784 assert!(ok, "lfs install in {name} failed:\n{out}"); 785 git(&dst, env, &["lfs", "pull"]) 786} 787 788#[tokio::test(flavor = "multi_thread", worker_threads = 4)] 789async fn the_lfs_stack_is_conformant_with_the_reference_server_and_client() { 790 if !require_git_lfs() || !require_scutiger() { 791 return; 792 } 793 let scratch = tempfile::tempdir().unwrap(); 794 let (key_path, public_line) = keygen(scratch.path()); 795 let world = spawn_world(public_line).await; 796 797 let media = media_bytes(); 798 let second = second_media_bytes(); 799 let media_oid = LfsOid::from_digest(Sha256::digest(&media).into()); 800 let second_oid = LfsOid::from_digest(Sha256::digest(&second).into()); 801 let expected: std::collections::BTreeSet<(String, u64)> = [ 802 (media_oid.as_str().to_string(), media.len() as u64), 803 (second_oid.as_str().to_string(), second.len() as u64), 804 ] 805 .into(); 806 807 let ssh = format!( 808 "ssh -i {key_path} -o IdentitiesOnly=yes -o StrictHostKeyChecking=no \ 809 -o UserKnownHostsFile=/dev/null -o PreferredAuthentications=publickey -o BatchMode=yes" 810 ); 811 let path_env = std::env::var("PATH").unwrap_or_default(); 812 let home = scratch.path().to_str().unwrap().to_string(); 813 let knot_env: Vec<(String, String)> = [ 814 ("GIT_SSH_COMMAND", &ssh), 815 ("PATH", &path_env), 816 ("HOME", &home), 817 ] 818 .map(|(key, value)| (key.to_string(), value.clone())) 819 .to_vec(); 820 let shim = write_shim(scratch.path()); 821 let reference_env: Vec<(String, String)> = [ 822 ("GIT_SSH_COMMAND", &shim), 823 ("PATH", &path_env), 824 ("HOME", &home), 825 ] 826 .map(|(key, value)| (key.to_string(), value.clone())) 827 .to_vec(); 828 829 let upstream = scratch.path().join("reference-upstream.git"); 830 let (ok, out) = git( 831 scratch.path(), 832 &reference_env, 833 &[ 834 "init", 835 "-q", 836 "--bare", 837 "-b", 838 "main", 839 upstream.to_str().unwrap(), 840 ], 841 ); 842 assert!(ok, "reference upstream init failed:\n{out}"); 843 844 let work = scratch.path().join("work"); 845 seed_lfs_work(&work, &knot_env, &media); 846 std::fs::write(work.join("extra.bin"), &second).unwrap(); 847 let commit: [&[&str]; 2] = [&["add", "-A"], &["commit", "-q", "-m", "extra media"]]; 848 commit.iter().for_each(|args| { 849 let (ok, out) = git(&work, &knot_env, args); 850 assert!(ok, "{args:?} failed:\n{out}"); 851 }); 852 853 let knot_push_url = format!( 854 "ssh://git@127.0.0.1:{}/{OWNER_DID}/{REPO_NAME}", 855 world.ssh_port 856 ); 857 let reference_push_url = format!("ssh://ref@localhost{}", upstream.display()); 858 let pushes = { 859 let work = work.clone(); 860 let knot_env = knot_env.clone(); 861 let reference_env = reference_env.clone(); 862 let knot_push_url = knot_push_url.clone(); 863 let reference_push_url = reference_push_url.clone(); 864 in_git_blocking(move || { 865 [ 866 git(&work, &knot_env, &["push", "-q", &knot_push_url, "main"]), 867 git( 868 &work, 869 &reference_env, 870 &["push", "-q", &reference_push_url, "main"], 871 ), 872 ] 873 }) 874 .await 875 }; 876 pushes.iter().for_each(|(ok, out)| { 877 assert!(ok, "push failed:\n{out}"); 878 }); 879 880 let source_repo = RepoDid::new(REPO_DID).unwrap(); 881 let knot_objects: std::collections::BTreeSet<(String, u64)> = world 882 .lfs 883 .store 884 .enumerate(&source_repo) 885 .unwrap() 886 .into_iter() 887 .map(|object| (object.oid.as_str().to_string(), object.size.get())) 888 .collect(); 889 let reference_objects: std::collections::BTreeSet<(String, u64)> = hex_object_files(&upstream) 890 .into_iter() 891 .map(|(name, size, _)| (name, size)) 892 .collect(); 893 assert_eq!( 894 knot_objects, expected, 895 "the knot store holds exactly the pushed object set" 896 ); 897 assert_eq!( 898 knot_objects, reference_objects, 899 "both servers hold identical object sets after the same push" 900 ); 901 902 let knot_clone_url = format!("{}/{OWNER_DID}/{REPO_NAME}", world.http_base); 903 let (knot_dst, reference_dst) = { 904 let base = scratch.path().to_path_buf(); 905 let knot_env = knot_env.clone(); 906 let reference_env = reference_env.clone(); 907 let knot_clone_url = knot_clone_url.clone(); 908 let reference_push_url = reference_push_url.clone(); 909 in_git_blocking(move || { 910 ( 911 clone_and_pull(&base, &knot_clone_url, "knot-reader", &knot_env), 912 clone_and_pull( 913 &base, 914 &reference_push_url, 915 "reference-reader", 916 &reference_env, 917 ), 918 ) 919 }) 920 .await 921 }; 922 ["media.bin", "extra.bin"].iter().for_each(|file| { 923 assert_eq!( 924 std::fs::read(knot_dst.join(file)).unwrap(), 925 std::fs::read(reference_dst.join(file)).unwrap(), 926 "{file}: both servers must check out identical media" 927 ); 928 }); 929 assert_eq!(std::fs::read(knot_dst.join("media.bin")).unwrap(), media); 930 assert_eq!(std::fs::read(knot_dst.join("extra.bin")).unwrap(), second); 931 932 let knot_removed = world 933 .lfs 934 .store 935 .object_file(&source_repo, &second_oid) 936 .unwrap() 937 .unwrap() 938 .1; 939 std::fs::remove_file(knot_removed).unwrap(); 940 let removed = hex_object_files(&upstream) 941 .into_iter() 942 .filter(|(name, _, _)| name == second_oid.as_str()) 943 .map(|(_, _, path)| std::fs::remove_file(path).unwrap()) 944 .count(); 945 assert!( 946 removed > 0, 947 "the reference server holds the object to remove" 948 ); 949 950 let verdicts = { 951 let base = scratch.path().to_path_buf(); 952 let knot_env = knot_env.clone(); 953 let reference_env = reference_env.clone(); 954 let knot_push_url = knot_push_url.clone(); 955 in_git_blocking(move || { 956 [ 957 pull_verdict(&base, &knot_clone_url, "knot-missing-http", &knot_env), 958 pull_verdict(&base, &knot_push_url, "knot-missing-ssh", &knot_env), 959 pull_verdict( 960 &base, 961 &reference_push_url, 962 "reference-missing", 963 &reference_env, 964 ), 965 ] 966 }) 967 .await 968 }; 969 let [(http_ok, http_out), (ssh_ok, ssh_out), (reference_ok, _)] = verdicts; 970 assert!( 971 !http_ok, 972 "an http pull of a missing object must fail loudly, never succeed silently:\n{http_out}" 973 ); 974 assert!( 975 !ssh_ok, 976 "an ssh pull of a missing object must fail loudly, never succeed silently:\n{ssh_out}" 977 ); 978 assert!( 979 reference_ok, 980 "scutiger 0.3.0 answers noop for a missing download and the client silently \ 981 succeeds. This pin is the recorded reason knot answers download instead, so its \ 982 get-object 404 turns the pull into a loud failure. If the reference starts failing \ 983 loudly too, the divergence note can be retired." 984 ); 985} 986 987async fn lfs_batch( 988 world: &World, 989 auth: Option<&str>, 990 op: &str, 991 oid: &LfsOid, 992 size: u64, 993) -> (http::StatusCode, serde_json::Value) { 994 let body = serde_json::json!({ 995 "operation": op, 996 "transfers": ["basic"], 997 "objects": [{ "oid": oid.as_str(), "size": size }], 998 "hash_algo": "sha256", 999 }); 1000 let mut builder = http::Request::builder() 1001 .method("POST") 1002 .uri(format!("/{OWNER_DID}/{REPO_NAME}/info/lfs/objects/batch")) 1003 .header(http::header::CONTENT_TYPE, "application/vnd.git-lfs+json"); 1004 if let Some(auth) = auth { 1005 builder = builder.header(http::header::AUTHORIZATION, auth); 1006 } 1007 let request = builder 1008 .body(axum::body::Body::from(serde_json::to_vec(&body).unwrap())) 1009 .unwrap(); 1010 let response = world.router.clone().oneshot(request).await.unwrap(); 1011 let status = response.status(); 1012 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) 1013 .await 1014 .unwrap(); 1015 ( 1016 status, 1017 serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null), 1018 ) 1019} 1020 1021async fn lfs_put( 1022 world: &World, 1023 auth: Option<&str>, 1024 oid: &LfsOid, 1025 bytes: Vec<u8>, 1026) -> http::StatusCode { 1027 let mut builder = http::Request::builder() 1028 .method("PUT") 1029 .uri(format!( 1030 "/{OWNER_DID}/{REPO_NAME}/info/lfs/objects/{}", 1031 oid.as_str() 1032 )) 1033 .header(http::header::CONTENT_LENGTH, bytes.len()); 1034 if let Some(auth) = auth { 1035 builder = builder.header(http::header::AUTHORIZATION, auth); 1036 } 1037 let request = builder.body(axum::body::Body::from(bytes)).unwrap(); 1038 world 1039 .router 1040 .clone() 1041 .oneshot(request) 1042 .await 1043 .unwrap() 1044 .status() 1045} 1046 1047fn basic_auth(token: &str) -> String { 1048 let raw = base64::engine::general_purpose::STANDARD.encode(format!("x-tangled-token:{token}")); 1049 format!("Basic {raw}") 1050} 1051 1052#[tokio::test(flavor = "multi_thread", worker_threads = 4)] 1053async fn lfs_http_push_stores_an_object_with_a_push_token() { 1054 let scratch = tempfile::tempdir().unwrap(); 1055 let (_key_path, public_line) = keygen(scratch.path()); 1056 let world = spawn_world(public_line).await; 1057 let repo = RepoDid::new(REPO_DID).unwrap(); 1058 1059 let payload: Vec<u8> = (0..4096u32) 1060 .map(|n| (n.wrapping_mul(17) % 251) as u8) 1061 .collect(); 1062 let oid = LfsOid::from_digest(Sha256::digest(&payload).into()); 1063 let size = payload.len() as u64; 1064 1065 let bearer = format!( 1066 "Bearer {}", 1067 service_jwt("sh.tangled.repo.push", "lfs-http-push-1") 1068 ); 1069 let (status, body) = lfs_batch(&world, Some(&bearer), "upload", &oid, size).await; 1070 assert_eq!( 1071 status, 1072 http::StatusCode::OK, 1073 "authenticated upload batch: {body}" 1074 ); 1075 let href = body["objects"][0]["actions"]["upload"]["href"] 1076 .as_str() 1077 .unwrap_or_else(|| panic!("expected an upload action, got {body}")); 1078 assert_eq!( 1079 href, 1080 format!( 1081 "{}/{OWNER_DID}/{REPO_NAME}/info/lfs/objects/{}", 1082 world.http_base, 1083 oid.as_str() 1084 ), 1085 "upload href points at the object route on this knot" 1086 ); 1087 assert_ne!( 1088 body["objects"][0]["authenticated"], 1089 serde_json::Value::Bool(true), 1090 "upload objects mustn't claim authenticated=true, else git-lfs sends the object put with no auth and loops on 401: {body}" 1091 ); 1092 assert!( 1093 world.lfs.store.probe(&repo, &oid).unwrap().is_none(), 1094 "object must be absent before the put" 1095 ); 1096 1097 let put = lfs_put(&world, Some(&bearer), &oid, payload.clone()).await; 1098 assert_eq!( 1099 put, 1100 http::StatusCode::OK, 1101 "the same push token must authorize both the batch and the object put" 1102 ); 1103 assert_eq!( 1104 world.lfs.store.probe(&repo, &oid).unwrap(), 1105 Some(LfsSize::new(size)), 1106 "the put object must be durable in the store" 1107 ); 1108 1109 let get = http::Request::builder() 1110 .method("GET") 1111 .uri(format!( 1112 "/{OWNER_DID}/{REPO_NAME}/info/lfs/objects/{}", 1113 oid.as_str() 1114 )) 1115 .body(axum::body::Body::empty()) 1116 .unwrap(); 1117 let response = world.router.clone().oneshot(get).await.unwrap(); 1118 assert_eq!( 1119 response.status(), 1120 http::StatusCode::OK, 1121 "anonymous download" 1122 ); 1123 let served = axum::body::to_bytes(response.into_body(), usize::MAX) 1124 .await 1125 .unwrap(); 1126 assert_eq!( 1127 served.as_ref(), 1128 payload.as_slice(), 1129 "an anonymous reader sees the byte-identical object a push stored" 1130 ); 1131 1132 let payload2: Vec<u8> = (0..2048u32) 1133 .map(|n| (n.wrapping_mul(29) % 251) as u8) 1134 .collect(); 1135 let oid2 = LfsOid::from_digest(Sha256::digest(&payload2).into()); 1136 let basic = basic_auth(&service_jwt("sh.tangled.repo.push", "lfs-http-push-2")); 1137 let (status, body) = 1138 lfs_batch(&world, Some(&basic), "upload", &oid2, payload2.len() as u64).await; 1139 assert_eq!( 1140 status, 1141 http::StatusCode::OK, 1142 "basic-auth upload batch: {body}" 1143 ); 1144 let put = lfs_put(&world, Some(&basic), &oid2, payload2.clone()).await; 1145 assert_eq!( 1146 put, 1147 http::StatusCode::OK, 1148 "a push token presented as the http basic password must authenticate the put" 1149 ); 1150 assert_eq!( 1151 world.lfs.store.probe(&repo, &oid2).unwrap(), 1152 Some(LfsSize::new(payload2.len() as u64)), 1153 "the basic-authenticated object must be durable too" 1154 ); 1155} 1156 1157#[tokio::test(flavor = "multi_thread", worker_threads = 4)] 1158async fn lfs_http_push_rejects_missing_and_mismatched_credentials() { 1159 let scratch = tempfile::tempdir().unwrap(); 1160 let (_key_path, public_line) = keygen(scratch.path()); 1161 let world = spawn_world(public_line).await; 1162 1163 let payload: Vec<u8> = (0..1024u32) 1164 .map(|n| (n.wrapping_mul(13) % 251) as u8) 1165 .collect(); 1166 let oid = LfsOid::from_digest(Sha256::digest(&payload).into()); 1167 let size = payload.len() as u64; 1168 1169 let (status, _) = lfs_batch(&world, None, "upload", &oid, size).await; 1170 assert_eq!( 1171 status, 1172 http::StatusCode::UNAUTHORIZED, 1173 "an unauthenticated upload batch is challenged" 1174 ); 1175 1176 let wrong_method = format!( 1177 "Bearer {}", 1178 service_jwt("sh.tangled.repo.create", "lfs-http-neg-method") 1179 ); 1180 let (status, _) = lfs_batch(&world, Some(&wrong_method), "upload", &oid, size).await; 1181 assert_eq!( 1182 status, 1183 http::StatusCode::UNAUTHORIZED, 1184 "a token bound to another method cannot authorize a push" 1185 ); 1186 1187 let stranger = format!( 1188 "Bearer {}", 1189 service_jwt_as(REPO_DID, "sh.tangled.repo.push", "lfs-http-neg-acl") 1190 ); 1191 let (status, _) = lfs_batch(&world, Some(&stranger), "upload", &oid, size).await; 1192 assert_eq!( 1193 status, 1194 http::StatusCode::FORBIDDEN, 1195 "a valid push token from a did that cannot push is refused by the acl" 1196 ); 1197 1198 let put = lfs_put(&world, None, &oid, payload).await; 1199 assert_eq!( 1200 put, 1201 http::StatusCode::UNAUTHORIZED, 1202 "an unauthenticated object put is challenged" 1203 ); 1204 assert!( 1205 world 1206 .lfs 1207 .store 1208 .probe(&RepoDid::new(REPO_DID).unwrap(), &oid) 1209 .unwrap() 1210 .is_none(), 1211 "no rejected request may leave an object behind" 1212 ); 1213} 1214 1215#[tokio::test(flavor = "multi_thread", worker_threads = 4)] 1216async fn git_push_over_http_authenticates_and_lands_the_ref() { 1217 let scratch = tempfile::tempdir().unwrap(); 1218 let (_key_path, public_line) = keygen(scratch.path()); 1219 let world = spawn_world(public_line).await; 1220 1221 let path_env = std::env::var("PATH").unwrap_or_default(); 1222 let home = scratch.path().to_str().unwrap().to_string(); 1223 let env: Vec<(String, String)> = [("PATH", &path_env), ("HOME", &home)] 1224 .map(|(key, value)| (key.to_string(), value.clone())) 1225 .to_vec(); 1226 1227 let work = scratch.path().join("work"); 1228 std::fs::create_dir_all(&work).unwrap(); 1229 let (ok, out) = git(&work, &env, &["init", "-q", "-b", "main"]); 1230 assert!(ok, "init failed:\n{out}"); 1231 std::fs::write(work.join("README.md"), "hello over http\n").unwrap(); 1232 let (ok, out) = git(&work, &env, &["add", "-A"]); 1233 assert!(ok, "add failed:\n{out}"); 1234 let (ok, out) = git(&work, &env, &["commit", "-q", "-m", "init over http"]); 1235 assert!(ok, "commit failed:\n{out}"); 1236 1237 let url = format!("{}/{OWNER_DID}/{REPO_NAME}", world.http_base); 1238 1239 let (ok, out) = { 1240 let work = work.clone(); 1241 let env = env.clone(); 1242 let url = url.clone(); 1243 in_git_blocking(move || git(&work, &env, &["push", "-q", &url, "main"])).await 1244 }; 1245 assert!( 1246 !ok, 1247 "an unauthenticated http push must be refused, git reported success:\n{out}" 1248 ); 1249 1250 let token = service_jwt("sh.tangled.repo.push", "git-http-push-1"); 1251 let header = format!( 1252 "http.extraHeader=Authorization: Basic {}", 1253 base64::engine::general_purpose::STANDARD.encode(format!("x-tangled-token:{token}")) 1254 ); 1255 let (ok, out) = { 1256 let work = work.clone(); 1257 let env = env.clone(); 1258 let url = url.clone(); 1259 let header = header.clone(); 1260 in_git_blocking(move || git(&work, &env, &["-c", &header, "push", "-q", &url, "main"])) 1261 .await 1262 }; 1263 assert!(ok, "authenticated http push failed:\n{out}"); 1264 1265 let (ok, refs) = { 1266 let scratch = scratch.path().to_path_buf(); 1267 let env = env.clone(); 1268 let url = url.clone(); 1269 in_git_blocking(move || git(&scratch, &env, &["ls-remote", &url])).await 1270 }; 1271 assert!( 1272 ok && refs.contains("refs/heads/main"), 1273 "the pushed ref must be advertised to an anonymous reader:\n{refs}" 1274 ); 1275 1276 let wrong = format!( 1277 "http.extraHeader=Authorization: Basic {}", 1278 base64::engine::general_purpose::STANDARD.encode(format!( 1279 "x-tangled-token:{}", 1280 service_jwt("sh.tangled.repo.create", "git-http-push-neg") 1281 )) 1282 ); 1283 std::fs::write(work.join("README.md"), "second write\n").unwrap(); 1284 let (ok, out) = git(&work, &env, &["commit", "-q", "-am", "second"]); 1285 assert!(ok, "second commit failed:\n{out}"); 1286 let (ok, out) = { 1287 let work = work.clone(); 1288 let env = env.clone(); 1289 let url = url.clone(); 1290 let wrong = wrong.clone(); 1291 in_git_blocking(move || git(&work, &env, &["-c", &wrong, "push", "-q", &url, "main"])).await 1292 }; 1293 assert!( 1294 !ok, 1295 "a token bound to another method mustn't authorize a push:\n{out}" 1296 ); 1297} 1298 1299fn build_pack(work: &Path, env: &[(String, String)], tip: &str) -> Vec<u8> { 1300 let mut command = knot_fixtures::command(work); 1301 command 1302 .args(["pack-objects", "--revs", "--stdout", "--delta-base-offset"]) 1303 .stdin(Stdio::piped()) 1304 .stdout(Stdio::piped()) 1305 .stderr(Stdio::piped()); 1306 env.iter().for_each(|(key, value)| { 1307 command.env(key, value); 1308 }); 1309 let mut child = command.spawn().expect("git pack-objects spawns"); 1310 child 1311 .stdin 1312 .take() 1313 .unwrap() 1314 .write_all(format!("{tip}\n").as_bytes()) 1315 .unwrap(); 1316 let out = child.wait_with_output().unwrap(); 1317 assert!( 1318 out.status.success(), 1319 "git pack-objects failed: {}", 1320 String::from_utf8_lossy(&out.stderr) 1321 ); 1322 out.stdout 1323} 1324 1325fn build_receive_body(tip: &str, pack: &[u8]) -> Bytes { 1326 let mut command = format!("{} {tip} refs/heads/main", "0".repeat(tip.len())).into_bytes(); 1327 command.push(0); 1328 command.extend_from_slice(b"report-status side-band-64k agent=knot-h3-test/0"); 1329 command.push(b'\n'); 1330 let mut body = common::pkt(&command); 1331 body.extend_from_slice(b"0000"); 1332 body.extend_from_slice(pack); 1333 Bytes::from(body) 1334} 1335 1336#[tokio::test(flavor = "multi_thread", worker_threads = 4)] 1337async fn git_push_over_h3_authenticates_and_lands_the_ref() { 1338 let scratch = tempfile::tempdir().unwrap(); 1339 let (_key_path, public_line) = keygen(scratch.path()); 1340 let world = spawn(public_line, true).await; 1341 let edge = world.h3.as_ref().expect("the h3 edge is stood up"); 1342 1343 let path_env = std::env::var("PATH").unwrap_or_default(); 1344 let home = scratch.path().to_str().unwrap().to_string(); 1345 let env: Vec<(String, String)> = [("PATH", &path_env), ("HOME", &home)] 1346 .map(|(key, value)| (key.to_string(), value.clone())) 1347 .to_vec(); 1348 1349 let work = scratch.path().join("work"); 1350 std::fs::create_dir_all(&work).unwrap(); 1351 let (ok, out) = git(&work, &env, &["init", "-q", "-b", "main"]); 1352 assert!(ok, "init failed:\n{out}"); 1353 std::fs::write(work.join("README.md"), "hello over http3\n").unwrap(); 1354 let (ok, out) = git(&work, &env, &["add", "-A"]); 1355 assert!(ok, "add failed:\n{out}"); 1356 let (ok, out) = git(&work, &env, &["commit", "-q", "-m", "init over http3"]); 1357 assert!(ok, "commit failed:\n{out}"); 1358 let (ok, tip) = git(&work, &env, &["rev-parse", "HEAD"]); 1359 assert!(ok, "rev-parse failed:\n{tip}"); 1360 let tip = tip.trim().to_string(); 1361 let pack = build_pack(&work, &env, &tip); 1362 1363 let advert_uri = 1364 format!("https://localhost/{OWNER_DID}/{REPO_NAME}/info/refs?service=git-receive-pack"); 1365 let receive_uri = format!("https://localhost/{OWNER_DID}/{REPO_NAME}/git-receive-pack"); 1366 let warmup = 1367 format!("https://localhost/{OWNER_DID}/{REPO_NAME}/info/refs?service=git-upload-pack"); 1368 const RECEIVE_CT: &str = "application/x-git-receive-pack-request"; 1369 1370 let (status, _) = 1371 common::h3_request(edge, Method::GET, advert_uri.clone(), &[], None, None).await; 1372 assert_eq!( 1373 status, 1374 http::StatusCode::UNAUTHORIZED, 1375 "an unauthenticated receive advertisement must be challenged over h3" 1376 ); 1377 1378 let token = basic_auth(&service_jwt("sh.tangled.repo.push", "git-h3-adv-1")); 1379 let (status, advert) = common::h3_request( 1380 edge, 1381 Method::GET, 1382 advert_uri, 1383 &[("authorization", token.as_str())], 1384 None, 1385 None, 1386 ) 1387 .await; 1388 assert_eq!( 1389 status, 1390 http::StatusCode::OK, 1391 "an authenticated receive advertisement is served over h3" 1392 ); 1393 assert!( 1394 String::from_utf8_lossy(&advert).contains("# service=git-receive-pack"), 1395 "the h3 receive advertisement includes the service banner" 1396 ); 1397 1398 let body = build_receive_body(&tip, &pack); 1399 let (status, _) = common::h3_request( 1400 edge, 1401 Method::POST, 1402 receive_uri.clone(), 1403 &[("content-type", RECEIVE_CT)], 1404 Some(body.clone()), 1405 Some(warmup.as_str()), 1406 ) 1407 .await; 1408 assert_eq!( 1409 status, 1410 http::StatusCode::UNAUTHORIZED, 1411 "an unauthenticated receive-pack post must be challenged over h3" 1412 ); 1413 1414 let wrong = basic_auth(&service_jwt("sh.tangled.repo.create", "git-h3-neg")); 1415 let (status, _) = common::h3_request( 1416 edge, 1417 Method::POST, 1418 receive_uri.clone(), 1419 &[ 1420 ("content-type", RECEIVE_CT), 1421 ("authorization", wrong.as_str()), 1422 ], 1423 Some(body.clone()), 1424 Some(warmup.as_str()), 1425 ) 1426 .await; 1427 assert_eq!( 1428 status, 1429 http::StatusCode::UNAUTHORIZED, 1430 "a token bound to another method cannot authorize a receive-pack over h3" 1431 ); 1432 1433 let good = basic_auth(&service_jwt("sh.tangled.repo.push", "git-h3-push-1")); 1434 let (status, report) = common::h3_request( 1435 edge, 1436 Method::POST, 1437 receive_uri, 1438 &[ 1439 ("content-type", RECEIVE_CT), 1440 ("authorization", good.as_str()), 1441 ], 1442 Some(body), 1443 Some(warmup.as_str()), 1444 ) 1445 .await; 1446 assert_eq!(status, http::StatusCode::OK, "the authenticated h3 push"); 1447 let report = String::from_utf8_lossy(&report); 1448 assert!( 1449 report.contains("unpack ok"), 1450 "the pack must unpack cleanly over h3:\n{report}" 1451 ); 1452 assert!( 1453 report.contains("ok refs/heads/main"), 1454 "the ref update must be accepted over h3:\n{report}" 1455 ); 1456 1457 let repo = world.layout.open(&RepoDid::new(REPO_DID).unwrap()).unwrap(); 1458 let landed = repo.references().unwrap().into_iter().any(|record| { 1459 record.name.as_str() == "refs/heads/main" && record.target.to_string() == tip 1460 }); 1461 assert!( 1462 landed, 1463 "the ref pushed over h3 must be durable in the bare repo" 1464 ); 1465}