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