This repository has no description
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}