asupersync 0.3.4

Spec-first, cancel-correct, capability-secure async runtime for Rust.
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
//! TLS error types with security-hardened display formatting.
//!
//! This module provides comprehensive error types for TLS operations in the
//! asupersync runtime, with special attention to preventing log injection attacks
//! and information disclosure through error messages.
//!
//! # Security Design
//!
//! - **Log injection prevention**: Peer-controlled strings are sanitized before logging
//! - **Amplification protection**: Error messages are length-limited to prevent DoS
//! - **Information hiding**: Internal details are not exposed in error displays
//! - **Structured errors**: Errors are properly typed for programmatic handling
//!
//! # Error Categories
//!
//! - **Connection errors**: Handshake failures, protocol violations
//! - **Certificate errors**: Validation, parsing, and chain building failures
//! - **Configuration errors**: Invalid TLS settings or feature mismatches
//! - **I/O errors**: Network layer failures with TLS context

use std::fmt;
use std::io;
use std::time::Duration;

/// Maximum bytes a sanitized peer-controlled string may contribute to
/// a TLS error display. Larger strings are truncated with an ellipsis.
///
/// Defends against log-amplification (a peer could return a multi-KB
/// rustls error, which would explode log volume per failed handshake).
const MAX_SANITIZED_LEN: usize = 256;

/// Strip CR, LF, tab, NUL, and other ASCII control characters from a
/// peer-controlled string before rendering it to the log path.
///
/// br-asupersync-kxw8nx: rustls error strings, peer-supplied SNI values,
/// peer certificate subjects, etc. all flow through Display and end up
/// in structured logs. An attacker who controls these strings can inject
/// embedded `\n` to splice forged log lines (log injection / forgery).
///
/// Sanitization rules:
///   * `\r`, `\n`, `\t` → ASCII space (preserves field separation,
///     prevents line splitting)
///   * Any other ASCII control char (0x00..=0x1F, 0x7F) → replaced with
///     `?` (visible-but-not-special replacement marker)
///   * UTF-8 truncation at MAX_SANITIZED_LEN bytes, cut on a char
///     boundary to avoid invalid UTF-8 in the output, with `…` suffix
///     on truncation
fn sanitize_for_log(input: &str) -> String {
    let mut out = String::with_capacity(input.len().min(MAX_SANITIZED_LEN + 3));
    let mut byte_count = 0usize;
    let mut truncated = false;
    for ch in input.chars() {
        let mapped = match ch {
            '\r' | '\n' | '\t' => ' ',
            // ASCII control chars (excluding the three above already handled)
            c if (c as u32) < 0x20 || c == '\u{7f}' => '?',
            c => c,
        };
        let mapped_len = mapped.len_utf8();
        if byte_count + mapped_len > MAX_SANITIZED_LEN {
            truncated = true;
            break;
        }
        out.push(mapped);
        byte_count += mapped_len;
    }
    if truncated {
        out.push('');
    }
    out
}

/// Error type for TLS operations.
#[derive(Debug)]
pub enum TlsError {
    /// Invalid DNS name for SNI.
    InvalidDnsName(String),
    /// TLS handshake failure.
    Handshake(String),
    /// Certificate error (generic).
    Certificate(String),
    /// Certificate has expired.
    CertificateExpired {
        /// The time the certificate expired (Unix timestamp in seconds).
        expired_at: i64,
        /// Description of the certificate.
        description: String,
    },
    /// Certificate is not yet valid.
    CertificateNotYetValid {
        /// The time the certificate becomes valid (Unix timestamp in seconds).
        valid_from: i64,
        /// Description of the certificate.
        description: String,
    },
    /// Certificate chain validation failed.
    ChainValidation(String),
    /// Certificate pin mismatch.
    PinMismatch {
        /// Expected pin(s).
        expected: Vec<String>,
        /// Actual pin found.
        actual: String,
    },
    /// Configuration error.
    Configuration(String),
    /// TLS support was requested from a build compiled without the `tls` feature.
    FeatureDisabled {
        /// Operation that required TLS support.
        operation: &'static str,
        /// Operator-facing rebuild hint.
        hint: &'static str,
    },
    /// I/O error during TLS operations.
    Io(io::Error),
    /// TLS operation timed out.
    Timeout(Duration),
    /// ALPN negotiation failed or did not meet requirements.
    ///
    /// This is returned when ALPN was configured as required (e.g. HTTP/2-only)
    /// but the peer did not negotiate any protocol, or the negotiated protocol
    /// was not one of the expected values.
    AlpnNegotiationFailed {
        /// The set of acceptable ALPN protocols (in preference order).
        expected: Vec<Vec<u8>>,
        /// The protocol negotiated by the peer, if any.
        negotiated: Option<Vec<u8>>,
    },
    /// Rustls-specific error.
    #[cfg(feature = "tls")]
    Rustls(rustls::Error),
}

impl fmt::Display for TlsError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        // br-asupersync-kxw8nx: every peer-controlled string is wrapped
        // in `sanitize_for_log` before formatting so an attacker cannot
        // inject CR/LF/tab to forge log lines, embed NULs to truncate
        // C-string consumers, or amplify log volume past 256 bytes per
        // error. Operator-controlled fields (Configuration) and
        // structured numeric fields (Timeout duration, expired_at
        // timestamp) are passed through verbatim.
        match self {
            Self::InvalidDnsName(name) => {
                write!(f, "invalid DNS name: {}", sanitize_for_log(name))
            }
            Self::Handshake(msg) => {
                write!(f, "TLS handshake failed: {}", sanitize_for_log(msg))
            }
            Self::Certificate(msg) => {
                write!(f, "certificate error: {}", sanitize_for_log(msg))
            }
            Self::CertificateExpired {
                expired_at,
                description,
            } => write!(
                f,
                "certificate expired at {expired_at}: {}",
                sanitize_for_log(description)
            ),
            Self::CertificateNotYetValid {
                valid_from,
                description,
            } => write!(
                f,
                "certificate not valid until {valid_from}: {}",
                sanitize_for_log(description)
            ),
            Self::ChainValidation(msg) => write!(
                f,
                "certificate chain validation failed: {}",
                sanitize_for_log(msg)
            ),
            Self::PinMismatch { expected, actual } => {
                // Pins are base64 — defensive sanitize anyway in case
                // a misconfigured caller passes raw subject strings.
                let expected_sanitized: Vec<String> =
                    expected.iter().map(|s| sanitize_for_log(s)).collect();
                write!(
                    f,
                    "certificate pin mismatch: expected one of {expected_sanitized:?}, got {}",
                    sanitize_for_log(actual)
                )
            }
            Self::Configuration(msg) => write!(f, "TLS configuration error: {msg}"),
            Self::FeatureDisabled { operation, hint } => {
                write!(f, "TLS feature disabled for {operation}: {hint}")
            }
            Self::Io(err) => {
                // io::Error Display can include peer-controlled paths
                // (e.g., file-not-found with attacker-supplied filename).
                write!(f, "I/O error: {}", sanitize_for_log(&err.to_string()))
            }
            Self::Timeout(duration) => write!(f, "TLS operation timed out after {duration:?}"),
            Self::AlpnNegotiationFailed {
                expected,
                negotiated,
            } => write!(
                f,
                "ALPN negotiation failed: expected one of {expected:?}, negotiated {negotiated:?}"
            ),
            #[cfg(feature = "tls")]
            Self::Rustls(err) => {
                // rustls error strings frequently include peer cert
                // subject CNs and other peer-controlled values.
                write!(f, "rustls error: {}", sanitize_for_log(&err.to_string()))
            }
        }
    }
}

impl std::error::Error for TlsError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Self::Io(err) => Some(err),
            #[cfg(feature = "tls")]
            Self::Rustls(err) => Some(err),
            _ => None,
        }
    }
}

impl From<io::Error> for TlsError {
    fn from(err: io::Error) -> Self {
        Self::Io(err)
    }
}

#[cfg(feature = "tls")]
impl From<rustls::Error> for TlsError {
    fn from(err: rustls::Error) -> Self {
        Self::Rustls(err)
    }
}

#[cfg(test)]
mod tests {
    #![allow(
        clippy::pedantic,
        clippy::nursery,
        clippy::expect_fun_call,
        clippy::map_unwrap_or,
        clippy::cast_possible_wrap,
        clippy::future_not_send
    )]
    use super::*;
    use std::error::Error;

    fn init_test(name: &str) {
        crate::test_utils::init_test_logging();
        crate::test_phase!(name);
    }

    #[test]
    fn test_display_invalid_dns_name() {
        init_test("test_display_invalid_dns_name");
        let err = TlsError::InvalidDnsName("bad.local".to_string());
        let rendered = format!("{err}");
        crate::assert_with_log!(
            rendered.contains("bad.local"),
            "display contains name",
            true,
            rendered.contains("bad.local")
        );
        crate::test_complete!("test_display_invalid_dns_name");
    }

    #[test]
    fn test_display_certificate_expired() {
        init_test("test_display_certificate_expired");
        let err = TlsError::CertificateExpired {
            expired_at: 123,
            description: "leaf".to_string(),
        };
        let rendered = format!("{err}");
        crate::assert_with_log!(
            rendered.contains("123") && rendered.contains("leaf"),
            "display expired",
            true,
            rendered.contains("123") && rendered.contains("leaf")
        );
        crate::test_complete!("test_display_certificate_expired");
    }

    #[test]
    fn test_display_pin_mismatch() {
        init_test("test_display_pin_mismatch");
        let err = TlsError::PinMismatch {
            expected: vec!["pinA".to_string(), "pinB".to_string()],
            actual: "pinC".to_string(),
        };
        let rendered = format!("{err}");
        crate::assert_with_log!(
            rendered.contains("pinC") && rendered.contains("pinA"),
            "display pin mismatch",
            true,
            rendered.contains("pinC") && rendered.contains("pinA")
        );
        crate::test_complete!("test_display_pin_mismatch");
    }

    #[test]
    fn test_io_error_source() {
        init_test("test_io_error_source");
        let io_err = io::Error::other("boom");
        let err = TlsError::from(io_err);
        crate::assert_with_log!(
            err.source().is_some(),
            "source",
            true,
            err.source().is_some()
        );
        let rendered = format!("{err}");
        crate::assert_with_log!(
            rendered.contains("I/O error"),
            "display io",
            true,
            rendered.contains("I/O error")
        );
        crate::test_complete!("test_io_error_source");
    }

    #[test]
    fn test_display_timeout() {
        init_test("test_display_timeout");
        let err = TlsError::Timeout(Duration::from_millis(250));
        let rendered = format!("{err}");
        crate::assert_with_log!(
            rendered.contains("250"),
            "display timeout",
            true,
            rendered.contains("250")
        );
        crate::test_complete!("test_display_timeout");
    }

    #[test]
    fn test_display_alpn_negotiation_failed() {
        init_test("test_display_alpn_negotiation_failed");
        let err = TlsError::AlpnNegotiationFailed {
            expected: vec![b"h2".to_vec(), b"http/1.1".to_vec()],
            negotiated: Some(b"http/1.1".to_vec()),
        };
        let rendered = format!("{err}");
        // ALPN protocol IDs are byte slices, so Debug format renders numeric bytes
        crate::assert_with_log!(
            rendered.contains("ALPN") && rendered.contains("negotiation failed"),
            "display alpn",
            true,
            rendered.contains("ALPN") && rendered.contains("negotiation failed")
        );
        crate::test_complete!("test_display_alpn_negotiation_failed");
    }

    // ---- remaining Display variants ----

    #[test]
    fn display_handshake() {
        let err = TlsError::Handshake("protocol version mismatch".into());
        let msg = err.to_string();
        assert!(msg.contains("handshake failed"), "{msg}");
        assert!(msg.contains("protocol version mismatch"), "{msg}");
    }

    #[test]
    fn display_certificate() {
        let err = TlsError::Certificate("self-signed".into());
        let msg = err.to_string();
        assert!(msg.contains("certificate error"), "{msg}");
        assert!(msg.contains("self-signed"), "{msg}");
    }

    #[test]
    fn display_certificate_not_yet_valid() {
        let err = TlsError::CertificateNotYetValid {
            valid_from: 9_999_999_999,
            description: "leaf cert".into(),
        };
        let msg = err.to_string();
        assert!(msg.contains("not valid until"), "{msg}");
        assert!(msg.contains("9999999999"), "{msg}");
        assert!(msg.contains("leaf cert"), "{msg}");
    }

    #[test]
    fn display_chain_validation() {
        let err = TlsError::ChainValidation("missing intermediate".into());
        let msg = err.to_string();
        assert!(msg.contains("chain validation failed"), "{msg}");
        assert!(msg.contains("missing intermediate"), "{msg}");
    }

    #[test]
    fn display_configuration() {
        let err = TlsError::Configuration("no cipher suites".into());
        let msg = err.to_string();
        assert!(msg.contains("configuration error"), "{msg}");
        assert!(msg.contains("no cipher suites"), "{msg}");
    }

    #[test]
    fn display_feature_disabled_includes_operation_and_hint() {
        let err = TlsError::FeatureDisabled {
            operation: "build TLS connector",
            hint: "rebuild with --features tls",
        };
        let msg = err.to_string();
        assert!(msg.contains("TLS feature disabled"), "{msg}");
        assert!(msg.contains("build TLS connector"), "{msg}");
        assert!(msg.contains("--features tls"), "{msg}");
    }

    #[test]
    fn display_alpn_no_negotiated() {
        let err = TlsError::AlpnNegotiationFailed {
            expected: vec![b"h2".to_vec()],
            negotiated: None,
        };
        let msg = err.to_string();
        assert!(msg.contains("None"), "{msg}");
    }

    // ---- source() for non-Io variants ----

    #[test]
    fn source_non_io_returns_none() {
        assert!(TlsError::InvalidDnsName("x".into()).source().is_none());
        assert!(TlsError::Handshake("x".into()).source().is_none());
        assert!(TlsError::Certificate("x".into()).source().is_none());
        assert!(TlsError::Configuration("x".into()).source().is_none());
        assert!(TlsError::ChainValidation("x".into()).source().is_none());
        assert!(
            TlsError::CertificateExpired {
                expired_at: 0,
                description: "x".into()
            }
            .source()
            .is_none()
        );
        assert!(
            TlsError::CertificateNotYetValid {
                valid_from: 0,
                description: "x".into()
            }
            .source()
            .is_none()
        );
        assert!(
            TlsError::PinMismatch {
                expected: vec![],
                actual: "x".into()
            }
            .source()
            .is_none()
        );
        assert!(TlsError::Timeout(Duration::ZERO).source().is_none());
        assert!(
            TlsError::AlpnNegotiationFailed {
                expected: vec![],
                negotiated: None
            }
            .source()
            .is_none()
        );
    }

    // ---- From<io::Error> ----

    #[test]
    fn from_io_error() {
        let io_err = io::Error::new(io::ErrorKind::ConnectionReset, "reset");
        let tls_err: TlsError = io_err.into();
        assert!(matches!(tls_err, TlsError::Io(_)));
        assert!(tls_err.source().is_some());
    }

    // ---- br-asupersync-kxw8nx: log injection sanitization ----

    #[test]
    fn sanitize_for_log_strips_crlf_to_space() {
        let raw = "line1\r\nline2";
        let sanitized = sanitize_for_log(raw);
        assert!(
            !sanitized.contains('\n') && !sanitized.contains('\r'),
            "CR/LF must be stripped, got {sanitized:?}"
        );
        assert_eq!(sanitized, "line1  line2");
    }

    #[test]
    fn sanitize_for_log_strips_tab_to_space() {
        let sanitized = sanitize_for_log("a\tb");
        assert_eq!(sanitized, "a b");
    }

    #[test]
    fn sanitize_for_log_replaces_other_control_with_question() {
        // NUL, BEL, ESC, DEL — all non-printable controls beyond CR/LF/tab.
        let raw = "x\x00y\x07z\x1bw\x7fv";
        let sanitized = sanitize_for_log(raw);
        assert_eq!(sanitized, "x?y?z?w?v");
    }

    #[test]
    fn sanitize_for_log_preserves_printable_ascii_and_unicode() {
        let raw = "hello, world! ✓ 漢字";
        let sanitized = sanitize_for_log(raw);
        assert_eq!(sanitized, raw);
    }

    #[test]
    fn sanitize_for_log_truncates_at_256_bytes_with_ellipsis() {
        let raw = "A".repeat(500);
        let sanitized = sanitize_for_log(&raw);
        // 256 bytes of 'A' (ASCII = 1 byte) plus '…' (3 bytes UTF-8).
        assert!(sanitized.starts_with(&"A".repeat(256)));
        assert!(sanitized.ends_with(''));
        // Total UTF-8 length: 256 + 3 = 259 bytes.
        assert_eq!(sanitized.len(), 259);
    }

    #[test]
    fn sanitize_for_log_under_cap_does_not_append_ellipsis() {
        let raw = "short";
        let sanitized = sanitize_for_log(raw);
        assert!(!sanitized.ends_with(''));
        assert_eq!(sanitized, "short");
    }

    #[test]
    fn sanitize_for_log_truncates_on_char_boundary_for_multibyte() {
        // Each '漢' is 3 UTF-8 bytes. 86 of them = 258 bytes (over cap).
        // Cap is 256 bytes; 85 chars = 255 bytes (fits); 86th char (3
        // bytes) would push to 258, so truncated at 85 chars + '…'.
        let raw = "".repeat(86);
        let sanitized = sanitize_for_log(&raw);
        assert!(sanitized.ends_with(''));
        // 85 * 3 = 255 bytes of 漢, plus 3 bytes of '…' = 258 total.
        assert_eq!(sanitized.len(), 258);
        // Verify char boundary respected: must be valid UTF-8.
        assert!(std::str::from_utf8(sanitized.as_bytes()).is_ok());
    }

    #[test]
    fn sanitize_for_log_is_idempotent_after_replacement_and_truncation() {
        let cases = [
            "clean peer error",
            "line1\r\nline2\tfield",
            "nul\x00bell\x07escape\x1bdel\x7f",
            "A very long ASCII peer error: ",
            "unicode peer error ✓ 漢字",
        ];

        for raw in cases {
            let long_input;
            let input = if raw.starts_with('A') {
                long_input = raw.repeat(20);
                long_input.as_str()
            } else {
                raw
            };
            let once = sanitize_for_log(input);
            let twice = sanitize_for_log(&once);

            assert_eq!(twice, once, "sanitization must be idempotent for {raw:?}");
            assert!(
                !twice.contains('\r'),
                "sanitized output must not contain CR"
            );
            assert!(
                !twice.contains('\n'),
                "sanitized output must not contain LF"
            );
            assert!(
                !twice.contains('\t'),
                "sanitized output must not contain tab"
            );
            assert!(!twice.chars().any(|ch| ch < ' ' || ch == '\u{7f}'));
        }
    }

    #[test]
    fn display_sanitizes_peer_controlled_fields_across_variants() {
        let peer_text = "peer\r\nvalue\twith\x00controls\x7f";
        let cases = [
            TlsError::InvalidDnsName(peer_text.to_string()),
            TlsError::Handshake(peer_text.to_string()),
            TlsError::Certificate(peer_text.to_string()),
            TlsError::CertificateExpired {
                expired_at: 42,
                description: peer_text.to_string(),
            },
            TlsError::CertificateNotYetValid {
                valid_from: 43,
                description: peer_text.to_string(),
            },
            TlsError::ChainValidation(peer_text.to_string()),
            TlsError::PinMismatch {
                expected: vec![peer_text.to_string()],
                actual: peer_text.to_string(),
            },
            TlsError::Io(io::Error::other(peer_text)),
        ];

        for err in cases {
            let display = err.to_string();

            assert!(
                !display.contains('\r'),
                "Display must strip CR for {display:?}"
            );
            assert!(
                !display.contains('\n'),
                "Display must strip LF for {display:?}"
            );
            assert!(
                !display.contains('\t'),
                "Display must strip tabs for {display:?}"
            );
            assert!(
                !display.chars().any(|ch| ch < ' ' || ch == '\u{7f}'),
                "Display must strip remaining ASCII controls for {display:?}"
            );
            assert!(
                display.contains("peer  value with?controls?"),
                "sanitized peer text should remain visible in one log line: {display:?}"
            );
        }
    }

    #[test]
    fn display_handshake_with_log_injection_attempt_sanitized() {
        // Peer-controlled handshake error containing a forged log line
        // splice attempt. Post-fix Display must NOT contain the
        // injected newline.
        let err = TlsError::Handshake(
            "alert: bad_certificate\n[ERROR] FORGED LOG ENTRY: privilege escalation".to_string(),
        );
        let display = err.to_string();
        assert!(
            !display.contains('\n'),
            "Display MUST strip embedded newlines: {display:?}"
        );
        // The injected text becomes part of the same log line, prefixed
        // by the spaces that replaced \n — visible to operators but
        // unable to forge a separate log entry.
        assert!(display.contains("FORGED LOG ENTRY"));
        assert!(display.starts_with("TLS handshake failed: "));
    }

    #[test]
    fn display_invalid_dns_name_with_control_chars_sanitized() {
        let err = TlsError::InvalidDnsName("evil.com\r\n\x00\x07ROOT_PROMPT$".to_string());
        let display = err.to_string();
        assert!(!display.contains('\r'));
        assert!(!display.contains('\n'));
        assert!(!display.contains('\0'));
        assert!(!display.contains('\x07'));
        // The control chars get converted to ? or space; the trailing
        // text ROOT_PROMPT$ remains visible.
        assert!(display.contains("ROOT_PROMPT$"));
    }

    #[test]
    fn display_certificate_error_amplification_capped() {
        // Peer-controlled certificate error of 10 KB must be capped at
        // 256 bytes + ellipsis; total Display output stays bounded.
        let huge = "X".repeat(10_000);
        let err = TlsError::Certificate(huge);
        let display = err.to_string();
        // Display = "certificate error: " (19) + 256 'X' + '…' (3)
        // = 19 + 256 + 3 = 278 bytes max.
        assert!(
            display.len() < 300,
            "Display must be capped to ~256 bytes of payload, got {} bytes",
            display.len()
        );
        assert!(display.ends_with(''));
    }
}