use std::sync::Arc; use std::time::Duration; use axum::body::{Body, to_bytes}; use bobbin_edge_index::{CoverageWatch, EdgeStore, StateIndex}; use bobbin_knot_proxy::{FailureThreshold, KnotHttpConfig, KnotProxy, KnotProxyConfig}; use bobbin_record_lru::{CacheCapacity, LruRecordStore}; use bobbin_resolver::RepoIdResolver; use bobbin_runtime::{RuntimeHasher, SystemClock}; use bobbin_search::{DEFAULT_WRITER_HEAP_BYTES, SearchIndex, SearchReader}; use bobbin_slingshot_client::SlingshotClient; use bobbin_xrpc::{AppState, router}; use http::{Request, StatusCode}; use jacquard_common::DefaultStr; use jacquard_common::types::did::Did; use jacquard_common::types::recordkey::Rkey; use serde_json::{Value, json}; use tower::ServiceExt; use url::Url; use url::form_urlencoded::byte_serialize; use wiremock::matchers::{header_exists, method, path, query_param}; use wiremock::{Mock, MockServer, ResponseTemplate}; const CID: &str = "bafyreieqygohnz2zqyvtvktbjpvhutphobcmbsnt4q5lc36ri7vpcmoz4i"; fn did(s: &str) -> Did { Did::new_owned(s).unwrap() } fn rkey(s: &str) -> Rkey { Rkey::new_owned(s).unwrap() } fn test_config() -> KnotProxyConfig { KnotProxyConfig { failure_threshold: FailureThreshold::new(2).unwrap(), cooldown: Duration::from_millis(80), allow_private_hosts: true, require_https: false, } } fn test_http_config() -> KnotHttpConfig { KnotHttpConfig { connect_timeout: Duration::from_millis(500), read_timeout: Duration::from_secs(2), } } struct Harness { slingshot: MockServer, knot: MockServer, state: AppState, } impl Harness { async fn new() -> Self { Self::with_config(test_config()).await } async fn with_config(config: KnotProxyConfig) -> Self { let slingshot_server = MockServer::start().await; let knot_server = MockServer::start().await; let state = AppState::new( Arc::new(LruRecordStore::new(CacheCapacity::from_bytes(64 * 1024))), SlingshotClient::with_default_http(Url::parse(&slingshot_server.uri()).unwrap()) .unwrap(), Arc::new(EdgeStore::new(RuntimeHasher::default())), Arc::new(StateIndex::new(RuntimeHasher::default())), Arc::new(StateIndex::new(RuntimeHasher::default())), Arc::new(CoverageWatch::new()), Arc::new( KnotProxy::new( config, test_http_config(), Arc::new(SystemClock::new()), RuntimeHasher::default(), ) .unwrap(), ), Arc::new( SearchIndex::new(DEFAULT_WRITER_HEAP_BYTES, Arc::new(SystemClock::new())).unwrap(), ) as Arc, Arc::new(RepoIdResolver::detached(RuntimeHasher::default())), ); Self { slingshot: slingshot_server, knot: knot_server, state, } } async fn mount_repo_record(&self, did: &Did, rkey: &Rkey, name: &str) { self.mount_repo_record_inner(did, rkey, Some(name)).await; } async fn mount_repo_record_rkey_as_name(&self, did: &Did, rkey: &Rkey) { self.mount_repo_record_inner(did, rkey, None).await; } async fn mount_repo_record_inner( &self, did: &Did, rkey: &Rkey, name: Option<&str>, ) { let knot_value = self.knot.uri(); let mut record = json!({ "$type": "sh.tangled.repo", "createdAt": "2026-05-01T00:00:00Z", "knot": knot_value, }); if let Some(n) = name { record["name"] = json!(n); } let uri = format!("at://{}/sh.tangled.repo/{}", did.as_ref(), rkey.as_ref()); Mock::given(method("GET")) .and(path("/xrpc/com.atproto.repo.getRecord")) .and(query_param("repo", did.as_ref())) .and(query_param("collection", "sh.tangled.repo")) .and(query_param("rkey", rkey.as_ref())) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "uri": uri, "cid": CID, "value": record, }))) .mount(&self.slingshot) .await; } async fn call(&self, path_and_query: &str) -> http::Response { self.call_with_headers(path_and_query, &[]).await } async fn call_with_headers( &self, path_and_query: &str, client_headers: &[(&str, &str)], ) -> http::Response { let builder = client_headers .iter() .fold(Request::builder().uri(path_and_query), |b, (k, v)| { b.header(*k, *v) }); router(self.state.clone()) .oneshot(builder.body(Body::empty()).unwrap()) .await .expect("router infallible") } } fn enc(s: &str) -> String { byte_serialize(s.as_bytes()).collect() } async fn body_string(resp: http::Response) -> String { let body = to_bytes(resp.into_body(), 64 * 1024).await.unwrap(); String::from_utf8(body.to_vec()).expect("response body is utf-8") } async fn body_value(resp: http::Response) -> Value { let s = body_string(resp).await; serde_json::from_str(&s).unwrap_or_else(|e| panic!("body not json: {e}: {s}")) } #[tokio::test] async fn proxies_repo_blob_with_did_slash_name_repo_param() { let h = Harness::new().await; let tid = "3jzfcijpj2z2a"; h.mount_repo_record(&did("did:plc:abalone"), &rkey(tid), "barnacle") .await; Mock::given(method("GET")) .and(path("/xrpc/sh.tangled.repo.blob")) .and(query_param("repo", "did:plc:abalone/barnacle")) .and(query_param("ref", "main")) .and(query_param("path", "README.md")) .respond_with( ResponseTemplate::new(200) .set_body_raw(r#"{"path":"README.md","content":"hi"}"#, "application/json"), ) .mount(&h.knot) .await; let target = format!( "/xrpc/sh.tangled.repo.blob?repo={}&ref=main&path=README.md", enc(&format!("at://did:plc:abalone/sh.tangled.repo/{tid}")), ); let resp = h.call(&target).await; assert_eq!(resp.status(), StatusCode::OK); assert_eq!( resp.headers().get("content-type").unwrap(), "application/json", ); let v = body_value(resp).await; assert_eq!(v["path"], "README.md"); assert_eq!(v["content"], "hi"); } #[tokio::test] async fn modern_rkey_as_name_uses_rkey_even_when_name_field_set() { let h = Harness::new().await; h.mount_repo_record(&did("did:plc:abalone"), &rkey("core"), "Tangled Core") .await; Mock::given(method("GET")) .and(path("/xrpc/sh.tangled.repo.getDefaultBranch")) .and(query_param("repo", "did:plc:abalone/core")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "hash": "abc", "name": "main", "when": "2026-05-01T00:00:00Z", }))) .mount(&h.knot) .await; let target = format!( "/xrpc/sh.tangled.repo.getDefaultBranch?repo={}", enc("at://did:plc:abalone/sh.tangled.repo/core"), ); let resp = h.call(&target).await; assert_eq!(resp.status(), StatusCode::OK); let v = body_value(resp).await; assert_eq!(v["name"], "main"); } #[tokio::test] async fn modern_rkey_as_name_works_when_name_field_null() { let h = Harness::new().await; h.mount_repo_record_rkey_as_name(&did("did:plc:abalone"), &rkey("core")) .await; Mock::given(method("GET")) .and(path("/xrpc/sh.tangled.repo.getDefaultBranch")) .and(query_param("repo", "did:plc:abalone/core")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "hash": "abc", "name": "main", "when": "2026-05-01T00:00:00Z", }))) .mount(&h.knot) .await; let target = format!( "/xrpc/sh.tangled.repo.getDefaultBranch?repo={}", enc("at://did:plc:abalone/sh.tangled.repo/core"), ); let resp = h.call(&target).await; assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] async fn legacy_tid_rkey_falls_back_to_name_field() { let h = Harness::new().await; let tid_rkey = "3jzfcijpj2z2a"; h.mount_repo_record(&did("did:plc:abalone"), &rkey(tid_rkey), "dotfiles") .await; Mock::given(method("GET")) .and(path("/xrpc/sh.tangled.repo.getDefaultBranch")) .and(query_param("repo", "did:plc:abalone/dotfiles")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "hash": "abc", "name": "main", "when": "2026-05-01T00:00:00Z", }))) .mount(&h.knot) .await; let target = format!( "/xrpc/sh.tangled.repo.getDefaultBranch?repo={}", enc(&format!("at://did:plc:abalone/sh.tangled.repo/{tid_rkey}")), ); let resp = h.call(&target).await; assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] async fn tid_rkey_without_name_falls_back_to_tid() { let h = Harness::new().await; let tid_rkey = "3jzfcijpj2z2a"; h.mount_repo_record_rkey_as_name(&did("did:plc:abalone"), &rkey(tid_rkey)) .await; Mock::given(method("GET")) .and(path("/xrpc/sh.tangled.repo.getDefaultBranch")) .and(query_param("repo", format!("did:plc:abalone/{tid_rkey}"))) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "hash": "abc", "name": "main", "when": "2026-05-01T00:00:00Z", }))) .mount(&h.knot) .await; let target = format!( "/xrpc/sh.tangled.repo.getDefaultBranch?repo={}", enc(&format!("at://did:plc:abalone/sh.tangled.repo/{tid_rkey}")), ); let resp = h.call(&target).await; assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] async fn streams_binary_archive_through_proxy() { let h = Harness::new().await; let tid = "3jzfcijpj2z2b"; h.mount_repo_record(&did("did:plc:limpet"), &rkey(tid), "kelp") .await; let payload: Vec = (0u8..=255).collect(); Mock::given(method("GET")) .and(path("/xrpc/sh.tangled.repo.archive")) .and(query_param("repo", "did:plc:limpet/kelp")) .and(query_param("ref", "v1")) .respond_with( ResponseTemplate::new(200) .insert_header("content-type", "application/gzip") .set_body_bytes(payload.clone()), ) .mount(&h.knot) .await; let target = format!( "/xrpc/sh.tangled.repo.archive?repo={}&ref=v1", enc(&format!("at://did:plc:limpet/sh.tangled.repo/{tid}")), ); let resp = h.call(&target).await; assert_eq!(resp.status(), StatusCode::OK); assert_eq!( resp.headers().get("content-type").unwrap(), "application/gzip", ); let body = to_bytes(resp.into_body(), 4 * 1024).await.unwrap(); assert_eq!(body.as_ref(), payload.as_slice()); } #[tokio::test] async fn missing_repo_param_returns_400() { let h = Harness::new().await; let resp = h.call("/xrpc/sh.tangled.repo.blob?ref=main").await; assert_eq!(resp.status(), StatusCode::BAD_REQUEST); let v = body_value(resp).await; assert_eq!(v["error"], "InvalidRequest"); } #[tokio::test] async fn unknown_repo_propagates_404_from_slingshot() { let h = Harness::new().await; Mock::given(method("GET")) .and(path("/xrpc/com.atproto.repo.getRecord")) .respond_with(ResponseTemplate::new(404).set_body_string("not found")) .mount(&h.slingshot) .await; let target = format!( "/xrpc/sh.tangled.repo.blob?repo={}&ref=main", enc("at://did:plc:abalone/sh.tangled.repo/missing"), ); let resp = h.call(&target).await; assert_eq!(resp.status(), StatusCode::NOT_FOUND); let v = body_value(resp).await; assert_eq!(v["error"], "RecordNotFound"); } #[tokio::test] async fn knot_5xx_routes_to_upstream_failed() { let h = Harness::new().await; h.mount_repo_record(&did("did:plc:abalone"), &rkey("r1"), "barnacle") .await; Mock::given(method("GET")) .and(path("/xrpc/sh.tangled.repo.blob")) .respond_with(ResponseTemplate::new(503)) .mount(&h.knot) .await; let target = format!( "/xrpc/sh.tangled.repo.blob?repo={}&ref=main&path=x", enc("at://did:plc:abalone/sh.tangled.repo/r1"), ); let resp = h.call(&target).await; assert_eq!(resp.status(), StatusCode::BAD_GATEWAY); let v = body_value(resp).await; assert_eq!(v["error"], "UpstreamFailed"); } #[tokio::test] async fn knot_4xx_passes_through_unchanged() { let h = Harness::new().await; h.mount_repo_record(&did("did:plc:abalone"), &rkey("r1"), "barnacle") .await; Mock::given(method("GET")) .and(path("/xrpc/sh.tangled.repo.blob")) .respond_with(ResponseTemplate::new(404).set_body_raw( r#"{"error":"FileNotFound","message":"nope"}"#, "application/json", )) .mount(&h.knot) .await; let target = format!( "/xrpc/sh.tangled.repo.blob?repo={}&ref=main&path=missing", enc("at://did:plc:abalone/sh.tangled.repo/r1"), ); let resp = h.call(&target).await; assert_eq!(resp.status(), StatusCode::NOT_FOUND); let v = body_value(resp).await; assert_eq!(v["error"], "FileNotFound"); } #[tokio::test] async fn breaker_opens_after_threshold_then_short_circuits() { let h = Harness::new().await; h.mount_repo_record(&did("did:plc:abalone"), &rkey("r1"), "barnacle") .await; Mock::given(method("GET")) .and(path("/xrpc/sh.tangled.repo.blob")) .respond_with(ResponseTemplate::new(503)) .mount(&h.knot) .await; let target = format!( "/xrpc/sh.tangled.repo.blob?repo={}&ref=main&path=x", enc("at://did:plc:abalone/sh.tangled.repo/r1"), ); let r1 = h.call(&target).await; assert_eq!(r1.status(), StatusCode::BAD_GATEWAY); let _ = body_string(r1).await; let r2 = h.call(&target).await; assert_eq!(r2.status(), StatusCode::BAD_GATEWAY); let _ = body_string(r2).await; let r3 = h.call(&target).await; assert_eq!(r3.status(), StatusCode::BAD_GATEWAY); let v = body_value(r3).await; assert!( v["message"] .as_str() .unwrap_or_default() .contains("circuit breaker open"), "third call must be short-circuited by breaker, got {v}", ); } #[tokio::test] async fn proxy_owner_uses_knot_query_param() { let h = Harness::new().await; Mock::given(method("GET")) .and(path("/xrpc/sh.tangled.owner")) .respond_with( ResponseTemplate::new(200) .set_body_raw(r#"{"owner":"did:plc:nautilus"}"#, "application/json"), ) .mount(&h.knot) .await; let target = format!("/xrpc/sh.tangled.owner?knot={}", enc(&h.knot.uri())); let resp = h.call(&target).await; assert_eq!(resp.status(), StatusCode::OK); let v = body_value(resp).await; assert_eq!(v["owner"], "did:plc:nautilus"); } #[tokio::test] async fn proxy_knot_version_uses_knot_query_param() { let h = Harness::new().await; Mock::given(method("GET")) .and(path("/xrpc/sh.tangled.knot.version")) .respond_with( ResponseTemplate::new(200).set_body_raw(r#"{"version":"0.42"}"#, "application/json"), ) .mount(&h.knot) .await; let target = format!("/xrpc/sh.tangled.knot.version?knot={}", enc(&h.knot.uri())); let resp = h.call(&target).await; assert_eq!(resp.status(), StatusCode::OK); let v = body_value(resp).await; assert_eq!(v["version"], "0.42"); } #[tokio::test] async fn proxy_knot_list_keys_forwards_pagination_params() { let h = Harness::new().await; Mock::given(method("GET")) .and(path("/xrpc/sh.tangled.knot.listKeys")) .and(query_param("limit", "5")) .and(query_param("cursor", "abc")) .respond_with(ResponseTemplate::new(200).set_body_raw(r#"{"keys":[]}"#, "application/json")) .mount(&h.knot) .await; let target = format!( "/xrpc/sh.tangled.knot.listKeys?knot={}&limit=5&cursor=abc", enc(&h.knot.uri()), ); let resp = h.call(&target).await; assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] async fn missing_knot_param_on_knot_route_returns_400() { let h = Harness::new().await; let resp = h.call("/xrpc/sh.tangled.knot.version").await; assert_eq!(resp.status(), StatusCode::BAD_REQUEST); let v = body_value(resp).await; assert_eq!(v["error"], "InvalidRequest"); } #[tokio::test] async fn second_proxy_call_skips_slingshot_via_lru() { let h = Harness::new().await; let tid = "3jzfcijpj2z2c"; h.mount_repo_record(&did("did:plc:abalone"), &rkey(tid), "barnacle") .await; Mock::given(method("GET")) .and(path("/xrpc/sh.tangled.repo.tree")) .and(query_param("repo", "did:plc:abalone/barnacle")) .and(query_param("ref", "main")) .respond_with( ResponseTemplate::new(200) .set_body_raw(r#"{"ref":"main","files":[]}"#, "application/json"), ) .mount(&h.knot) .await; let target = format!( "/xrpc/sh.tangled.repo.tree?repo={}&ref=main", enc(&format!("at://did:plc:abalone/sh.tangled.repo/{tid}")), ); let r1 = h.call(&target).await; assert_eq!(r1.status(), StatusCode::OK); let _ = body_string(r1).await; let r2 = h.call(&target).await; assert_eq!(r2.status(), StatusCode::OK); let _ = body_string(r2).await; let received = h.slingshot.received_requests().await.unwrap(); let getrecord = received .iter() .filter(|r| r.url.path() == "/xrpc/com.atproto.repo.getRecord") .count(); assert_eq!( getrecord, 1, "slingshot must be hit exactly once because the LRU serves the second proxy call", ); } #[tokio::test] async fn does_not_inject_auth_or_atproto_proxy_headers() { let h = Harness::new().await; h.mount_repo_record(&did("did:plc:abalone"), &rkey("r1"), "barnacle") .await; Mock::given(method("GET")) .and(path("/xrpc/sh.tangled.repo.blob")) .and(header_exists("user-agent")) .respond_with( ResponseTemplate::new(200).set_body_raw(r#"{"path":"x"}"#, "application/json"), ) .mount(&h.knot) .await; let target = format!( "/xrpc/sh.tangled.repo.blob?repo={}&ref=main&path=x", enc("at://did:plc:abalone/sh.tangled.repo/r1"), ); let resp = h.call(&target).await; assert_eq!(resp.status(), StatusCode::OK); let received = h.knot.received_requests().await.unwrap(); let knot_call = received .iter() .find(|r| r.url.path() == "/xrpc/sh.tangled.repo.blob") .expect("knot received the proxied call"); assert!( knot_call.headers.get("authorization").is_none(), "bobbin must not inject auth, anonymous read by design", ); assert!( knot_call.headers.get("atproto-proxy").is_none(), "bobbin is not an atproto-proxy chain", ); assert!( knot_call.headers.get("atproto-accept-labelers").is_none(), "bobbin does not negotiate labelers with knots", ); } #[tokio::test] async fn forwards_range_and_conditional_request_headers() { let h = Harness::new().await; let tid = "3jzfcijpj2z2d"; h.mount_repo_record(&did("did:plc:limpet"), &rkey(tid), "kelp") .await; Mock::given(method("GET")) .and(path("/xrpc/sh.tangled.repo.archive")) .and(query_param("repo", "did:plc:limpet/kelp")) .respond_with( ResponseTemplate::new(206) .insert_header("content-type", "application/octet-stream") .insert_header("content-range", "bytes 0-99/2048") .insert_header("accept-ranges", "bytes") .insert_header("etag", "\"v1\"") .set_body_bytes(vec![0u8; 100]), ) .mount(&h.knot) .await; let target = format!( "/xrpc/sh.tangled.repo.archive?repo={}&ref=v1", enc(&format!("at://did:plc:limpet/sh.tangled.repo/{tid}")), ); let resp = h .call_with_headers( &target, &[ ("range", "bytes=0-99"), ("if-none-match", "\"old\""), ("if-modified-since", "Wed, 01 May 2026 00:00:00 GMT"), ], ) .await; assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT); assert_eq!( resp.headers().get("content-range").unwrap(), "bytes 0-99/2048" ); assert_eq!(resp.headers().get("accept-ranges").unwrap(), "bytes"); assert_eq!(resp.headers().get("etag").unwrap(), "\"v1\""); let received = h.knot.received_requests().await.unwrap(); let knot_call = received .iter() .find(|r| r.url.path() == "/xrpc/sh.tangled.repo.archive") .expect("knot received the proxied call"); assert_eq!(knot_call.headers.get("range").unwrap(), "bytes=0-99"); assert_eq!(knot_call.headers.get("if-none-match").unwrap(), "\"old\""); assert_eq!( knot_call.headers.get("if-modified-since").unwrap(), "Wed, 01 May 2026 00:00:00 GMT", ); } #[tokio::test] async fn drops_disallowed_client_headers() { let h = Harness::new().await; h.mount_repo_record(&did("did:plc:abalone"), &rkey("r1"), "barnacle") .await; Mock::given(method("GET")) .and(path("/xrpc/sh.tangled.repo.blob")) .respond_with( ResponseTemplate::new(200).set_body_raw(r#"{"path":"x"}"#, "application/json"), ) .mount(&h.knot) .await; let target = format!( "/xrpc/sh.tangled.repo.blob?repo={}&path=x", enc("at://did:plc:abalone/sh.tangled.repo/r1"), ); let resp = h .call_with_headers( &target, &[ ("authorization", "Bearer secret"), ("cookie", "sid=evil"), ("x-custom", "should-not-pass"), ], ) .await; assert_eq!(resp.status(), StatusCode::OK); let received = h.knot.received_requests().await.unwrap(); let knot_call = received .iter() .find(|r| r.url.path() == "/xrpc/sh.tangled.repo.blob") .expect("knot received the proxied call"); assert!(knot_call.headers.get("authorization").is_none()); assert!(knot_call.headers.get("cookie").is_none()); assert!(knot_call.headers.get("x-custom").is_none()); } #[tokio::test] async fn rejects_client_supplied_loopback_under_strict_config() { let strict = KnotProxyConfig { allow_private_hosts: false, ..test_config() }; let h = Harness::with_config(strict).await; let resp = h .call(&format!( "/xrpc/sh.tangled.knot.version?knot={}", enc("http://127.0.0.1:9"), )) .await; assert_eq!(resp.status(), StatusCode::BAD_REQUEST); let v = body_value(resp).await; assert_eq!(v["error"], "InvalidRequest"); let msg = v["message"].as_str().unwrap_or_default().to_owned(); assert!( msg.contains("loopback") || msg.contains("blocked"), "message should explain block reason, got {msg}", ); } #[tokio::test] async fn rejects_client_supplied_link_local_metadata_endpoint() { let strict = KnotProxyConfig { allow_private_hosts: false, ..test_config() }; let h = Harness::with_config(strict).await; let resp = h .call(&format!( "/xrpc/sh.tangled.knot.version?knot={}", enc("http://169.254.169.254"), )) .await; assert_eq!(resp.status(), StatusCode::BAD_REQUEST); let v = body_value(resp).await; assert_eq!(v["error"], "InvalidRequest"); } #[tokio::test] async fn record_with_private_knot_returns_invalid_record() { let strict = KnotProxyConfig { allow_private_hosts: false, ..test_config() }; let h = Harness::with_config(strict).await; let owner = did("did:plc:abalone"); let rk = rkey("r1"); let record = json!({ "$type": "sh.tangled.repo", "createdAt": "2026-05-01T00:00:00Z", "knot": "http://10.0.0.5:3000", "name": "barnacle", }); let uri = format!("at://{}/sh.tangled.repo/{}", owner.as_ref(), rk.as_ref()); Mock::given(method("GET")) .and(path("/xrpc/com.atproto.repo.getRecord")) .and(query_param("repo", owner.as_ref())) .and(query_param("collection", "sh.tangled.repo")) .and(query_param("rkey", rk.as_ref())) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "uri": uri, "cid": CID, "value": record, }))) .mount(&h.slingshot) .await; let resp = h .call(&format!( "/xrpc/sh.tangled.repo.blob?repo={}&path=x", enc(&uri), )) .await; assert_eq!(resp.status(), StatusCode::BAD_GATEWAY); let v = body_value(resp).await; assert_eq!(v["error"], "InvalidRecord"); } #[tokio::test] async fn strips_basic_auth_from_credentialed_knot_url() { let h = Harness::new().await; let parsed = Url::parse(&h.knot.uri()).unwrap(); let knot_with_creds = format!( "{}://attacker:secret@{}:{}/", parsed.scheme(), parsed.host_str().unwrap(), parsed.port().unwrap(), ); let owner = did("did:plc:abalone"); let rk = rkey("r1"); let record = json!({ "$type": "sh.tangled.repo", "createdAt": "2026-05-01T00:00:00Z", "knot": knot_with_creds, "name": "barnacle", }); let uri = format!("at://{}/sh.tangled.repo/{}", owner.as_ref(), rk.as_ref()); Mock::given(method("GET")) .and(path("/xrpc/com.atproto.repo.getRecord")) .and(query_param("repo", owner.as_ref())) .and(query_param("collection", "sh.tangled.repo")) .and(query_param("rkey", rk.as_ref())) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "uri": uri, "cid": CID, "value": record, }))) .mount(&h.slingshot) .await; Mock::given(method("GET")) .and(path("/xrpc/sh.tangled.repo.blob")) .respond_with( ResponseTemplate::new(200).set_body_raw(r#"{"path":"x"}"#, "application/json"), ) .mount(&h.knot) .await; let target = format!("/xrpc/sh.tangled.repo.blob?repo={}&path=x", enc(&uri)); let resp = h.call(&target).await; assert_eq!(resp.status(), StatusCode::OK); let received = h.knot.received_requests().await.unwrap(); let knot_call = received .iter() .find(|r| r.url.path() == "/xrpc/sh.tangled.repo.blob") .expect("knot received the proxied call"); assert!( knot_call.headers.get("authorization").is_none(), "userinfo in knot field must not become an Authorization header", ); } #[tokio::test] async fn knot_redirect_surfaces_as_upstream_failed() { let h = Harness::new().await; let secondary = MockServer::start().await; h.mount_repo_record(&did("did:plc:abalone"), &rkey("r1"), "barnacle") .await; Mock::given(method("GET")) .and(path("/xrpc/sh.tangled.repo.blob")) .respond_with( ResponseTemplate::new(302) .insert_header("location", &format!("{}/secret", secondary.uri())), ) .mount(&h.knot) .await; Mock::given(method("GET")) .and(path("/secret")) .respond_with(ResponseTemplate::new(200).set_body_string("leaked")) .mount(&secondary) .await; let resp = h .call(&format!( "/xrpc/sh.tangled.repo.blob?repo={}&path=x", enc("at://did:plc:abalone/sh.tangled.repo/r1"), )) .await; assert_eq!(resp.status(), StatusCode::BAD_GATEWAY); let v = body_value(resp).await; assert_eq!(v["error"], "UpstreamFailed"); let received = secondary.received_requests().await.unwrap(); assert!(received.is_empty(), "redirect target must not be dialled"); } #[tokio::test] async fn forwards_repeated_query_params() { let h = Harness::new().await; h.mount_repo_record(&did("did:plc:limpet"), &rkey("r4"), "kelp") .await; Mock::given(method("GET")) .and(path("/xrpc/sh.tangled.repo.tags")) .respond_with(ResponseTemplate::new(200).set_body_raw(r#"{"tags":[]}"#, "application/json")) .mount(&h.knot) .await; let target = format!( "/xrpc/sh.tangled.repo.tags?repo={}&filter=alpha&filter=beta", enc("at://did:plc:limpet/sh.tangled.repo/r4"), ); let resp = h.call(&target).await; assert_eq!(resp.status(), StatusCode::OK); let received = h.knot.received_requests().await.unwrap(); let knot_call = received .iter() .find(|r| r.url.path() == "/xrpc/sh.tangled.repo.tags") .expect("knot received the proxied call"); let filters: Vec = knot_call .url .query_pairs() .filter(|(k, _)| k == "filter") .map(|(_, v)| v.into_owned()) .collect(); assert_eq!(filters, vec!["alpha".to_owned(), "beta".to_owned()]); } #[tokio::test] async fn duplicate_repo_param_rejected_as_invalid_request() { let h = Harness::new().await; let target = format!( "/xrpc/sh.tangled.repo.blob?repo={}&repo={}", enc("at://did:plc:abalone/sh.tangled.repo/r1"), enc("at://did:plc:limpet/sh.tangled.repo/r2"), ); let resp = h.call(&target).await; assert_eq!(resp.status(), StatusCode::BAD_REQUEST); let v = body_value(resp).await; assert_eq!(v["error"], "InvalidRequest"); assert!( v["message"] .as_str() .unwrap_or_default() .contains("repo parameter must appear at most once"), "got {v}", ); } #[tokio::test] async fn duplicate_knot_param_rejected_as_invalid_request() { let h = Harness::new().await; let target = format!( "/xrpc/sh.tangled.knot.version?knot={}&knot={}", enc("https://oyster.cafe"), enc("https://nel.pet"), ); let resp = h.call(&target).await; assert_eq!(resp.status(), StatusCode::BAD_REQUEST); let v = body_value(resp).await; assert_eq!(v["error"], "InvalidRequest"); } #[tokio::test] async fn rejects_client_supplied_plaintext_when_https_required() { let strict = KnotProxyConfig { require_https: true, ..test_config() }; let h = Harness::with_config(strict).await; let resp = h .call(&format!( "/xrpc/sh.tangled.knot.version?knot={}", enc("http://oyster.cafe"), )) .await; assert_eq!(resp.status(), StatusCode::BAD_REQUEST); let v = body_value(resp).await; assert_eq!(v["error"], "InvalidRequest"); assert!( v["message"] .as_str() .unwrap_or_default() .contains("must be https"), "got {v}", ); } #[tokio::test] async fn record_with_plaintext_knot_returns_invalid_record_when_https_required() { let strict = KnotProxyConfig { require_https: true, allow_private_hosts: true, ..test_config() }; let h = Harness::with_config(strict).await; let owner = did("did:plc:abalone"); let rk = rkey("r1"); let record = json!({ "$type": "sh.tangled.repo", "createdAt": "2026-05-01T00:00:00Z", "knot": "http://oyster.cafe", "name": "barnacle", }); let uri = format!("at://{}/sh.tangled.repo/{}", owner.as_ref(), rk.as_ref()); Mock::given(method("GET")) .and(path("/xrpc/com.atproto.repo.getRecord")) .and(query_param("repo", owner.as_ref())) .and(query_param("collection", "sh.tangled.repo")) .and(query_param("rkey", rk.as_ref())) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "uri": uri, "cid": CID, "value": record, }))) .mount(&h.slingshot) .await; let resp = h .call(&format!( "/xrpc/sh.tangled.repo.blob?repo={}&path=x", enc(&uri), )) .await; assert_eq!(resp.status(), StatusCode::BAD_GATEWAY); let v = body_value(resp).await; assert_eq!(v["error"], "InvalidRecord"); assert!( v["message"] .as_str() .unwrap_or_default() .contains("requires https"), "got {v}", ); } #[tokio::test] async fn knot_not_modified_passes_through() { let h = Harness::new().await; h.mount_repo_record(&did("did:plc:limpet"), &rkey("r5"), "kelp") .await; Mock::given(method("GET")) .and(path("/xrpc/sh.tangled.repo.archive")) .respond_with(ResponseTemplate::new(304).insert_header("etag", "\"v1\"")) .mount(&h.knot) .await; let target = format!( "/xrpc/sh.tangled.repo.archive?repo={}&ref=v1", enc("at://did:plc:limpet/sh.tangled.repo/r5"), ); let resp = h .call_with_headers(&target, &[("if-none-match", "\"v1\"")]) .await; assert_eq!(resp.status(), StatusCode::NOT_MODIFIED); assert_eq!(resp.headers().get("etag").unwrap(), "\"v1\""); }