This repository has no description
0

Configure Feed

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

core / bobbin / crates / knot-proxy / src / lib.rs
25 kB 797 lines
1use std::path::PathBuf; 2use std::pin::Pin; 3use std::sync::Arc; 4use std::task::{Context, Poll}; 5use std::time::Duration; 6 7use bobbin_runtime::{ 8 BodyStream as InnerBodyStream, Clock, ExtraCaError, HttpRequest, HttpResponseHead, 9 HttpTransport, NetworkError, ReqwestHttp, RuntimeHasher, load_extra_ca_cert, 10}; 11use bytes::Bytes; 12use futures::Stream; 13use http::{HeaderMap, StatusCode}; 14use jacquard_common::BosStr; 15use jacquard_common::types::nsid::Nsid; 16use reqwest::{Client, redirect::Policy}; 17use scc::HashMap as SccMap; 18use thiserror::Error; 19use url::Url; 20 21mod breaker; 22mod dns; 23mod host; 24 25pub use breaker::{Breaker, BreakerPermit, CircuitOpen, FailureThreshold, ThresholdError}; 26pub use dns::PrivateAddressFilter; 27pub use host::{KnotHost, KnotHostError, PrivateHostReason, RepoSlug, RepoSlugError, classify_ip}; 28 29const USER_AGENT: &str = concat!("bobbin/", env!("CARGO_PKG_VERSION")); 30const HTTPS_SCHEME: &str = "https"; 31 32#[derive(Clone, Debug)] 33pub struct KnotProxyConfig { 34 pub failure_threshold: FailureThreshold, 35 pub cooldown: Duration, 36 pub allow_private_hosts: bool, 37 pub require_https: bool, 38 /// Extra PEM CA certificate to trust for knot TLS connections, in 39 /// addition to the bundled webpki roots. For local dev CAs only. 40 pub extra_ca_cert: Option<PathBuf>, 41} 42 43impl Default for KnotProxyConfig { 44 fn default() -> Self { 45 Self { 46 failure_threshold: FailureThreshold::new(5).expect("nonzero literal"), 47 cooldown: Duration::from_secs(30), 48 allow_private_hosts: false, 49 require_https: true, 50 extra_ca_cert: None, 51 } 52 } 53} 54 55#[derive(Clone, Copy, Debug)] 56pub struct KnotHttpConfig { 57 pub connect_timeout: Duration, 58 pub read_timeout: Duration, 59} 60 61impl Default for KnotHttpConfig { 62 fn default() -> Self { 63 Self { 64 connect_timeout: Duration::from_secs(5), 65 read_timeout: Duration::from_secs(60), 66 } 67 } 68} 69 70#[derive(Debug, Error)] 71pub enum KnotProxyError { 72 #[error("circuit breaker open")] 73 CircuitOpen, 74 #[error("blocked: host {host} resolves to {reason} address space")] 75 BlockedHost { 76 host: String, 77 reason: PrivateHostReason, 78 }, 79 #[error("blocked: knot {host} requires https, got plaintext http")] 80 PlaintextHttp { host: String }, 81 #[error("connect failed: {0}")] 82 Connect(String), 83 #[error("upstream read timed out: {0}")] 84 Timeout(String), 85 #[error("redirect refused: {0}")] 86 Redirect(String), 87 #[error("transport: {0}")] 88 Transport(String), 89 #[error("upstream returned status {0}")] 90 Upstream(StatusCode), 91} 92 93pub struct KnotProxy { 94 http: Arc<dyn HttpTransport>, 95 breakers: SccMap<KnotHost, Arc<Breaker>, RuntimeHasher>, 96 threshold: FailureThreshold, 97 cooldown: Duration, 98 allow_private_hosts: bool, 99 require_https: bool, 100 clock: Arc<dyn Clock>, 101} 102 103#[derive(Debug, Error)] 104pub enum KnotProxyBuildError { 105 #[error("extra ca cert: {0}")] 106 ExtraCa(#[from] ExtraCaError), 107 #[error("build http client: {0}")] 108 Client(#[from] reqwest::Error), 109} 110 111impl KnotProxy { 112 pub fn new( 113 config: KnotProxyConfig, 114 http: KnotHttpConfig, 115 clock: Arc<dyn Clock>, 116 hasher: RuntimeHasher, 117 ) -> Result<Self, KnotProxyBuildError> { 118 let resolver = Arc::new(dns::PrivateAddressFilter::new(config.allow_private_hosts)); 119 let mut builder = Client::builder() 120 .user_agent(USER_AGENT) 121 .connect_timeout(http.connect_timeout) 122 .read_timeout(http.read_timeout) 123 .redirect(Policy::none()) 124 .no_gzip() 125 .no_brotli() 126 .no_deflate() 127 .dns_resolver(resolver); 128 if let Some(path) = &config.extra_ca_cert { 129 builder = builder.add_root_certificate(load_extra_ca_cert(path)?); 130 } 131 let client = builder.build()?; 132 Ok(Self::with_transport( 133 ReqwestHttp::shared(client), 134 config, 135 clock, 136 hasher, 137 )) 138 } 139 140 pub fn with_transport( 141 http: Arc<dyn HttpTransport>, 142 config: KnotProxyConfig, 143 clock: Arc<dyn Clock>, 144 hasher: RuntimeHasher, 145 ) -> Self { 146 Self { 147 http, 148 breakers: SccMap::with_hasher(hasher), 149 threshold: config.failure_threshold, 150 cooldown: config.cooldown, 151 allow_private_hosts: config.allow_private_hosts, 152 require_https: config.require_https, 153 clock, 154 } 155 } 156 157 pub fn allows_private_hosts(&self) -> bool { 158 self.allow_private_hosts 159 } 160 161 pub fn requires_https(&self) -> bool { 162 self.require_https 163 } 164 165 pub async fn forward<S: BosStr + AsRef<str>>( 166 &self, 167 host: &KnotHost, 168 nsid: &Nsid<S>, 169 query: &[(&str, &str)], 170 headers: HeaderMap, 171 ) -> Result<ProxyResponse, KnotProxyError> { 172 self.guard_host(host)?; 173 let breaker = self.breaker_for(host).await; 174 let permit = breaker 175 .try_acquire() 176 .map_err(|_: CircuitOpen| KnotProxyError::CircuitOpen)?; 177 let url = build_xrpc_url(host, nsid, query); 178 let outcome = self.http.execute(HttpRequest { url, headers }).await; 179 classify(outcome, permit) 180 } 181 182 fn guard_host(&self, host: &KnotHost) -> Result<(), KnotProxyError> { 183 let host_str = || host.url().host_str().unwrap_or_default().to_owned(); 184 if self.require_https && host.url().scheme() != HTTPS_SCHEME { 185 return Err(KnotProxyError::PlaintextHttp { host: host_str() }); 186 } 187 if self.allow_private_hosts { 188 return Ok(()); 189 } 190 match host.private_literal_reason() { 191 None => Ok(()), 192 Some(reason) => Err(KnotProxyError::BlockedHost { 193 host: host_str(), 194 reason, 195 }), 196 } 197 } 198 199 async fn breaker_for(&self, host: &KnotHost) -> Arc<Breaker> { 200 if let Some(existing) = self.breakers.read_async(host, |_, v| Arc::clone(v)).await { 201 return existing; 202 } 203 let entry = self.breakers.entry_async(host.clone()).await; 204 Arc::clone( 205 entry 206 .or_insert_with(|| { 207 Arc::new(Breaker::new( 208 self.threshold, 209 self.cooldown, 210 self.clock.clone(), 211 )) 212 }) 213 .get(), 214 ) 215 } 216} 217 218pub struct ProxyResponse { 219 status: StatusCode, 220 headers: HeaderMap, 221 body: InnerBodyStream, 222 permit: BreakerPermit, 223} 224 225impl std::fmt::Debug for ProxyResponse { 226 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 227 f.debug_struct("ProxyResponse") 228 .field("status", &self.status) 229 .field("headers", &self.headers) 230 .finish_non_exhaustive() 231 } 232} 233 234impl ProxyResponse { 235 pub fn status(&self) -> StatusCode { 236 self.status 237 } 238 239 pub fn headers(&self) -> &HeaderMap { 240 &self.headers 241 } 242 243 pub fn into_body_stream(self) -> BodyStream { 244 BodyStream::new(self.body, self.permit) 245 } 246} 247 248pub struct BodyStream { 249 inner: InnerBodyStream, 250 permit: Option<BreakerPermit>, 251} 252 253impl BodyStream { 254 fn new(inner: InnerBodyStream, permit: BreakerPermit) -> Self { 255 Self { 256 inner, 257 permit: Some(permit), 258 } 259 } 260 261 fn resolve(&mut self, success: bool) { 262 if let Some(permit) = self.permit.take() { 263 if success { 264 permit.record_success(); 265 } else { 266 permit.record_failure(); 267 } 268 } 269 } 270} 271 272impl Stream for BodyStream { 273 type Item = Result<Bytes, NetworkError>; 274 275 fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> { 276 let next = match self.inner.as_mut().poll_next(cx) { 277 Poll::Pending => return Poll::Pending, 278 Poll::Ready(item) => item, 279 }; 280 match &next { 281 Some(Ok(_)) => {} 282 Some(Err(_)) => self.resolve(false), 283 None => self.resolve(true), 284 } 285 Poll::Ready(next) 286 } 287} 288 289fn build_xrpc_url<S: BosStr + AsRef<str>>( 290 host: &KnotHost, 291 nsid: &Nsid<S>, 292 query: &[(&str, &str)], 293) -> Url { 294 let mut url = host.xrpc_url(nsid); 295 { 296 let mut pairs = url.query_pairs_mut(); 297 query.iter().for_each(|(k, v)| { 298 pairs.append_pair(k, v); 299 }); 300 } 301 url 302} 303 304fn classify( 305 outcome: Result<HttpResponseHead, NetworkError>, 306 permit: BreakerPermit, 307) -> Result<ProxyResponse, KnotProxyError> { 308 match outcome { 309 Ok(head) if is_upstream_failure(head.status) => { 310 let status = head.status; 311 permit.record_failure(); 312 Err(KnotProxyError::Upstream(status)) 313 } 314 Ok(head) => Ok(ProxyResponse { 315 status: head.status, 316 headers: head.headers, 317 body: head.body, 318 permit, 319 }), 320 Err(err) => { 321 permit.record_failure(); 322 Err(map_network(err)) 323 } 324 } 325} 326 327fn is_upstream_failure(status: StatusCode) -> bool { 328 status.is_server_error() || is_unfollowable_redirect(status) 329} 330 331fn is_unfollowable_redirect(status: StatusCode) -> bool { 332 matches!(status.as_u16(), 301 | 302 | 303 | 307 | 308) 333} 334 335fn map_network(err: NetworkError) -> KnotProxyError { 336 match err { 337 NetworkError::Timeout(msg) => KnotProxyError::Timeout(msg), 338 NetworkError::Connect(msg) => KnotProxyError::Connect(msg), 339 NetworkError::Redirect(msg) => KnotProxyError::Redirect(msg), 340 NetworkError::Transport(msg) | NetworkError::Body(msg) | NetworkError::Protocol(msg) => { 341 KnotProxyError::Transport(msg) 342 } 343 } 344} 345 346#[cfg(test)] 347mod tests { 348 use super::*; 349 use bobbin_runtime::SystemClock; 350 use futures::stream::TryStreamExt; 351 use jacquard_common::DefaultStr; 352 use tokio::io::AsyncWriteExt; 353 use wiremock::matchers::{method, path, query_param}; 354 use wiremock::{Mock, MockServer, ResponseTemplate}; 355 356 fn nsid(s: &'static str) -> Nsid<DefaultStr> { 357 Nsid::new_static(s).unwrap() 358 } 359 360 pub(crate) fn config_for_test() -> KnotProxyConfig { 361 KnotProxyConfig { 362 failure_threshold: FailureThreshold::new(2).unwrap(), 363 cooldown: Duration::from_millis(80), 364 allow_private_hosts: true, 365 require_https: false, 366 extra_ca_cert: None, 367 } 368 } 369 370 pub(crate) fn http_config_for_test() -> KnotHttpConfig { 371 KnotHttpConfig { 372 connect_timeout: Duration::from_millis(500), 373 read_timeout: Duration::from_secs(2), 374 } 375 } 376 377 fn proxy_for_test() -> KnotProxy { 378 KnotProxy::new( 379 config_for_test(), 380 http_config_for_test(), 381 Arc::new(SystemClock::new()), 382 RuntimeHasher::default(), 383 ) 384 .unwrap() 385 } 386 387 async fn server() -> MockServer { 388 MockServer::start().await 389 } 390 391 fn host_of(server: &MockServer) -> KnotHost { 392 KnotHost::parse(&server.uri()).unwrap() 393 } 394 395 pub(crate) async fn drain(stream: BodyStream) -> Result<Bytes, NetworkError> { 396 let chunks: Vec<Bytes> = stream.try_collect().await?; 397 let total: usize = chunks.iter().map(|b| b.len()).sum(); 398 let mut buf = bytes::BytesMut::with_capacity(total); 399 chunks.iter().for_each(|c| buf.extend_from_slice(c)); 400 Ok(buf.freeze()) 401 } 402 403 #[tokio::test] 404 async fn forwards_query_params_and_returns_body() { 405 let server = server().await; 406 Mock::given(method("GET")) 407 .and(path("/xrpc/sh.tangled.repo.blob")) 408 .and(query_param("repo", "did:plc:squid/barnacle")) 409 .and(query_param("ref", "main")) 410 .and(query_param("path", "README.md")) 411 .respond_with( 412 ResponseTemplate::new(200) 413 .insert_header("content-type", "application/json") 414 .set_body_string(r#"{"path":"README.md"}"#), 415 ) 416 .mount(&server) 417 .await; 418 419 let proxy = proxy_for_test(); 420 let resp = proxy 421 .forward( 422 &host_of(&server), 423 &nsid("sh.tangled.repo.blob"), 424 &[ 425 ("repo", "did:plc:squid/barnacle"), 426 ("ref", "main"), 427 ("path", "README.md"), 428 ], 429 HeaderMap::new(), 430 ) 431 .await 432 .expect("happy path"); 433 assert_eq!(resp.status(), 200); 434 let body = drain(resp.into_body_stream()).await.unwrap(); 435 assert_eq!(&body[..], br#"{"path":"README.md"}"#); 436 } 437 438 #[tokio::test] 439 async fn five_hundreds_open_breaker() { 440 let server = server().await; 441 Mock::given(method("GET")) 442 .and(path("/xrpc/sh.tangled.repo.blob")) 443 .respond_with(ResponseTemplate::new(503)) 444 .mount(&server) 445 .await; 446 447 let proxy = proxy_for_test(); 448 let host = host_of(&server); 449 let r1 = proxy 450 .forward(&host, &nsid("sh.tangled.repo.blob"), &[], HeaderMap::new()) 451 .await; 452 assert!(matches!(r1, Err(KnotProxyError::Upstream(_)))); 453 let r2 = proxy 454 .forward(&host, &nsid("sh.tangled.repo.blob"), &[], HeaderMap::new()) 455 .await; 456 assert!(matches!(r2, Err(KnotProxyError::Upstream(_)))); 457 let r3 = proxy 458 .forward(&host, &nsid("sh.tangled.repo.blob"), &[], HeaderMap::new()) 459 .await; 460 assert!(matches!(r3, Err(KnotProxyError::CircuitOpen))); 461 } 462 463 #[tokio::test] 464 async fn four_hundreds_do_not_open_breaker() { 465 let server = server().await; 466 Mock::given(method("GET")) 467 .and(path("/xrpc/sh.tangled.repo.blob")) 468 .respond_with(ResponseTemplate::new(404).set_body_string("not found")) 469 .mount(&server) 470 .await; 471 472 let proxy = proxy_for_test(); 473 let host = host_of(&server); 474 let r1 = proxy 475 .forward(&host, &nsid("sh.tangled.repo.blob"), &[], HeaderMap::new()) 476 .await; 477 assert_eq!(r1.unwrap().status(), 404); 478 let r2 = proxy 479 .forward(&host, &nsid("sh.tangled.repo.blob"), &[], HeaderMap::new()) 480 .await; 481 assert_eq!(r2.unwrap().status(), 404); 482 let r3 = proxy 483 .forward(&host, &nsid("sh.tangled.repo.blob"), &[], HeaderMap::new()) 484 .await; 485 assert_eq!( 486 r3.unwrap().status(), 487 404, 488 "client errors must not trip breaker", 489 ); 490 } 491 492 #[tokio::test] 493 async fn breaker_recovers_after_cooldown() { 494 let server = server().await; 495 Mock::given(method("GET")) 496 .and(path("/xrpc/sh.tangled.repo.blob")) 497 .respond_with(ResponseTemplate::new(503)) 498 .up_to_n_times(2) 499 .mount(&server) 500 .await; 501 Mock::given(method("GET")) 502 .and(path("/xrpc/sh.tangled.repo.blob")) 503 .respond_with( 504 ResponseTemplate::new(200) 505 .insert_header("content-type", "application/json") 506 .set_body_string("ok"), 507 ) 508 .mount(&server) 509 .await; 510 511 let proxy = proxy_for_test(); 512 let host = host_of(&server); 513 let _ = proxy 514 .forward(&host, &nsid("sh.tangled.repo.blob"), &[], HeaderMap::new()) 515 .await; 516 let _ = proxy 517 .forward(&host, &nsid("sh.tangled.repo.blob"), &[], HeaderMap::new()) 518 .await; 519 assert!(matches!( 520 proxy 521 .forward(&host, &nsid("sh.tangled.repo.blob"), &[], HeaderMap::new()) 522 .await, 523 Err(KnotProxyError::CircuitOpen), 524 )); 525 tokio::time::sleep(Duration::from_millis(120)).await; 526 let recovered = proxy 527 .forward(&host, &nsid("sh.tangled.repo.blob"), &[], HeaderMap::new()) 528 .await 529 .expect("must recover after cooldown"); 530 assert_eq!(recovered.status(), 200); 531 let body = drain(recovered.into_body_stream()).await.unwrap(); 532 assert_eq!(&body[..], b"ok"); 533 } 534 535 #[tokio::test] 536 async fn breakers_are_isolated_per_host() { 537 let bad = server().await; 538 let good = server().await; 539 Mock::given(method("GET")) 540 .and(path("/xrpc/sh.tangled.repo.blob")) 541 .respond_with(ResponseTemplate::new(503)) 542 .mount(&bad) 543 .await; 544 Mock::given(method("GET")) 545 .and(path("/xrpc/sh.tangled.repo.blob")) 546 .respond_with( 547 ResponseTemplate::new(200) 548 .insert_header("content-type", "application/json") 549 .set_body_string("ok"), 550 ) 551 .mount(&good) 552 .await; 553 554 let proxy = proxy_for_test(); 555 let bad_host = host_of(&bad); 556 let good_host = host_of(&good); 557 let _ = proxy 558 .forward( 559 &bad_host, 560 &nsid("sh.tangled.repo.blob"), 561 &[], 562 HeaderMap::new(), 563 ) 564 .await; 565 let _ = proxy 566 .forward( 567 &bad_host, 568 &nsid("sh.tangled.repo.blob"), 569 &[], 570 HeaderMap::new(), 571 ) 572 .await; 573 assert!(matches!( 574 proxy 575 .forward( 576 &bad_host, 577 &nsid("sh.tangled.repo.blob"), 578 &[], 579 HeaderMap::new() 580 ) 581 .await, 582 Err(KnotProxyError::CircuitOpen), 583 )); 584 let resp = proxy 585 .forward( 586 &good_host, 587 &nsid("sh.tangled.repo.blob"), 588 &[], 589 HeaderMap::new(), 590 ) 591 .await 592 .expect("healthy host stays open"); 593 assert_eq!(resp.status(), 200); 594 } 595 596 #[tokio::test] 597 async fn build_xrpc_url_appends_query() { 598 let host = KnotHost::parse("https://oyster.cafe").unwrap(); 599 let url = build_xrpc_url( 600 &host, 601 &nsid("sh.tangled.repo.tree"), 602 &[("repo", "did:plc:squid/barnacle"), ("ref", "main")], 603 ); 604 assert_eq!( 605 url.as_str(), 606 "https://oyster.cafe/xrpc/sh.tangled.repo.tree?repo=did%3Aplc%3Asquid%2Fbarnacle&ref=main", 607 ); 608 } 609 610 #[tokio::test] 611 async fn rejects_private_host_by_default() { 612 let server = server().await; 613 let strict = KnotProxyConfig { 614 allow_private_hosts: false, 615 ..config_for_test() 616 }; 617 let proxy = KnotProxy::new( 618 strict, 619 http_config_for_test(), 620 Arc::new(SystemClock::new()), 621 RuntimeHasher::default(), 622 ) 623 .unwrap(); 624 let err = proxy 625 .forward( 626 &host_of(&server), 627 &nsid("sh.tangled.repo.blob"), 628 &[], 629 HeaderMap::new(), 630 ) 631 .await 632 .expect_err("loopback must be blocked under strict config"); 633 assert!(matches!(err, KnotProxyError::BlockedHost { .. })); 634 } 635 636 #[tokio::test] 637 async fn rejects_plaintext_when_https_required() { 638 let server = server().await; 639 let strict = KnotProxyConfig { 640 require_https: true, 641 ..config_for_test() 642 }; 643 let proxy = KnotProxy::new( 644 strict, 645 http_config_for_test(), 646 Arc::new(SystemClock::new()), 647 RuntimeHasher::default(), 648 ) 649 .unwrap(); 650 let err = proxy 651 .forward( 652 &host_of(&server), 653 &nsid("sh.tangled.repo.blob"), 654 &[], 655 HeaderMap::new(), 656 ) 657 .await 658 .expect_err("plaintext http must be rejected under https-required"); 659 assert!( 660 matches!(err, KnotProxyError::PlaintextHttp { .. }), 661 "got {err:?}", 662 ); 663 } 664 665 #[tokio::test] 666 async fn transport_error_trips_breaker() { 667 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); 668 let addr = listener.local_addr().unwrap(); 669 drop(listener); 670 let dead = KnotHost::parse(&format!("http://{addr}")).unwrap(); 671 672 let proxy = proxy_for_test(); 673 let r1 = proxy 674 .forward(&dead, &nsid("sh.tangled.repo.blob"), &[], HeaderMap::new()) 675 .await; 676 assert!( 677 r1.is_err(), 678 "transport must fail against closed port: {r1:?}" 679 ); 680 let r2 = proxy 681 .forward(&dead, &nsid("sh.tangled.repo.blob"), &[], HeaderMap::new()) 682 .await; 683 assert!(r2.is_err(), "second transport must fail: {r2:?}"); 684 let r3 = proxy 685 .forward(&dead, &nsid("sh.tangled.repo.blob"), &[], HeaderMap::new()) 686 .await; 687 assert!( 688 matches!(r3, Err(KnotProxyError::CircuitOpen)), 689 "transport failures must trip breaker, got {r3:?}", 690 ); 691 } 692 693 #[tokio::test] 694 async fn redirects_surface_as_upstream_failure() { 695 let primary = server().await; 696 let secondary = server().await; 697 Mock::given(method("GET")) 698 .and(path("/xrpc/sh.tangled.repo.blob")) 699 .respond_with( 700 ResponseTemplate::new(302) 701 .insert_header("location", &format!("{}/secret", secondary.uri())), 702 ) 703 .mount(&primary) 704 .await; 705 Mock::given(method("GET")) 706 .and(path("/secret")) 707 .respond_with(ResponseTemplate::new(200).set_body_string("leaked")) 708 .mount(&secondary) 709 .await; 710 711 let proxy = proxy_for_test(); 712 let err = proxy 713 .forward( 714 &host_of(&primary), 715 &nsid("sh.tangled.repo.blob"), 716 &[], 717 HeaderMap::new(), 718 ) 719 .await 720 .expect_err("302 must surface as upstream failure"); 721 assert!( 722 matches!(err, KnotProxyError::Upstream(s) if s.as_u16() == 302), 723 "got {err:?}", 724 ); 725 let received = secondary.received_requests().await.unwrap(); 726 assert!(received.is_empty(), "secondary must never be dialled"); 727 } 728 729 #[tokio::test] 730 async fn not_modified_passes_through() { 731 let server = server().await; 732 Mock::given(method("GET")) 733 .and(path("/xrpc/sh.tangled.repo.blob")) 734 .respond_with(ResponseTemplate::new(304).insert_header("etag", "\"v1\"")) 735 .mount(&server) 736 .await; 737 let proxy = proxy_for_test(); 738 let resp = proxy 739 .forward( 740 &host_of(&server), 741 &nsid("sh.tangled.repo.blob"), 742 &[], 743 HeaderMap::new(), 744 ) 745 .await 746 .expect("304 is a cache validator, not a redirect"); 747 assert_eq!(resp.status(), 304); 748 } 749 750 #[tokio::test] 751 async fn mid_stream_drop_records_breaker_failure() { 752 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); 753 let addr = listener.local_addr().unwrap(); 754 let server = tokio::spawn(async move { 755 async fn drop_after_partial(mut socket: tokio::net::TcpStream) { 756 let _ = socket 757 .write_all( 758 b"HTTP/1.1 200 OK\r\nContent-Length: 1024\r\nContent-Type: application/octet-stream\r\n\r\nabcd", 759 ) 760 .await; 761 drop(socket); 762 } 763 let admit = || async { 764 let (socket, _) = listener.accept().await.ok()?; 765 drop_after_partial(socket).await; 766 Some(()) 767 }; 768 admit().await; 769 admit().await; 770 }); 771 772 let host = KnotHost::parse(&format!("http://{addr}")).unwrap(); 773 let proxy = proxy_for_test(); 774 775 let r1 = proxy 776 .forward(&host, &nsid("sh.tangled.repo.blob"), &[], HeaderMap::new()) 777 .await 778 .expect("headers arrive even when body is truncated"); 779 assert_eq!(r1.status(), 200); 780 let _ = drain(r1.into_body_stream()).await; 781 782 let r2 = proxy 783 .forward(&host, &nsid("sh.tangled.repo.blob"), &[], HeaderMap::new()) 784 .await 785 .expect("second call still gets headers"); 786 let _ = drain(r2.into_body_stream()).await; 787 788 let r3 = proxy 789 .forward(&host, &nsid("sh.tangled.repo.blob"), &[], HeaderMap::new()) 790 .await; 791 assert!( 792 matches!(r3, Err(KnotProxyError::CircuitOpen)), 793 "two truncated streams must trip the breaker, got {r3:?}", 794 ); 795 server.abort(); 796 } 797}