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