This repository has no description
0

Configure Feed

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

core / bobbin / crates / xrpc / tests / knot_proxy.rs
37 kB 1074 lines
1use std::net::{IpAddr, Ipv4Addr, SocketAddr}; 2use std::sync::Arc; 3use std::time::Duration; 4 5use axum::body::{Body, to_bytes}; 6use axum::extract::ConnectInfo; 7use bobbin_edge_index::{CoverageWatch, EdgeStore, StateIndex}; 8use bobbin_knot_proxy::{FailureThreshold, KnotHttpConfig, KnotProxy, KnotProxyConfig}; 9use bobbin_record_lru::{CacheCapacity, LruRecordStore}; 10use bobbin_resolver::RepoIdResolver; 11use bobbin_runtime::{RuntimeHasher, SystemClock}; 12use bobbin_search::{DEFAULT_WRITER_HEAP_BYTES, SearchIndex, SearchReader}; 13use bobbin_slingshot_client::SlingshotClient; 14use bobbin_xrpc::{AppState, router}; 15use http::{HeaderName, HeaderValue, Request, StatusCode}; 16use jacquard_common::DefaultStr; 17use jacquard_common::types::did::Did; 18use jacquard_common::types::recordkey::Rkey; 19use serde_json::{Value, json}; 20use tower::ServiceExt; 21use trusted_proxies::TrustedProxies; 22use url::Url; 23use url::form_urlencoded::byte_serialize; 24use wiremock::matchers::{header_exists, method, path, query_param}; 25use wiremock::{Mock, MockServer, ResponseTemplate}; 26 27const CID: &str = "bafyreieqygohnz2zqyvtvktbjpvhutphobcmbsnt4q5lc36ri7vpcmoz4i"; 28 29const SOCKET: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 4321); 30 31fn did(s: &str) -> Did<DefaultStr> { 32 Did::new_owned(s).unwrap() 33} 34 35fn rkey(s: &str) -> Rkey<DefaultStr> { 36 Rkey::new_owned(s).unwrap() 37} 38 39fn hdr(name: &'static str, value: &'static str) -> (HeaderName, HeaderValue) { 40 ( 41 HeaderName::from_static(name), 42 HeaderValue::from_static(value), 43 ) 44} 45 46fn test_config() -> KnotProxyConfig { 47 KnotProxyConfig { 48 failure_threshold: FailureThreshold::new(2).unwrap(), 49 cooldown: Duration::from_millis(80), 50 allow_private_hosts: true, 51 require_https: false, 52 } 53} 54 55fn test_http_config() -> KnotHttpConfig { 56 KnotHttpConfig { 57 connect_timeout: Duration::from_millis(500), 58 read_timeout: Duration::from_secs(2), 59 } 60} 61 62struct Harness { 63 slingshot: MockServer, 64 knot: MockServer, 65 state: AppState, 66} 67 68impl Harness { 69 async fn new() -> Self { 70 Self::with_config(test_config()).await 71 } 72 73 async fn behind_proxy() -> Self { 74 let harness = Self::with_config(test_config()).await; 75 Self { 76 state: harness 77 .state 78 .clone() 79 .with_proxies(TrustedProxies::parse(["127.0.0.1"]).unwrap()), 80 ..harness 81 } 82 } 83 84 async fn with_config(config: KnotProxyConfig) -> Self { 85 let slingshot_server = MockServer::start().await; 86 let knot_server = MockServer::start().await; 87 let state = AppState::new( 88 Arc::new(LruRecordStore::new(CacheCapacity::from_bytes(64 * 1024))), 89 SlingshotClient::with_default_http(Url::parse(&slingshot_server.uri()).unwrap()) 90 .unwrap(), 91 Arc::new(EdgeStore::new(RuntimeHasher::default())), 92 Arc::new(StateIndex::new(RuntimeHasher::default())), 93 Arc::new(StateIndex::new(RuntimeHasher::default())), 94 Arc::new(CoverageWatch::new()), 95 Arc::new( 96 KnotProxy::new( 97 config, 98 test_http_config(), 99 Arc::new(SystemClock::new()), 100 RuntimeHasher::default(), 101 ) 102 .unwrap(), 103 ), 104 Arc::new( 105 SearchIndex::new(DEFAULT_WRITER_HEAP_BYTES, Arc::new(SystemClock::new())).unwrap(), 106 ) as Arc<dyn SearchReader>, 107 Arc::new(RepoIdResolver::detached(RuntimeHasher::default())), 108 Arc::new(bobbin_xrpc::default_directory()), 109 ); 110 Self { 111 slingshot: slingshot_server, 112 knot: knot_server, 113 state, 114 } 115 } 116 117 async fn mount_repo_record(&self, did: &Did<DefaultStr>, rkey: &Rkey<DefaultStr>, name: &str) { 118 self.mount_repo_record_inner(did, rkey, Some(name)).await; 119 } 120 121 async fn mount_repo_record_rkey_as_name(&self, did: &Did<DefaultStr>, rkey: &Rkey<DefaultStr>) { 122 self.mount_repo_record_inner(did, rkey, None).await; 123 } 124 125 async fn mount_repo_record_inner( 126 &self, 127 did: &Did<DefaultStr>, 128 rkey: &Rkey<DefaultStr>, 129 name: Option<&str>, 130 ) { 131 let knot_value = self.knot.uri(); 132 let mut record = json!({ 133 "$type": "sh.tangled.repo", 134 "createdAt": "2026-05-01T00:00:00Z", 135 "knot": knot_value, 136 }); 137 if let Some(n) = name { 138 record["name"] = json!(n); 139 } 140 let uri = format!("at://{}/sh.tangled.repo/{}", did.as_ref(), rkey.as_ref()); 141 Mock::given(method("GET")) 142 .and(path("/xrpc/com.atproto.repo.getRecord")) 143 .and(query_param("repo", did.as_ref())) 144 .and(query_param("collection", "sh.tangled.repo")) 145 .and(query_param("rkey", rkey.as_ref())) 146 .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 147 "uri": uri, 148 "cid": CID, 149 "value": record, 150 }))) 151 .mount(&self.slingshot) 152 .await; 153 } 154 155 async fn call(&self, path_and_query: &str) -> http::Response<Body> { 156 self.call_with_headers(path_and_query, &[]).await 157 } 158 159 async fn call_with_headers( 160 &self, 161 path_and_query: &str, 162 client_headers: &[(HeaderName, HeaderValue)], 163 ) -> http::Response<Body> { 164 self.call_from(path_and_query, client_headers, Some(SOCKET)) 165 .await 166 } 167 168 async fn blob_client_address(&self, tid: &str, socket: Option<SocketAddr>) -> Option<String> { 169 self.mount_repo_record(&did("did:plc:limpet"), &rkey(tid), "kelp") 170 .await; 171 Mock::given(method("GET")) 172 .and(path("/xrpc/sh.tangled.repo.blob")) 173 .respond_with( 174 ResponseTemplate::new(200).set_body_raw(r#"{"path":"x"}"#, "application/json"), 175 ) 176 .mount(&self.knot) 177 .await; 178 let target = format!( 179 "/xrpc/sh.tangled.repo.blob?repo={}&path=x", 180 enc(&format!("at://did:plc:limpet/sh.tangled.repo/{tid}")), 181 ); 182 let resp = self 183 .call_from(&target, &[hdr("x-forwarded-for", "203.0.113.42")], socket) 184 .await; 185 assert_eq!(resp.status(), StatusCode::OK); 186 self.knot 187 .received_requests() 188 .await 189 .unwrap() 190 .iter() 191 .find(|r| r.url.path() == "/xrpc/sh.tangled.repo.blob") 192 .expect("knot received the proxied call") 193 .headers 194 .get("x-forwarded-for") 195 .map(|value| value.to_str().unwrap().to_owned()) 196 } 197 198 async fn call_from( 199 &self, 200 path_and_query: &str, 201 client_headers: &[(HeaderName, HeaderValue)], 202 socket: Option<SocketAddr>, 203 ) -> http::Response<Body> { 204 let connected = socket 205 .into_iter() 206 .fold(Request::builder().uri(path_and_query), |b, socket| { 207 b.extension(ConnectInfo(socket)) 208 }); 209 let builder = client_headers 210 .iter() 211 .fold(connected, |b, (name, value)| b.header(name, value)); 212 router(self.state.clone()) 213 .oneshot(builder.body(Body::empty()).unwrap()) 214 .await 215 .expect("router infallible") 216 } 217} 218 219fn enc(s: &str) -> String { 220 byte_serialize(s.as_bytes()).collect() 221} 222 223async fn body_string(resp: http::Response<Body>) -> String { 224 let body = to_bytes(resp.into_body(), 64 * 1024).await.unwrap(); 225 String::from_utf8(body.to_vec()).expect("response body is utf-8") 226} 227 228async fn body_value(resp: http::Response<Body>) -> Value { 229 let s = body_string(resp).await; 230 serde_json::from_str(&s).unwrap_or_else(|e| panic!("body not json: {e}: {s}")) 231} 232 233#[tokio::test] 234async fn proxies_repo_blob_with_did_slash_name_repo_param() { 235 let h = Harness::new().await; 236 let tid = "3jzfcijpj2z2a"; 237 h.mount_repo_record(&did("did:plc:abalone"), &rkey(tid), "barnacle") 238 .await; 239 Mock::given(method("GET")) 240 .and(path("/xrpc/sh.tangled.repo.blob")) 241 .and(query_param("repo", "did:plc:abalone/barnacle")) 242 .and(query_param("ref", "main")) 243 .and(query_param("path", "README.md")) 244 .respond_with( 245 ResponseTemplate::new(200) 246 .set_body_raw(r#"{"path":"README.md","content":"hi"}"#, "application/json"), 247 ) 248 .mount(&h.knot) 249 .await; 250 251 let target = format!( 252 "/xrpc/sh.tangled.repo.blob?repo={}&ref=main&path=README.md", 253 enc(&format!("at://did:plc:abalone/sh.tangled.repo/{tid}")), 254 ); 255 let resp = h.call(&target).await; 256 assert_eq!(resp.status(), StatusCode::OK); 257 assert_eq!( 258 resp.headers().get("content-type").unwrap(), 259 "application/json", 260 ); 261 let v = body_value(resp).await; 262 assert_eq!(v["path"], "README.md"); 263 assert_eq!(v["content"], "hi"); 264} 265 266#[tokio::test] 267async fn modern_rkey_as_name_uses_rkey_even_when_name_field_set() { 268 let h = Harness::new().await; 269 h.mount_repo_record(&did("did:plc:abalone"), &rkey("core"), "Tangled Core") 270 .await; 271 Mock::given(method("GET")) 272 .and(path("/xrpc/sh.tangled.repo.getDefaultBranch")) 273 .and(query_param("repo", "did:plc:abalone/core")) 274 .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 275 "hash": "abc", 276 "name": "main", 277 "when": "2026-05-01T00:00:00Z", 278 }))) 279 .mount(&h.knot) 280 .await; 281 282 let target = format!( 283 "/xrpc/sh.tangled.repo.getDefaultBranch?repo={}", 284 enc("at://did:plc:abalone/sh.tangled.repo/core"), 285 ); 286 let resp = h.call(&target).await; 287 assert_eq!(resp.status(), StatusCode::OK); 288 let v = body_value(resp).await; 289 assert_eq!(v["name"], "main"); 290} 291 292#[tokio::test] 293async fn modern_rkey_as_name_works_when_name_field_null() { 294 let h = Harness::new().await; 295 h.mount_repo_record_rkey_as_name(&did("did:plc:abalone"), &rkey("core")) 296 .await; 297 Mock::given(method("GET")) 298 .and(path("/xrpc/sh.tangled.repo.getDefaultBranch")) 299 .and(query_param("repo", "did:plc:abalone/core")) 300 .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 301 "hash": "abc", 302 "name": "main", 303 "when": "2026-05-01T00:00:00Z", 304 }))) 305 .mount(&h.knot) 306 .await; 307 308 let target = format!( 309 "/xrpc/sh.tangled.repo.getDefaultBranch?repo={}", 310 enc("at://did:plc:abalone/sh.tangled.repo/core"), 311 ); 312 let resp = h.call(&target).await; 313 assert_eq!(resp.status(), StatusCode::OK); 314} 315 316#[tokio::test] 317async fn legacy_tid_rkey_falls_back_to_name_field() { 318 let h = Harness::new().await; 319 let tid_rkey = "3jzfcijpj2z2a"; 320 h.mount_repo_record(&did("did:plc:abalone"), &rkey(tid_rkey), "dotfiles") 321 .await; 322 Mock::given(method("GET")) 323 .and(path("/xrpc/sh.tangled.repo.getDefaultBranch")) 324 .and(query_param("repo", "did:plc:abalone/dotfiles")) 325 .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 326 "hash": "abc", 327 "name": "main", 328 "when": "2026-05-01T00:00:00Z", 329 }))) 330 .mount(&h.knot) 331 .await; 332 333 let target = format!( 334 "/xrpc/sh.tangled.repo.getDefaultBranch?repo={}", 335 enc(&format!("at://did:plc:abalone/sh.tangled.repo/{tid_rkey}")), 336 ); 337 let resp = h.call(&target).await; 338 assert_eq!(resp.status(), StatusCode::OK); 339} 340 341#[tokio::test] 342async fn tid_rkey_without_name_falls_back_to_tid() { 343 let h = Harness::new().await; 344 let tid_rkey = "3jzfcijpj2z2a"; 345 h.mount_repo_record_rkey_as_name(&did("did:plc:abalone"), &rkey(tid_rkey)) 346 .await; 347 Mock::given(method("GET")) 348 .and(path("/xrpc/sh.tangled.repo.getDefaultBranch")) 349 .and(query_param("repo", format!("did:plc:abalone/{tid_rkey}"))) 350 .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 351 "hash": "abc", 352 "name": "main", 353 "when": "2026-05-01T00:00:00Z", 354 }))) 355 .mount(&h.knot) 356 .await; 357 let target = format!( 358 "/xrpc/sh.tangled.repo.getDefaultBranch?repo={}", 359 enc(&format!("at://did:plc:abalone/sh.tangled.repo/{tid_rkey}")), 360 ); 361 let resp = h.call(&target).await; 362 assert_eq!(resp.status(), StatusCode::OK); 363} 364 365#[tokio::test] 366async fn streams_binary_archive_through_proxy() { 367 let h = Harness::new().await; 368 let tid = "3jzfcijpj2z2b"; 369 h.mount_repo_record(&did("did:plc:limpet"), &rkey(tid), "kelp") 370 .await; 371 let payload: Vec<u8> = (0u8..=255).collect(); 372 Mock::given(method("GET")) 373 .and(path("/xrpc/sh.tangled.repo.archive")) 374 .and(query_param("repo", "did:plc:limpet/kelp")) 375 .and(query_param("ref", "v1")) 376 .respond_with( 377 ResponseTemplate::new(200) 378 .insert_header("content-type", "application/gzip") 379 .set_body_bytes(payload.clone()), 380 ) 381 .mount(&h.knot) 382 .await; 383 384 let target = format!( 385 "/xrpc/sh.tangled.repo.archive?repo={}&ref=v1", 386 enc(&format!("at://did:plc:limpet/sh.tangled.repo/{tid}")), 387 ); 388 let resp = h.call(&target).await; 389 assert_eq!(resp.status(), StatusCode::OK); 390 assert_eq!( 391 resp.headers().get("content-type").unwrap(), 392 "application/gzip", 393 ); 394 let body = to_bytes(resp.into_body(), 4 * 1024).await.unwrap(); 395 assert_eq!(body.as_ref(), payload.as_slice()); 396} 397 398#[tokio::test] 399async fn missing_repo_param_returns_400() { 400 let h = Harness::new().await; 401 let resp = h.call("/xrpc/sh.tangled.repo.blob?ref=main").await; 402 assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 403 let v = body_value(resp).await; 404 assert_eq!(v["error"], "InvalidRequest"); 405} 406 407#[tokio::test] 408async fn unknown_repo_propagates_404_from_slingshot() { 409 let h = Harness::new().await; 410 Mock::given(method("GET")) 411 .and(path("/xrpc/com.atproto.repo.getRecord")) 412 .respond_with(ResponseTemplate::new(404).set_body_string("not found")) 413 .mount(&h.slingshot) 414 .await; 415 let target = format!( 416 "/xrpc/sh.tangled.repo.blob?repo={}&ref=main", 417 enc("at://did:plc:abalone/sh.tangled.repo/missing"), 418 ); 419 let resp = h.call(&target).await; 420 assert_eq!(resp.status(), StatusCode::NOT_FOUND); 421 let v = body_value(resp).await; 422 assert_eq!(v["error"], "RecordNotFound"); 423} 424 425#[tokio::test] 426async fn knot_5xx_routes_to_upstream_failed() { 427 let h = Harness::new().await; 428 h.mount_repo_record(&did("did:plc:abalone"), &rkey("r1"), "barnacle") 429 .await; 430 Mock::given(method("GET")) 431 .and(path("/xrpc/sh.tangled.repo.blob")) 432 .respond_with(ResponseTemplate::new(503)) 433 .mount(&h.knot) 434 .await; 435 let target = format!( 436 "/xrpc/sh.tangled.repo.blob?repo={}&ref=main&path=x", 437 enc("at://did:plc:abalone/sh.tangled.repo/r1"), 438 ); 439 let resp = h.call(&target).await; 440 assert_eq!(resp.status(), StatusCode::BAD_GATEWAY); 441 let v = body_value(resp).await; 442 assert_eq!(v["error"], "UpstreamFailed"); 443} 444 445#[tokio::test] 446async fn knot_4xx_passes_through_unchanged() { 447 let h = Harness::new().await; 448 h.mount_repo_record(&did("did:plc:abalone"), &rkey("r1"), "barnacle") 449 .await; 450 Mock::given(method("GET")) 451 .and(path("/xrpc/sh.tangled.repo.blob")) 452 .respond_with(ResponseTemplate::new(404).set_body_raw( 453 r#"{"error":"FileNotFound","message":"nope"}"#, 454 "application/json", 455 )) 456 .mount(&h.knot) 457 .await; 458 let target = format!( 459 "/xrpc/sh.tangled.repo.blob?repo={}&ref=main&path=missing", 460 enc("at://did:plc:abalone/sh.tangled.repo/r1"), 461 ); 462 let resp = h.call(&target).await; 463 assert_eq!(resp.status(), StatusCode::NOT_FOUND); 464 let v = body_value(resp).await; 465 assert_eq!(v["error"], "FileNotFound"); 466} 467 468#[tokio::test] 469async fn breaker_opens_after_threshold_then_short_circuits() { 470 let h = Harness::new().await; 471 h.mount_repo_record(&did("did:plc:abalone"), &rkey("r1"), "barnacle") 472 .await; 473 Mock::given(method("GET")) 474 .and(path("/xrpc/sh.tangled.repo.blob")) 475 .respond_with(ResponseTemplate::new(503)) 476 .mount(&h.knot) 477 .await; 478 let target = format!( 479 "/xrpc/sh.tangled.repo.blob?repo={}&ref=main&path=x", 480 enc("at://did:plc:abalone/sh.tangled.repo/r1"), 481 ); 482 let r1 = h.call(&target).await; 483 assert_eq!(r1.status(), StatusCode::BAD_GATEWAY); 484 let _ = body_string(r1).await; 485 let r2 = h.call(&target).await; 486 assert_eq!(r2.status(), StatusCode::BAD_GATEWAY); 487 let _ = body_string(r2).await; 488 let r3 = h.call(&target).await; 489 assert_eq!(r3.status(), StatusCode::BAD_GATEWAY); 490 let v = body_value(r3).await; 491 assert!( 492 v["message"] 493 .as_str() 494 .unwrap_or_default() 495 .contains("circuit breaker open"), 496 "third call must be short-circuited by breaker, got {v}", 497 ); 498} 499 500#[tokio::test] 501async fn proxy_owner_uses_knot_query_param() { 502 let h = Harness::new().await; 503 Mock::given(method("GET")) 504 .and(path("/xrpc/sh.tangled.owner")) 505 .respond_with( 506 ResponseTemplate::new(200) 507 .set_body_raw(r#"{"owner":"did:plc:nautilus"}"#, "application/json"), 508 ) 509 .mount(&h.knot) 510 .await; 511 let target = format!("/xrpc/sh.tangled.owner?knot={}", enc(&h.knot.uri())); 512 let resp = h.call(&target).await; 513 assert_eq!(resp.status(), StatusCode::OK); 514 let v = body_value(resp).await; 515 assert_eq!(v["owner"], "did:plc:nautilus"); 516} 517 518#[tokio::test] 519async fn proxy_knot_version_uses_knot_query_param() { 520 let h = Harness::new().await; 521 Mock::given(method("GET")) 522 .and(path("/xrpc/sh.tangled.knot.version")) 523 .respond_with( 524 ResponseTemplate::new(200).set_body_raw(r#"{"version":"0.42"}"#, "application/json"), 525 ) 526 .mount(&h.knot) 527 .await; 528 let target = format!("/xrpc/sh.tangled.knot.version?knot={}", enc(&h.knot.uri())); 529 let resp = h.call(&target).await; 530 assert_eq!(resp.status(), StatusCode::OK); 531 let v = body_value(resp).await; 532 assert_eq!(v["version"], "0.42"); 533} 534 535#[tokio::test] 536async fn proxy_knot_list_keys_forwards_pagination_params() { 537 let h = Harness::new().await; 538 Mock::given(method("GET")) 539 .and(path("/xrpc/sh.tangled.knot.listKeys")) 540 .and(query_param("limit", "5")) 541 .and(query_param("cursor", "abc")) 542 .respond_with(ResponseTemplate::new(200).set_body_raw(r#"{"keys":[]}"#, "application/json")) 543 .mount(&h.knot) 544 .await; 545 let target = format!( 546 "/xrpc/sh.tangled.knot.listKeys?knot={}&limit=5&cursor=abc", 547 enc(&h.knot.uri()), 548 ); 549 let resp = h.call(&target).await; 550 assert_eq!(resp.status(), StatusCode::OK); 551} 552 553#[tokio::test] 554async fn missing_knot_param_on_knot_route_returns_400() { 555 let h = Harness::new().await; 556 let resp = h.call("/xrpc/sh.tangled.knot.version").await; 557 assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 558 let v = body_value(resp).await; 559 assert_eq!(v["error"], "InvalidRequest"); 560} 561 562#[tokio::test] 563async fn second_proxy_call_skips_slingshot_via_lru() { 564 let h = Harness::new().await; 565 let tid = "3jzfcijpj2z2c"; 566 h.mount_repo_record(&did("did:plc:abalone"), &rkey(tid), "barnacle") 567 .await; 568 Mock::given(method("GET")) 569 .and(path("/xrpc/sh.tangled.repo.tree")) 570 .and(query_param("repo", "did:plc:abalone/barnacle")) 571 .and(query_param("ref", "main")) 572 .respond_with( 573 ResponseTemplate::new(200) 574 .set_body_raw(r#"{"ref":"main","files":[]}"#, "application/json"), 575 ) 576 .mount(&h.knot) 577 .await; 578 let target = format!( 579 "/xrpc/sh.tangled.repo.tree?repo={}&ref=main", 580 enc(&format!("at://did:plc:abalone/sh.tangled.repo/{tid}")), 581 ); 582 let r1 = h.call(&target).await; 583 assert_eq!(r1.status(), StatusCode::OK); 584 let _ = body_string(r1).await; 585 let r2 = h.call(&target).await; 586 assert_eq!(r2.status(), StatusCode::OK); 587 let _ = body_string(r2).await; 588 let received = h.slingshot.received_requests().await.unwrap(); 589 let getrecord = received 590 .iter() 591 .filter(|r| r.url.path() == "/xrpc/com.atproto.repo.getRecord") 592 .count(); 593 assert_eq!( 594 getrecord, 1, 595 "slingshot must be hit exactly once because the LRU serves the second proxy call", 596 ); 597} 598 599#[tokio::test] 600async fn does_not_inject_auth_or_atproto_proxy_headers() { 601 let h = Harness::new().await; 602 h.mount_repo_record(&did("did:plc:abalone"), &rkey("r1"), "barnacle") 603 .await; 604 Mock::given(method("GET")) 605 .and(path("/xrpc/sh.tangled.repo.blob")) 606 .and(header_exists("user-agent")) 607 .respond_with( 608 ResponseTemplate::new(200).set_body_raw(r#"{"path":"x"}"#, "application/json"), 609 ) 610 .mount(&h.knot) 611 .await; 612 let target = format!( 613 "/xrpc/sh.tangled.repo.blob?repo={}&ref=main&path=x", 614 enc("at://did:plc:abalone/sh.tangled.repo/r1"), 615 ); 616 let resp = h.call(&target).await; 617 assert_eq!(resp.status(), StatusCode::OK); 618 let received = h.knot.received_requests().await.unwrap(); 619 let knot_call = received 620 .iter() 621 .find(|r| r.url.path() == "/xrpc/sh.tangled.repo.blob") 622 .expect("knot received the proxied call"); 623 assert!( 624 knot_call.headers.get("authorization").is_none(), 625 "bobbin must not inject auth, anonymous read by design", 626 ); 627 assert!( 628 knot_call.headers.get("atproto-proxy").is_none(), 629 "bobbin is not an atproto-proxy chain", 630 ); 631 assert!( 632 knot_call.headers.get("atproto-accept-labelers").is_none(), 633 "bobbin does not negotiate labelers with knots", 634 ); 635} 636 637#[tokio::test] 638async fn forwards_range_conditional_and_client_address_headers() { 639 let h = Harness::behind_proxy().await; 640 let tid = "3jzfcijpj2z2d"; 641 h.mount_repo_record(&did("did:plc:limpet"), &rkey(tid), "kelp") 642 .await; 643 Mock::given(method("GET")) 644 .and(path("/xrpc/sh.tangled.repo.archive")) 645 .and(query_param("repo", "did:plc:limpet/kelp")) 646 .respond_with( 647 ResponseTemplate::new(206) 648 .insert_header("content-type", "application/octet-stream") 649 .insert_header("content-range", "bytes 0-99/2048") 650 .insert_header("accept-ranges", "bytes") 651 .insert_header("etag", "\"v1\"") 652 .set_body_bytes(vec![0u8; 100]), 653 ) 654 .mount(&h.knot) 655 .await; 656 657 let target = format!( 658 "/xrpc/sh.tangled.repo.archive?repo={}&ref=v1", 659 enc(&format!("at://did:plc:limpet/sh.tangled.repo/{tid}")), 660 ); 661 let resp = h 662 .call_with_headers( 663 &target, 664 &[ 665 hdr("range", "bytes=0-99"), 666 hdr("if-none-match", "\"old\""), 667 hdr("if-modified-since", "Wed, 01 May 2026 00:00:00 GMT"), 668 hdr("x-forwarded-for", "203.0.113.42"), 669 ], 670 ) 671 .await; 672 assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT); 673 assert_eq!( 674 resp.headers().get("content-range").unwrap(), 675 "bytes 0-99/2048" 676 ); 677 assert_eq!(resp.headers().get("accept-ranges").unwrap(), "bytes"); 678 assert_eq!(resp.headers().get("etag").unwrap(), "\"v1\""); 679 680 let received = h.knot.received_requests().await.unwrap(); 681 let knot_call = received 682 .iter() 683 .find(|r| r.url.path() == "/xrpc/sh.tangled.repo.archive") 684 .expect("knot received the proxied call"); 685 assert_eq!(knot_call.headers.get("range").unwrap(), "bytes=0-99"); 686 assert_eq!(knot_call.headers.get("if-none-match").unwrap(), "\"old\""); 687 assert_eq!( 688 knot_call.headers.get("if-modified-since").unwrap(), 689 "Wed, 01 May 2026 00:00:00 GMT", 690 ); 691 assert_eq!( 692 knot_call.headers.get("x-forwarded-for").unwrap(), 693 "203.0.113.42", 694 ); 695} 696 697#[tokio::test] 698async fn bobbin_forwards_only_a_client_address_it_can_vouch_for() { 699 assert_eq!( 700 Harness::new() 701 .await 702 .blob_client_address("3jzfcijpj2z2e", Some(SOCKET)) 703 .await, 704 Some(SOCKET.ip().to_string()), 705 "a client that writes this header itself must reach the knot under the address it connected from, since bobbin hasn't been told to trust any proxy" 706 ); 707 assert_eq!( 708 Harness::behind_proxy() 709 .await 710 .blob_client_address("3jzfcijpj2z2f", None) 711 .await, 712 None, 713 "bobbin won't forward the header a client wrote or an address it made up, because a listener served without connect info doesn't leave it anything to vouch for" 714 ); 715} 716 717#[tokio::test] 718async fn drops_disallowed_client_headers() { 719 let h = Harness::new().await; 720 h.mount_repo_record(&did("did:plc:abalone"), &rkey("r1"), "barnacle") 721 .await; 722 Mock::given(method("GET")) 723 .and(path("/xrpc/sh.tangled.repo.blob")) 724 .respond_with( 725 ResponseTemplate::new(200).set_body_raw(r#"{"path":"x"}"#, "application/json"), 726 ) 727 .mount(&h.knot) 728 .await; 729 let target = format!( 730 "/xrpc/sh.tangled.repo.blob?repo={}&path=x", 731 enc("at://did:plc:abalone/sh.tangled.repo/r1"), 732 ); 733 let resp = h 734 .call_with_headers( 735 &target, 736 &[ 737 hdr("authorization", "Bearer secret"), 738 hdr("cookie", "sid=evil"), 739 hdr("x-custom", "should-not-pass"), 740 ], 741 ) 742 .await; 743 assert_eq!(resp.status(), StatusCode::OK); 744 let received = h.knot.received_requests().await.unwrap(); 745 let knot_call = received 746 .iter() 747 .find(|r| r.url.path() == "/xrpc/sh.tangled.repo.blob") 748 .expect("knot received the proxied call"); 749 assert!(knot_call.headers.get("authorization").is_none()); 750 assert!(knot_call.headers.get("cookie").is_none()); 751 assert!(knot_call.headers.get("x-custom").is_none()); 752} 753 754#[tokio::test] 755async fn rejects_client_supplied_loopback_under_strict_config() { 756 let strict = KnotProxyConfig { 757 allow_private_hosts: false, 758 ..test_config() 759 }; 760 let h = Harness::with_config(strict).await; 761 let resp = h 762 .call(&format!( 763 "/xrpc/sh.tangled.knot.version?knot={}", 764 enc("http://127.0.0.1:9"), 765 )) 766 .await; 767 assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 768 let v = body_value(resp).await; 769 assert_eq!(v["error"], "InvalidRequest"); 770 let msg = v["message"].as_str().unwrap_or_default().to_owned(); 771 assert!( 772 msg.contains("loopback") || msg.contains("blocked"), 773 "message should explain block reason, got {msg}", 774 ); 775} 776 777#[tokio::test] 778async fn rejects_client_supplied_link_local_metadata_endpoint() { 779 let strict = KnotProxyConfig { 780 allow_private_hosts: false, 781 ..test_config() 782 }; 783 let h = Harness::with_config(strict).await; 784 let resp = h 785 .call(&format!( 786 "/xrpc/sh.tangled.knot.version?knot={}", 787 enc("http://169.254.169.254"), 788 )) 789 .await; 790 assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 791 let v = body_value(resp).await; 792 assert_eq!(v["error"], "InvalidRequest"); 793} 794 795#[tokio::test] 796async fn record_with_private_knot_returns_invalid_record() { 797 let strict = KnotProxyConfig { 798 allow_private_hosts: false, 799 ..test_config() 800 }; 801 let h = Harness::with_config(strict).await; 802 let owner = did("did:plc:abalone"); 803 let rk = rkey("r1"); 804 let record = json!({ 805 "$type": "sh.tangled.repo", 806 "createdAt": "2026-05-01T00:00:00Z", 807 "knot": "http://10.0.0.5:3000", 808 "name": "barnacle", 809 }); 810 let uri = format!("at://{}/sh.tangled.repo/{}", owner.as_ref(), rk.as_ref()); 811 Mock::given(method("GET")) 812 .and(path("/xrpc/com.atproto.repo.getRecord")) 813 .and(query_param("repo", owner.as_ref())) 814 .and(query_param("collection", "sh.tangled.repo")) 815 .and(query_param("rkey", rk.as_ref())) 816 .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 817 "uri": uri, 818 "cid": CID, 819 "value": record, 820 }))) 821 .mount(&h.slingshot) 822 .await; 823 let resp = h 824 .call(&format!( 825 "/xrpc/sh.tangled.repo.blob?repo={}&path=x", 826 enc(&uri), 827 )) 828 .await; 829 assert_eq!(resp.status(), StatusCode::BAD_GATEWAY); 830 let v = body_value(resp).await; 831 assert_eq!(v["error"], "InvalidRecord"); 832} 833 834#[tokio::test] 835async fn strips_basic_auth_from_credentialed_knot_url() { 836 let h = Harness::new().await; 837 let parsed = Url::parse(&h.knot.uri()).unwrap(); 838 let knot_with_creds = format!( 839 "{}://attacker:secret@{}:{}/", 840 parsed.scheme(), 841 parsed.host_str().unwrap(), 842 parsed.port().unwrap(), 843 ); 844 let owner = did("did:plc:abalone"); 845 let rk = rkey("r1"); 846 let record = json!({ 847 "$type": "sh.tangled.repo", 848 "createdAt": "2026-05-01T00:00:00Z", 849 "knot": knot_with_creds, 850 "name": "barnacle", 851 }); 852 let uri = format!("at://{}/sh.tangled.repo/{}", owner.as_ref(), rk.as_ref()); 853 Mock::given(method("GET")) 854 .and(path("/xrpc/com.atproto.repo.getRecord")) 855 .and(query_param("repo", owner.as_ref())) 856 .and(query_param("collection", "sh.tangled.repo")) 857 .and(query_param("rkey", rk.as_ref())) 858 .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 859 "uri": uri, 860 "cid": CID, 861 "value": record, 862 }))) 863 .mount(&h.slingshot) 864 .await; 865 Mock::given(method("GET")) 866 .and(path("/xrpc/sh.tangled.repo.blob")) 867 .respond_with( 868 ResponseTemplate::new(200).set_body_raw(r#"{"path":"x"}"#, "application/json"), 869 ) 870 .mount(&h.knot) 871 .await; 872 let target = format!("/xrpc/sh.tangled.repo.blob?repo={}&path=x", enc(&uri)); 873 let resp = h.call(&target).await; 874 assert_eq!(resp.status(), StatusCode::OK); 875 let received = h.knot.received_requests().await.unwrap(); 876 let knot_call = received 877 .iter() 878 .find(|r| r.url.path() == "/xrpc/sh.tangled.repo.blob") 879 .expect("knot received the proxied call"); 880 assert!( 881 knot_call.headers.get("authorization").is_none(), 882 "userinfo in knot field must not become an Authorization header", 883 ); 884} 885 886#[tokio::test] 887async fn knot_redirect_surfaces_as_upstream_failed() { 888 let h = Harness::new().await; 889 let secondary = MockServer::start().await; 890 h.mount_repo_record(&did("did:plc:abalone"), &rkey("r1"), "barnacle") 891 .await; 892 Mock::given(method("GET")) 893 .and(path("/xrpc/sh.tangled.repo.blob")) 894 .respond_with( 895 ResponseTemplate::new(302) 896 .insert_header("location", &format!("{}/secret", secondary.uri())), 897 ) 898 .mount(&h.knot) 899 .await; 900 Mock::given(method("GET")) 901 .and(path("/secret")) 902 .respond_with(ResponseTemplate::new(200).set_body_string("leaked")) 903 .mount(&secondary) 904 .await; 905 let resp = h 906 .call(&format!( 907 "/xrpc/sh.tangled.repo.blob?repo={}&path=x", 908 enc("at://did:plc:abalone/sh.tangled.repo/r1"), 909 )) 910 .await; 911 assert_eq!(resp.status(), StatusCode::BAD_GATEWAY); 912 let v = body_value(resp).await; 913 assert_eq!(v["error"], "UpstreamFailed"); 914 let received = secondary.received_requests().await.unwrap(); 915 assert!(received.is_empty(), "redirect target must not be dialled"); 916} 917 918#[tokio::test] 919async fn forwards_repeated_query_params() { 920 let h = Harness::new().await; 921 h.mount_repo_record(&did("did:plc:limpet"), &rkey("r4"), "kelp") 922 .await; 923 Mock::given(method("GET")) 924 .and(path("/xrpc/sh.tangled.repo.tags")) 925 .respond_with(ResponseTemplate::new(200).set_body_raw(r#"{"tags":[]}"#, "application/json")) 926 .mount(&h.knot) 927 .await; 928 let target = format!( 929 "/xrpc/sh.tangled.repo.tags?repo={}&filter=alpha&filter=beta", 930 enc("at://did:plc:limpet/sh.tangled.repo/r4"), 931 ); 932 let resp = h.call(&target).await; 933 assert_eq!(resp.status(), StatusCode::OK); 934 let received = h.knot.received_requests().await.unwrap(); 935 let knot_call = received 936 .iter() 937 .find(|r| r.url.path() == "/xrpc/sh.tangled.repo.tags") 938 .expect("knot received the proxied call"); 939 let filters: Vec<String> = knot_call 940 .url 941 .query_pairs() 942 .filter(|(k, _)| k == "filter") 943 .map(|(_, v)| v.into_owned()) 944 .collect(); 945 assert_eq!(filters, vec!["alpha".to_owned(), "beta".to_owned()]); 946} 947 948#[tokio::test] 949async fn duplicate_repo_param_rejected_as_invalid_request() { 950 let h = Harness::new().await; 951 let target = format!( 952 "/xrpc/sh.tangled.repo.blob?repo={}&repo={}", 953 enc("at://did:plc:abalone/sh.tangled.repo/r1"), 954 enc("at://did:plc:limpet/sh.tangled.repo/r2"), 955 ); 956 let resp = h.call(&target).await; 957 assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 958 let v = body_value(resp).await; 959 assert_eq!(v["error"], "InvalidRequest"); 960 assert!( 961 v["message"] 962 .as_str() 963 .unwrap_or_default() 964 .contains("repo parameter must appear at most once"), 965 "got {v}", 966 ); 967} 968 969#[tokio::test] 970async fn duplicate_knot_param_rejected_as_invalid_request() { 971 let h = Harness::new().await; 972 let target = format!( 973 "/xrpc/sh.tangled.knot.version?knot={}&knot={}", 974 enc("https://oyster.cafe"), 975 enc("https://nel.pet"), 976 ); 977 let resp = h.call(&target).await; 978 assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 979 let v = body_value(resp).await; 980 assert_eq!(v["error"], "InvalidRequest"); 981} 982 983#[tokio::test] 984async fn rejects_client_supplied_plaintext_when_https_required() { 985 let strict = KnotProxyConfig { 986 require_https: true, 987 ..test_config() 988 }; 989 let h = Harness::with_config(strict).await; 990 let resp = h 991 .call(&format!( 992 "/xrpc/sh.tangled.knot.version?knot={}", 993 enc("http://oyster.cafe"), 994 )) 995 .await; 996 assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 997 let v = body_value(resp).await; 998 assert_eq!(v["error"], "InvalidRequest"); 999 assert!( 1000 v["message"] 1001 .as_str() 1002 .unwrap_or_default() 1003 .contains("must be https"), 1004 "got {v}", 1005 ); 1006} 1007 1008#[tokio::test] 1009async fn record_with_plaintext_knot_returns_invalid_record_when_https_required() { 1010 let strict = KnotProxyConfig { 1011 require_https: true, 1012 allow_private_hosts: true, 1013 ..test_config() 1014 }; 1015 let h = Harness::with_config(strict).await; 1016 let owner = did("did:plc:abalone"); 1017 let rk = rkey("r1"); 1018 let record = json!({ 1019 "$type": "sh.tangled.repo", 1020 "createdAt": "2026-05-01T00:00:00Z", 1021 "knot": "http://oyster.cafe", 1022 "name": "barnacle", 1023 }); 1024 let uri = format!("at://{}/sh.tangled.repo/{}", owner.as_ref(), rk.as_ref()); 1025 Mock::given(method("GET")) 1026 .and(path("/xrpc/com.atproto.repo.getRecord")) 1027 .and(query_param("repo", owner.as_ref())) 1028 .and(query_param("collection", "sh.tangled.repo")) 1029 .and(query_param("rkey", rk.as_ref())) 1030 .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 1031 "uri": uri, 1032 "cid": CID, 1033 "value": record, 1034 }))) 1035 .mount(&h.slingshot) 1036 .await; 1037 let resp = h 1038 .call(&format!( 1039 "/xrpc/sh.tangled.repo.blob?repo={}&path=x", 1040 enc(&uri), 1041 )) 1042 .await; 1043 assert_eq!(resp.status(), StatusCode::BAD_GATEWAY); 1044 let v = body_value(resp).await; 1045 assert_eq!(v["error"], "InvalidRecord"); 1046 assert!( 1047 v["message"] 1048 .as_str() 1049 .unwrap_or_default() 1050 .contains("requires https"), 1051 "got {v}", 1052 ); 1053} 1054 1055#[tokio::test] 1056async fn knot_not_modified_passes_through() { 1057 let h = Harness::new().await; 1058 h.mount_repo_record(&did("did:plc:limpet"), &rkey("r5"), "kelp") 1059 .await; 1060 Mock::given(method("GET")) 1061 .and(path("/xrpc/sh.tangled.repo.archive")) 1062 .respond_with(ResponseTemplate::new(304).insert_header("etag", "\"v1\"")) 1063 .mount(&h.knot) 1064 .await; 1065 let target = format!( 1066 "/xrpc/sh.tangled.repo.archive?repo={}&ref=v1", 1067 enc("at://did:plc:limpet/sh.tangled.repo/r5"), 1068 ); 1069 let resp = h 1070 .call_with_headers(&target, &[hdr("if-none-match", "\"v1\"")]) 1071 .await; 1072 assert_eq!(resp.status(), StatusCode::NOT_MODIFIED); 1073 assert_eq!(resp.headers().get("etag").unwrap(), "\"v1\""); 1074}