shiguredo_container 2026.1.0-canary.8

Runtime-agnostic container library for Rust on macOS and Linux
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
//! HTTP 待機戦略。本家 testcontainers-rs 0.27 の `core::wait::http_strategy` に相当。
//!
//! 本家 testcontainers-rs は reqwest を使うが、shiguredo は依存最小方針のため
//! `shiguredo_http11` + `tokio::net::TcpStream` で plain HTTP のみ対応する (TLS 非対応)。
//! そのため本家の reqwest 型を受け取る API (`with_client` / `with_method(reqwest::Method)` /
//! `with_response_matcher(reqwest::Response)`) は shiguredo 独自の型に置き換えている。

use std::{fmt, sync::Arc, time::Duration};

use base64ct::{Base64, Encoding};
use tokio::io::{AsyncReadExt, AsyncWriteExt};

use crate::{
    ContainerAsync, Image,
    core::{
        client::Client,
        error::{Result, WaitContainerError},
        host::Host,
        ports::ContainerPort,
    },
};

const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_HTTP_RESPONSE_BODY_BYTES: usize = 1024 * 1024;

/// HTTP 待機のエラー。元の 0.27 の `HttpWaitError` に相当。
#[derive(Debug)]
pub enum HttpWaitError {
    /// コンテナに公開ポートが無い。
    NoExposedPortsForHttpWait,
    /// response matcher が未設定。
    /// 本家は待機ループ内で `TestcontainersError::other` を返すが、shiguredo は型付きにする。
    NoResponseMatcher,
    /// HTTP リクエストの構築に失敗した。
    RequestBuild(String),
}

impl fmt::Display for HttpWaitError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            HttpWaitError::NoExposedPortsForHttpWait => {
                write!(f, "container has no exposed ports")
            }
            HttpWaitError::NoResponseMatcher => {
                write!(
                    f,
                    "no response matcher provided for HTTP wait strategy (use with_expected_status_code or with_response_matcher)"
                )
            }
            HttpWaitError::RequestBuild(message) => write!(f, "{message}"),
        }
    }
}

impl std::error::Error for HttpWaitError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        None
    }
}

/// response matcher に渡す HTTP レスポンス。
///
/// 本家は `reqwest::Response` を渡すが、shiguredo は受信済みのレスポンスを保持する
/// 独自型を渡す (body 読み出しが不要なため matcher は同期関数でよい)。
#[derive(Debug, Clone)]
pub struct HttpResponse {
    status: u16,
    headers: Vec<(String, String)>,
    body: Vec<u8>,
}

impl HttpResponse {
    /// HTTP ステータスコードを返す。
    pub fn status(&self) -> u16 {
        self.status
    }

    /// 受信順のヘッダ一覧を返す。
    pub fn headers(&self) -> &[(String, String)] {
        &self.headers
    }

    /// 指定名のヘッダ値を返す (名前は大文字小文字を無視)。
    pub fn header(&self, name: &str) -> Option<&str> {
        self.headers
            .iter()
            .find(|(k, _)| k.eq_ignore_ascii_case(name))
            .map(|(_, v)| v.as_str())
    }

    /// レスポンスボディを返す。
    ///
    /// 保持量は最大 1 MiB であり、それを超えた部分は切り詰められる。
    pub fn body(&self) -> &[u8] {
        &self.body
    }
}

type ResponseMatcher = Arc<dyn Fn(&HttpResponse) -> bool + Send + Sync + 'static>;

#[derive(Clone)]
enum Auth {
    Basic { username: String, password: String },
    Bearer(String),
}

/// HTTP レスポンスによる待機戦略。元の 0.27 の `HttpWaitStrategy` と同等の API。
#[derive(Clone)]
pub struct HttpWaitStrategy {
    path: String,
    port: Option<ContainerPort>,
    method: String,
    headers: Vec<(String, String)>,
    body: Option<Vec<u8>>,
    auth: Option<Auth>,
    response_matcher: Option<ResponseMatcher>,
    poll_interval: Duration,
    request_timeout: Duration,
}

impl HttpWaitStrategy {
    /// 指定パスに対する待機戦略を作る (デフォルトは GET)。
    pub fn new(path: impl Into<String>) -> Self {
        Self {
            path: path.into(),
            port: None,
            method: "GET".to_string(),
            headers: Vec::new(),
            body: None,
            auth: None,
            response_matcher: None,
            poll_interval: Duration::from_millis(100),
            request_timeout: DEFAULT_REQUEST_TIMEOUT,
        }
    }

    /// リクエスト先のコンテナポートを指定する。
    ///
    /// 対応するホスト側ポートに接続する。未指定なら最初の公開ポートを使う。
    pub fn with_port(mut self, port: ContainerPort) -> Self {
        self.port = Some(port);
        self
    }

    /// HTTP メソッドを指定する。本家は `reqwest::Method` だが shiguredo は文字列。
    pub fn with_method(mut self, method: impl Into<String>) -> Self {
        self.method = method.into();
        self
    }

    /// リクエストヘッダを追加する。
    pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
        self.headers.push((key.into(), value.into()));
        self
    }

    /// リクエストボディを設定する。
    pub fn with_body(mut self, body: impl Into<Vec<u8>>) -> Self {
        self.body = Some(body.into());
        self
    }

    /// Basic 認証を設定する。既に設定済みの認証を上書きする。
    pub fn with_basic_auth(
        mut self,
        username: impl Into<String>,
        password: impl Into<String>,
    ) -> Self {
        self.auth = Some(Auth::Basic {
            username: username.into(),
            password: password.into(),
        });
        self
    }

    /// Bearer トークンを設定する。既に設定済みの認証を上書きする。
    pub fn with_bearer_auth(mut self, token: impl Into<String>) -> Self {
        self.auth = Some(Auth::Bearer(token.into()));
        self
    }

    /// ポーリング間隔を設定する。
    pub fn with_poll_interval(mut self, poll_interval: Duration) -> Self {
        self.poll_interval = poll_interval;
        self
    }

    /// HTTP リクエスト 1 回のタイムアウトを設定する。
    ///
    /// 既定値は 10 秒で、接続・送信・レスポンスヘッダおよびボディ受信全体に適用する。
    /// `Duration::ZERO` は即座にタイムアウトとして扱われ、待機ループでリトライする。
    pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
        self.request_timeout = timeout;
        self
    }

    /// 期待するステータスコードを設定する。
    /// `with_response_matcher(|response| response.status() == status)` のショートカット。
    pub fn with_expected_status_code(self, status: impl Into<u16>) -> Self {
        let status = status.into();
        self.with_response_matcher(move |response| response.status() == status)
    }

    /// レスポンスの一致条件を設定する。`true` を返したら待機完了。
    pub fn with_response_matcher<Matcher>(mut self, matcher: Matcher) -> Self
    where
        Matcher: Fn(&HttpResponse) -> bool + Send + Sync + 'static,
    {
        self.response_matcher = Some(Arc::new(matcher));
        self
    }

    /// 戦略が制御するヘッダと衝突するユーザー指定ヘッダを判定する。
    fn is_reserved_header(&self, name: &str) -> bool {
        name.eq_ignore_ascii_case("Host")
            || name.eq_ignore_ascii_case("Connection")
            || (self.body.is_some() && name.eq_ignore_ascii_case("Content-Length"))
            || (self.auth.is_some() && name.eq_ignore_ascii_case("Authorization"))
    }

    /// Host ヘッダ値を組み立てる。
    fn host_header(host: &str, port: u16) -> String {
        if host.contains(':') {
            format!("[{host}]:{port}")
        } else {
            format!("{host}:{port}")
        }
    }

    /// リトライ前に 1 回だけ HTTP リクエストを構築する。
    fn build_request_bytes(
        &self,
        host: &str,
        port: u16,
    ) -> std::result::Result<Vec<u8>, HttpWaitError> {
        use shiguredo_http11::Request;

        let method = shiguredo_http11::Method::new(&self.method)
            .map_err(|error| HttpWaitError::RequestBuild(error.to_string()))?;
        // Connection: close を明示しないと keep-alive でサーバーが接続を保持し、
        // EOF 待ちでポーリングが数十秒単位で停滞する。
        let mut request = Request::new(method, self.path.as_str())
            .and_then(|request| request.header("Host", Self::host_header(host, port)))
            .and_then(|request| request.header("Connection", "close"))
            .map_err(|error| HttpWaitError::RequestBuild(error.to_string()))?;
        let mut warned_headers = Vec::new();
        for (name, value) in &self.headers {
            if self.is_reserved_header(name) {
                let normalized_name = name.to_ascii_lowercase();
                if !warned_headers.contains(&normalized_name) {
                    tracing::warn!(
                        header = normalized_name.as_str(),
                        "skipping user-supplied reserved HTTP header"
                    );
                    warned_headers.push(normalized_name);
                }
                continue;
            }
            request = request
                .header(
                    shiguredo_http11::HeaderName::new(name).map_err(|_| {
                        HttpWaitError::RequestBuild(format!("invalid header name: {name}"))
                    })?,
                    value.as_str(),
                )
                .map_err(|_| {
                    // ヘッダ値はシークレットになり得るためエラー文字列に含めない。
                    HttpWaitError::RequestBuild(format!("failed to set header `{name}`"))
                })?;
        }
        match &self.auth {
            Some(Auth::Basic { username, password }) => {
                // RFC 4648 (パディングあり)。shiguredo-rust の base64ct 規約に従う。
                let credentials =
                    Base64::encode_string(format!("{username}:{password}").as_bytes());
                request = request
                    .header("Authorization", format!("Basic {credentials}"))
                    .map_err(|_| {
                        HttpWaitError::RequestBuild(
                            "failed to set Authorization header".to_string(),
                        )
                    })?;
            }
            Some(Auth::Bearer(token)) => {
                request = request
                    .header("Authorization", format!("Bearer {token}"))
                    .map_err(|_| {
                        HttpWaitError::RequestBuild(
                            "failed to set Authorization header".to_string(),
                        )
                    })?;
            }
            None => {}
        }
        if let Some(body) = &self.body {
            // Request がボディ長と一致する Content-Length を自動で付与する。
            request = request.body(body.clone());
        }
        request
            .encode()
            .map_err(|error| HttpWaitError::RequestBuild(error.to_string()))
    }

    /// リクエストを 1 回送信してレスポンスを受信する。
    /// 接続失敗・プロトコルエラーは呼び出し側でリトライする。
    async fn send_request(
        &self,
        host: &str,
        port: u16,
        request_bytes: &[u8],
    ) -> std::result::Result<HttpResponse, Box<dyn std::error::Error + Send + Sync>> {
        if self.request_timeout.is_zero() {
            return Err(std::io::Error::new(
                std::io::ErrorKind::TimedOut,
                "HTTP request timed out",
            )
            .into());
        }
        tokio::time::timeout(
            self.request_timeout,
            self.send_request_inner(host, port, request_bytes),
        )
        .await
        .map_err(|_| std::io::Error::new(std::io::ErrorKind::TimedOut, "HTTP request timed out"))?
    }

    async fn send_request_inner(
        &self,
        host: &str,
        port: u16,
        request_bytes: &[u8],
    ) -> std::result::Result<HttpResponse, Box<dyn std::error::Error + Send + Sync>> {
        use crate::core::client::http_decode::{BodyLimit, ResponseAccumulator};

        let mut stream = tokio::net::TcpStream::connect((host, port)).await?;
        stream.write_all(request_bytes).await?;

        // HTTP 待機はボディの 1 MiB 超を切り詰めて続行する (既存挙動)。
        let mut acc = ResponseAccumulator::new(
            &self.method,
            BodyLimit::Truncate(MAX_HTTP_RESPONSE_BODY_BYTES),
        );

        loop {
            let want = acc.read_buf_size();
            if want == 0 {
                return Err("decoder buffer full".into());
            }
            let buf = acc.mut_buf(want)?;
            let n = stream.read(buf).await?;
            if acc.feed(n)? {
                break;
            }
        }

        let decoded = acc.finish()?;
        Ok(HttpResponse {
            status: decoded.head.status_code(),
            headers: decoded
                .head
                .headers()
                .iter()
                .map(|(name, value)| (name.as_str().to_string(), value.clone()))
                .collect(),
            body: decoded.body,
        })
    }
}

impl HttpWaitStrategy {
    pub(crate) async fn wait_until_ready<I: Image>(
        self,
        _client: &Client,
        container: &ContainerAsync<I>,
    ) -> Result<()> {
        // 本家は待機ループ内で matcher 未設定をエラーにするが、ループ前に検査する。
        let matcher = self
            .response_matcher
            .clone()
            .ok_or(WaitContainerError::HttpWait(
                HttpWaitError::NoResponseMatcher,
            ))?;

        let host = container.get_host().await?;

        // ポート未指定なら最初の公開ポートを使う (本家と同じ)。
        let container_port = match self.port {
            Some(port) => port,
            None => container.ports().await?.first_container_port().ok_or(
                WaitContainerError::HttpWait(HttpWaitError::NoExposedPortsForHttpWait),
            )?,
        };

        // ホスト側ポートを解決。IPv4 が無ければ IPv6 にフォールバック (本家と同じ)。
        let host_port = match host {
            Host::Addr(std::net::IpAddr::V6(_)) => {
                container.get_host_port_ipv6(container_port).await?
            }
            _ => match container.get_host_port_ipv4(container_port).await {
                Ok(port) => port,
                Err(_) => container.get_host_port_ipv6(container_port).await?,
            },
        };

        let host = host.to_string();
        let request_bytes = self
            .build_request_bytes(&host, host_port)
            .map_err(WaitContainerError::HttpWait)?;
        loop {
            match self.send_request(&host, host_port, &request_bytes).await {
                Ok(response) => {
                    if matcher(&response) {
                        return Ok(());
                    }
                    tracing::debug!("HTTP response condition not met");
                }
                Err(e) => {
                    // 起動前の接続拒否などはリトライする。
                    // 全体は AsyncRunner の startup_timeout で打ち切られる。
                    tracing::debug!("error while waiting for HTTP response: {e}");
                }
            }
            tokio::time::sleep(self.poll_interval).await;
        }
    }
}

impl fmt::Debug for HttpWaitStrategy {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("HttpWaitStrategy")
            .field("path", &self.path)
            .field("port", &self.port)
            .field("method", &self.method)
            .field("headers", &RedactedHeaders(&self.headers))
            .field("body_len", &self.body.as_ref().map(Vec::len))
            .field("auth", &self.auth)
            .field("poll_interval", &self.poll_interval)
            .field("request_timeout", &self.request_timeout)
            .finish()
    }
}

impl fmt::Debug for Auth {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Auth::Basic { username, .. } => f
                .debug_struct("Basic")
                .field("username", username)
                .field("password", &"***")
                .finish(),
            Auth::Bearer(_) => f.debug_tuple("Bearer").field(&"***").finish(),
        }
    }
}

struct RedactedHeaders<'a>(&'a [(String, String)]);

impl fmt::Debug for RedactedHeaders<'_> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let headers: Vec<(&str, &str)> = self
            .0
            .iter()
            .map(|(name, value)| {
                (
                    name.as_str(),
                    if name.eq_ignore_ascii_case("Authorization") {
                        "***"
                    } else {
                        value.as_str()
                    },
                )
            })
            .collect();
        headers.fmt(f)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn base64_encode_rfc4648_test_vectors() {
        // base64ct 置換後の回帰確認 (RFC 4648 / RFC 7617)。
        assert_eq!(Base64::encode_string(b""), "");
        assert_eq!(Base64::encode_string(b"f"), "Zg==");
        assert_eq!(Base64::encode_string(b"fo"), "Zm8=");
        assert_eq!(Base64::encode_string(b"foo"), "Zm9v");
        assert_eq!(Base64::encode_string(b"foob"), "Zm9vYg==");
        assert_eq!(Base64::encode_string(b"fooba"), "Zm9vYmE=");
        assert_eq!(Base64::encode_string(b"foobar"), "Zm9vYmFy");
        assert_eq!(
            Base64::encode_string(b"Aladdin:open sesame"),
            "QWxhZGRpbjpvcGVuIHNlc2FtZQ=="
        );
    }

    #[test]
    fn expected_status_code_sets_matcher() {
        let strategy = HttpWaitStrategy::new("/health").with_expected_status_code(200_u16);
        let matcher = strategy
            .response_matcher
            .expect("matcher が設定されていること");
        let ok = HttpResponse {
            status: 200,
            headers: vec![],
            body: vec![],
        };
        let ng = HttpResponse {
            status: 503,
            headers: vec![],
            body: vec![],
        };
        assert!(matcher(&ok));
        assert!(!matcher(&ng));
    }

    #[test]
    fn http_response_header_is_case_insensitive() {
        let response = HttpResponse {
            status: 200,
            headers: vec![("Content-Type".to_string(), "text/html".to_string())],
            body: vec![],
        };
        assert_eq!(response.header("content-type"), Some("text/html"));
        assert_eq!(response.header("CONTENT-TYPE"), Some("text/html"));
        assert_eq!(response.header("x-missing"), None);
    }

    #[test]
    fn request_build_failure_is_returned_immediately() {
        let strategy = HttpWaitStrategy::new("/health").with_method("invalid method");

        let error = strategy
            .build_request_bytes("localhost", 8080)
            .expect_err("不正なメソッドはリクエスト構築に失敗する");

        assert!(matches!(error, HttpWaitError::RequestBuild(_)));
        assert!(std::error::Error::source(&error).is_none());
    }

    #[test]
    fn reserved_headers_are_not_sent_case_insensitively() {
        let strategy = HttpWaitStrategy::new("/health")
            .with_header("host", "user.example")
            .with_header("CONNECTION", "keep-alive")
            .with_header("content-length", "999")
            .with_header("authorization", "Bearer user-token")
            .with_header("X-Request-Id", "request-id")
            .with_body(b"body".to_vec())
            .with_bearer_auth("strategy-token");

        let request = String::from_utf8(
            strategy
                .build_request_bytes("localhost", 8080)
                .expect("リクエストを構築できる"),
        )
        .expect("HTTP リクエストは ASCII");

        assert_eq!(request.matches("\r\nHost:").count(), 1);
        assert_eq!(request.matches("\r\nConnection:").count(), 1);
        assert_eq!(request.matches("\r\nContent-Length:").count(), 1);
        assert_eq!(request.matches("\r\nAuthorization:").count(), 1);
        assert!(request.contains("Host: localhost:8080\r\n"));
        assert!(request.contains("Connection: close\r\n"));
        assert!(request.contains("Content-Length: 4\r\n"));
        assert!(request.contains("Authorization: Bearer strategy-token\r\n"));
        assert!(request.contains("X-Request-Id: request-id\r\n"));
        assert!(!request.contains("user.example"));
        assert!(!request.contains("keep-alive"));
        assert!(!request.contains("999"));
        assert!(!request.contains("user-token"));
    }

    #[test]
    fn authorization_header_is_preserved_without_auth() {
        let strategy =
            HttpWaitStrategy::new("/health").with_header("Authorization", "Bearer user-token");

        let request = String::from_utf8(
            strategy
                .build_request_bytes("localhost", 8080)
                .expect("リクエストを構築できる"),
        )
        .expect("HTTP リクエストは ASCII");

        assert!(request.contains("Authorization: Bearer user-token\r\n"));
    }

    #[test]
    fn ipv6_host_header_is_bracketed() {
        let strategy = HttpWaitStrategy::new("/health");

        let request = String::from_utf8(
            strategy
                .build_request_bytes("2001:db8::1", 8080)
                .expect("リクエストを構築できる"),
        )
        .expect("HTTP リクエストは ASCII");

        assert!(request.contains("Host: [2001:db8::1]:8080\r\n"));
    }

    #[test]
    fn debug_redacts_auth_authorization_and_body() {
        let strategy = HttpWaitStrategy::new("/health")
            .with_header("Authorization", "Bearer header-secret")
            .with_body(b"body-secret".to_vec())
            .with_basic_auth("user", "password-secret");

        let debug = format!("{strategy:?}");

        assert!(debug.contains("user"));
        assert!(debug.contains("***"));
        assert!(debug.contains("body_len: Some(11)"));
        assert!(!debug.contains("header-secret"));
        assert!(!debug.contains("password-secret"));
        assert!(!debug.contains("body-secret"));
    }

    #[tokio::test]
    async fn zero_request_timeout_returns_immediately() {
        let strategy = HttpWaitStrategy::new("/health").with_request_timeout(Duration::ZERO);
        let request = strategy
            .build_request_bytes("127.0.0.1", 1)
            .expect("リクエストを構築できる");

        let error = strategy
            .send_request("127.0.0.1", 1, &request)
            .await
            .expect_err("ZERO は即時にタイムアウトする");

        assert!(
            error
                .downcast_ref::<std::io::Error>()
                .is_some_and(|error| error.kind() == std::io::ErrorKind::TimedOut)
        );
    }

    #[tokio::test]
    async fn request_timeout_limits_unresponsive_server() {
        let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
            .await
            .expect("TCP リスナーを確保できる");
        let port = listener
            .local_addr()
            .expect("TCP リスナーのアドレスを取得できる")
            .port();
        let server = tokio::spawn(async move {
            let (mut stream, _) = listener.accept().await.expect("TCP 接続を受け付ける");
            let mut request = [0_u8; 1024];
            let _ = stream
                .read(&mut request)
                .await
                .expect("リクエストを受信できる");
            tokio::time::sleep(Duration::from_secs(1)).await;
        });
        let strategy =
            HttpWaitStrategy::new("/health").with_request_timeout(Duration::from_millis(20));
        let request = strategy
            .build_request_bytes("127.0.0.1", port)
            .expect("リクエストを構築できる");

        let error = strategy
            .send_request("127.0.0.1", port, &request)
            .await
            .expect_err("無応答サーバーはタイムアウトする");

        assert!(
            error
                .downcast_ref::<std::io::Error>()
                .is_some_and(|error| error.kind() == std::io::ErrorKind::TimedOut)
        );
        server.abort();
    }

    #[tokio::test]
    async fn response_body_is_limited_to_one_mebibyte() {
        let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
            .await
            .expect("TCP リスナーを確保できる");
        let port = listener
            .local_addr()
            .expect("TCP リスナーのアドレスを取得できる")
            .port();
        let body = vec![b'x'; MAX_HTTP_RESPONSE_BODY_BYTES + 1];
        let server = tokio::spawn(async move {
            let (mut stream, _) = listener.accept().await.expect("TCP 接続を受け付ける");
            let mut request = [0_u8; 1024];
            let _ = stream
                .read(&mut request)
                .await
                .expect("リクエストを受信できる");
            let response = format!(
                "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
                body.len()
            );
            stream
                .write_all(response.as_bytes())
                .await
                .expect("レスポンスヘッダを送信できる");
            stream
                .write_all(&body)
                .await
                .expect("レスポンスボディを送信できる");
        });
        let strategy = HttpWaitStrategy::new("/health");
        let request = strategy
            .build_request_bytes("127.0.0.1", port)
            .expect("リクエストを構築できる");

        let response = strategy
            .send_request("127.0.0.1", port, &request)
            .await
            .expect("レスポンスを受信できる");

        assert_eq!(
            response.status(),
            200,
            "切り詰め後もステータスが読み取れること"
        );
        assert_eq!(response.body().len(), MAX_HTTP_RESPONSE_BODY_BYTES);
        assert!(response.body().iter().all(|byte| *byte == b'x'));
        server.await.expect("TCP サーバーが完了する");
    }

    /// 10 MiB 超のレスポンスでも 1 MiB で切り詰めて続行し、エラーにならないこと。
    ///
    /// 修正前はデコーダの既定 `max_body_size` (10 MiB) が先に発動し、Content-Length
    /// フレーミングの 10 MiB 超応答で `body too large` エラーになっていた。
    #[tokio::test]
    async fn response_body_over_ten_mebibytes_is_truncated_to_one_mebibyte() {
        let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
            .await
            .expect("TCP リスナーを確保できる");
        let port = listener
            .local_addr()
            .expect("TCP リスナーのアドレスを取得できる")
            .port();
        // デコーダ既定上限 (10 MiB) を超えるサイズ。保持量は 1 MiB に切り詰められる。
        let body = vec![b'x'; 10 * 1024 * 1024 + 1];
        let server = tokio::spawn(async move {
            let (mut stream, _) = listener.accept().await.expect("TCP 接続を受け付ける");
            let mut request = [0_u8; 1024];
            let _ = stream
                .read(&mut request)
                .await
                .expect("リクエストを受信できる");
            let response = format!(
                "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
                body.len()
            );
            stream
                .write_all(response.as_bytes())
                .await
                .expect("レスポンスヘッダを送信できる");
            // 10 MiB 超のボディを一括送信する (request_timeout 内に収まる)。
            stream
                .write_all(&body)
                .await
                .expect("レスポンスボディを送信できる");
        });
        let strategy = HttpWaitStrategy::new("/health");
        let request = strategy
            .build_request_bytes("127.0.0.1", port)
            .expect("リクエストを構築できる");

        let response = strategy
            .send_request("127.0.0.1", port, &request)
            .await
            .expect("10 MiB 超のレスポンスでもエラーにならないこと");

        assert_eq!(
            response.status(),
            200,
            "切り詰め後もステータスが読み取れること"
        );
        assert_eq!(response.body().len(), MAX_HTTP_RESPONSE_BODY_BYTES);
        assert!(response.body().iter().all(|byte| *byte == b'x'));
        server.await.expect("TCP サーバーが完了する");
    }
}