Skip to main content

camel_api/
redact.rs

1//! Canonical string-based URL redaction for diagnostic surfaces (ADR-0051).
2//!
3//! String surgery over raw URL bytes: userinfo in every authority window
4//! is masked as `***`, query/fragment content is replaced with
5//! `?[redacted]` / `#[redacted]` sentinels, and the result is capped at
6//! 256 bytes on a UTF-8 char boundary. Authority windows are enumerated
7//! over maximal runs of `/` and `\` characters: pure-slash runs (the
8//! landed `//` rule) open a window at two or more characters,
9//! backslash-bearing runs only behind an RFC 3986 scheme prefix. These
10//! helpers never parse — they are the strictest feasible handling for
11//! strings that may be malformed or hostile.
12//!
13//! Distinct from [`crate::endpoint_uri::EndpointUri::to_redacted_string`],
14//! which redacts the catalog-driven authored-URI layer (structured
15//! `EndpointUri` values) and is out of scope here.
16
17/// Return `input` with userinfo masked in every authority window. Windows
18/// are enumerated over maximal runs of `/` and `\` characters (see
19/// [`authority_windows`]); a window containing `@` carries userinfo, and
20/// the bytes from window start through the LAST `@` are replaced with
21/// `***` (over-masking is safe, under-masking is not). Windows are
22/// collected on the input and masked in reverse offset order (with
23/// duplicates deduped) so an edit never shifts a yet-to-be-processed
24/// window. Idempotent: an already-masked `***@host` window rewrites to
25/// itself.
26fn mask_authority_windows(input: &str) -> String {
27    let mut out = input.to_string();
28    let mut windows = authority_windows(input);
29    // The run scanner emits one window per maximal run in ascending order;
30    // sort+dedup are belt-and-braces.
31    windows.sort_unstable();
32    windows.dedup();
33    for (start, end) in windows.into_iter().rev() {
34        if let Some(at) = out[start..end].rfind('@') {
35            out.replace_range(start..start + at, "***");
36        }
37    }
38    out
39}
40
41/// Authority-window enumeration (bd rc-f05q8). Windows are derived from
42/// maximal runs of `/` and `\` characters, and a qualifying run opens
43/// exactly one window that ends at the next `/`, `?`, or `#` (or end of
44/// input). A pure-slash run qualifies at length >= 2 (the landed `//`
45/// rule) and its window starts at the run's end, keeping the slashes
46/// visible. A backslash-bearing run qualifies only behind an RFC 3986
47/// scheme prefix ending immediately before the run (see
48/// [`scheme_prefix_len`]): length >= 2 needs any scheme; a single `\`
49/// needs a scheme of two or more characters, or a one-character scheme
50/// whose candidate window content is credential-shaped (see
51/// [`single_backslash_credential_gate`]). Every qualifying window starts
52/// at the run's end — the spec's uniform rule: a window begins
53/// immediately after a maximal run of `/` and `\` — so the introducer
54/// slashes and backslashes stay visible. Scheme-less backslash runs —
55/// Windows drive paths and UNC paths — never open a window.
56fn authority_windows(input: &str) -> Vec<(usize, usize)> {
57    let bytes = input.as_bytes();
58    let mut windows = Vec::new();
59    let mut i = 0;
60    while i < bytes.len() {
61        if !matches!(bytes[i], b'/' | b'\\') {
62            i += 1;
63            continue;
64        }
65        let run_start = i;
66        let mut has_backslash = false;
67        while i < bytes.len() && matches!(bytes[i], b'/' | b'\\') {
68            has_backslash |= bytes[i] == b'\\';
69            i += 1;
70        }
71        let run_len = i - run_start;
72        let opens = if has_backslash {
73            match scheme_prefix_len(input, run_start) {
74                None => false,
75                Some(scheme_len) => {
76                    run_len >= 2
77                        || scheme_len >= 2
78                        || (scheme_len == 1 && single_backslash_credential_gate(input, i))
79                }
80            }
81        } else {
82            run_len >= 2
83        };
84        if opens {
85            let end = input[i..]
86                .find(['/', '?', '#'])
87                .map_or(input.len(), |offset| i + offset);
88            // Every window starts at the run's end (spec: a window begins
89            // immediately after a maximal run of `/` and `\`), so the
90            // introducer slashes and backslashes stay visible.
91            let start = i;
92            windows.push((start, end));
93        }
94    }
95    windows
96}
97
98/// Length of the RFC 3986 scheme name (`[a-zA-Z][a-zA-Z0-9+.-]*`) whose
99/// `:` ends immediately before `run_start` — the LONGEST valid match, so
100/// the name is the maximal run of scheme characters directly before the
101/// colon and its first character must be alphabetic. `None` when
102/// `input[..run_start]` does not end with `scheme:`.
103fn scheme_prefix_len(input: &str, run_start: usize) -> Option<usize> {
104    let bytes = input.as_bytes();
105    if run_start < 2 || bytes[run_start - 1] != b':' {
106        return None;
107    }
108    let mut start = run_start - 1;
109    while start > 0
110        && (bytes[start - 1].is_ascii_alphanumeric()
111            || matches!(bytes[start - 1], b'+' | b'.' | b'-'))
112    {
113        start -= 1;
114    }
115    let len = run_start - 1 - start;
116    if len == 0 || !bytes[start].is_ascii_alphabetic() {
117        return None;
118    }
119    Some(len)
120}
121
122/// Credential-shaped content gate for a single `\` behind a
123/// one-character scheme (bd rc-f05q8): the candidate window content —
124/// from `content_start` (the run's end) to the next `/`, `?`, or `#` —
125/// must carry a `:` strictly before its LAST `@`. Without an `@` there is
126/// no userinfo to hide, so the gate fails.
127fn single_backslash_credential_gate(input: &str, content_start: usize) -> bool {
128    let end = input[content_start..]
129        .find(['/', '?', '#'])
130        .map_or(input.len(), |offset| content_start + offset);
131    let window = &input[content_start..end];
132    match window.rfind('@') {
133        Some(at) => window[..at].contains(':'),
134        None => false,
135    }
136}
137
138/// Truncate `s` to at most `max` bytes, walking the cut down to the nearest
139/// UTF-8 char boundary so a multibyte character straddling the cap cannot
140/// panic.
141fn truncate_utf8_safe(s: &mut String, max: usize) {
142    if s.len() <= max {
143        return;
144    }
145    let mut cut = max;
146    while !s.is_char_boundary(cut) {
147        cut -= 1;
148    }
149    s.truncate(cut);
150}
151
152/// Single left-to-right pass over `bytes` that decodes `%HH` sequences
153/// where `decode(hi, lo)` yields a byte; every other byte is copied
154/// through unchanged. `decode` is consulted only when `%` is followed by
155/// two bytes — a trailing `%`, a stray `%`, and an undecodable pair all
156/// fall through to the literal copy. The caller decides which escapes
157/// count (see [`minimal_decode_pair`] and [`decode_match_key`]).
158fn percent_scan(bytes: &[u8], decode: impl Fn(u8, u8) -> Option<u8>) -> Vec<u8> {
159    let mut out = Vec::with_capacity(bytes.len());
160    let mut i = 0;
161    while i < bytes.len() {
162        if bytes[i] == b'%'
163            && i + 2 < bytes.len()
164            && let Some(b) = decode(bytes[i + 1], bytes[i + 2])
165        {
166            out.push(b);
167            i += 3;
168            continue;
169        }
170        out.push(bytes[i]);
171        i += 1;
172    }
173    out
174}
175
176/// Single left-to-right pass over a query pair that decodes ONLY the three
177/// sequences a credential URL cannot be recognized without — `%40` → `@`,
178/// `%3a`/`%3A` → `:`, `%2f`/`%2F` → `/` — and copies every other byte
179/// through (other `%HH` escapes, stray `%`, and incomplete escapes
180/// included). Valid UTF-8 is preserved byte-for-byte; invalid bytes become
181/// `U+FFFD`, which cannot match an ASCII needle, so the shape checks below
182/// are unaffected. This "minimal decode" (bd rc-r7v8s) is deliberately
183/// weaker than a full percent-decoder: it exists only so the allowlist
184/// redactor can recognize credential-shaped values hidden behind encoding,
185/// while never giving a benign pair a reason to change. Double-encoded
186/// input (`%2540`) stays encoded — out of scope by design.
187fn minimal_decode_pair(pair: &str) -> String {
188    let decoded = percent_scan(pair.as_bytes(), |hi, lo| match (hi, lo) {
189        (b'4', b'0') => Some(b'@'),
190        (b'3', b'a' | b'A') => Some(b':'),
191        (b'2', b'f' | b'F') => Some(b'/'),
192        _ => None,
193    });
194    String::from_utf8_lossy(&decoded).into_owned()
195}
196
197/// Credential-shape test shared by both query-pair positions (ADR-0076
198/// appendix, bd rc-yvjp3): an input whose minimal decode carries `@` and
199/// also `:` or `//` is treated as an embedded `user:pass@host` credential
200/// wherever it rides — key position or value position. One predicate, one
201/// invariant; a lone `@` (an email address) is not credential-shaped.
202fn is_credential_shaped(decoded: &str) -> bool {
203    decoded.contains('@') && (decoded.contains(':') || decoded.contains("//"))
204}
205
206/// Match key for the sensitive-substring check: a single left-to-right
207/// `%HH` decode over the raw key bytes (ANY two hex digits after `%`,
208/// case-insensitive; invalid sequences copied verbatim), then lowercased.
209/// Unlike [`minimal_decode_pair`] this is a full digit decode because the
210/// denylist matches literal text (`pass%77ord` must hit `password`), not
211/// credential shape. The result is only used for substring matching and is
212/// never rendered; invalid UTF-8 from a decoded byte becomes `U+FFFD`,
213/// which cannot match an ASCII needle.
214fn decode_match_key(raw_key: &str) -> String {
215    let decoded = percent_scan(raw_key.as_bytes(), |hi, lo| {
216        let hi = (hi as char).to_digit(16)?;
217        let lo = (lo as char).to_digit(16)?;
218        Some((hi * 16 + lo) as u8)
219    });
220    String::from_utf8_lossy(&decoded).to_lowercase()
221}
222
223/// Canonical strict redaction of a raw URL string for diagnostic surfaces
224/// (ADR-0051, bd rc-eh49): authority windows are enumerated over maximal
225/// runs of `/` and `\` characters — a pure-slash run of two or more
226/// characters opens a window, a backslash-bearing run opens one only
227/// behind an RFC 3986 scheme prefix (`[a-zA-Z][a-zA-Z0-9+.-]*:`) ending
228/// immediately before the run (a single `\` additionally needs a scheme of
229/// two-plus characters, or a one-character scheme whose candidate window
230/// content is credential-shaped) — and each window ends at the next `/`,
231/// `?`, or `#`. A window containing `@` carries userinfo,
232/// and the bytes from window start through the LAST `@` are masked in
233/// place as `***@` (over-masking is safe, under-masking is not). Every
234/// window is scanned, so credentials cannot hide in a later window behind
235/// a benign first one. Everything from the earliest `?` or `#` is dropped;
236/// the sentinels
237/// compose: each distinct introducer character (`?` and/or `#`) that occurs
238/// anywhere in the URL appends its matching `?[redacted]` / `#[redacted]`
239/// sentinel in first-occurrence order — queries and fragments routinely
240/// carry tokens. The result is capped at 256 bytes on a UTF-8 char
241/// boundary. There is no URL parser here, so in-place windowed masking is
242/// the strictest feasible handling. This is the string layer;
243/// [`crate::endpoint_uri::EndpointUri::to_redacted_string`] redacts the
244/// catalog-driven authored-URI surface instead.
245pub fn redact_url(raw: &str) -> String {
246    let mut out = mask_authority_windows(raw);
247    if let Some(i) = out.find(['?', '#']) {
248        // Compose-both: one sentinel per distinct introducer found in the
249        // raw URL, in first-occurrence order.
250        let query_pos = out.find('?');
251        let fragment_pos = out.find('#');
252        out.truncate(i);
253        // Reserve the sentinel bytes before truncating so the cap never
254        // splits an appended sentinel (e_gpt stage-4).
255        let sentinel_total = match (query_pos, fragment_pos) {
256            (Some(_), Some(_)) => 22,
257            (Some(_), None) | (None, Some(_)) => 11,
258            (None, None) => 0,
259        };
260        if sentinel_total > 0 {
261            truncate_utf8_safe(&mut out, 256 - sentinel_total);
262        }
263        match (query_pos, fragment_pos) {
264            (Some(q), Some(f)) if f < q => out.push_str("#[redacted]?[redacted]"),
265            (Some(_), Some(_)) => out.push_str("?[redacted]#[redacted]"),
266            (Some(_), None) => out.push_str("?[redacted]"),
267            (None, Some(_)) => out.push_str("#[redacted]"),
268            (None, None) => {}
269        }
270    }
271    truncate_utf8_safe(&mut out, 256);
272    out
273}
274
275/// Fail-closed variant of [`redact_url`]: when any authority window of
276/// `raw` carries an `@`, the whole string is replaced with `[redacted]` —
277/// a string with an unvalidated authority marker may carry credentials
278/// nothing validated, so nothing of it is rendered (deliberate fail-closed
279/// over-redaction per ADR-0051). Otherwise identical to [`redact_url`].
280pub fn redact_url_fail_closed(raw: &str) -> String {
281    if window_has_at_sign(raw) {
282        "[redacted]".to_string()
283    } else {
284        redact_url(raw)
285    }
286}
287
288/// Broker-style redaction: window-mask userinfo like [`redact_url`], then
289/// redact sensitive query params per key while keeping benign ones. A pair
290/// whose match key — the raw key single-pass `%HH`-decoded then lowercased
291/// (bd rc-r7v8s) — contains any of `sensitive_key_substrings` renders as
292/// `{raw_key}=<redacted>` (the key keeps its original encoded bytes),
293/// EXCEPT when the raw key's own single-pass minimal decode
294/// (see `minimal_decode_pair`) is credential-shaped — contains `@` and
295/// also `:` or `//`: then the pair renders as a bare `<redacted>` and the
296/// key never echoes (bd rc-yvjp3, ADR-0076 appendix: key-position
297/// credential-shape symmetry — the same predicate the benign-key branch
298/// applies to the whole pair; a key such as `user%3Asecret%40host` embeds
299/// the credential `user:secret@host` and must not render). Well-known
300/// broker parameter names never decode to that shape, so the
301/// transport-policy diagnostic value below is preserved. Otherwise, if the
302/// pair's `minimal_decode_pair` output is
303/// credential-shaped (contains `@` and also `:` or `//`), the whole pair is
304/// replaced with a bare `<redacted>`: encoded or literal
305/// `user:secret@host` values must not survive under a benign key, while a
306/// lone `@` (an email address) keeps the pair visible. Every other pair
307/// survives byte-for-byte, because
308/// non-secret transport policy in query params (ActiveMQ failover URIs) is
309/// the sole diagnostic value of logging the broker URL. Fragments are never
310/// echoed: everything from the first `#` is dropped and replaced with the
311/// `#[redacted]` sentinel. The result is capped at 256 bytes on a UTF-8
312/// char boundary. String-based; no URL parser.
313pub fn redact_url_with_query_allowlist(raw: &str, sensitive_key_substrings: &[&str]) -> String {
314    redact_url_with_query_allowlist_impl(raw, sensitive_key_substrings, Some(256))
315}
316
317/// Whether a raw query-parameter key is itself credential-shaped: its
318/// single-pass minimal decode (see `minimal_decode_pair`) contains `@`
319/// and also `:` or `//`. This is the exact key-position predicate
320/// `redact_url_with_query_allowlist_impl` applies (bd rc-yvjp3), exposed
321/// so parsers that retain unknown keys can suppress them instead of
322/// echoing embedded `user:secret@host` material. The predicate is
323/// heuristic, and it becomes frozen public surface once camel-api
324/// publishes externally.
325pub fn is_credential_shaped_key(raw_key: &str) -> bool {
326    is_credential_shaped(&minimal_decode_pair(raw_key))
327}
328
329/// Untruncated variant of [`redact_url_with_query_allowlist`]: identical
330/// windowed userinfo masking, per-key query redaction, and fragment
331/// suppression, but WITHOUT the 256-byte diagnostic cap. Callers that use
332/// the redacted string as stable identity material (for example a
333/// broker-key hash) MUST use this variant: the 256-byte cap conflates two
334/// long URLs that share a prefix but differ in a distinct address after
335/// the cap. Never use it as a rendered diagnostic surface.
336pub fn redact_url_with_query_allowlist_untruncated(
337    raw: &str,
338    sensitive_key_substrings: &[&str],
339) -> String {
340    redact_url_with_query_allowlist_impl(raw, sensitive_key_substrings, None)
341}
342
343/// Shared implementation for the truncating and untruncated variants.
344/// `cap` is `Some(256)` for the diagnostic surface and `None` for stable
345/// identity material. When capped, the fragment sentinel is reserved before
346/// the cut so the cap never splits it (e_gpt stage-4).
347fn redact_url_with_query_allowlist_impl(
348    raw: &str,
349    sensitive_key_substrings: &[&str],
350    cap: Option<usize>,
351) -> String {
352    // 1) Mask userinfo in every authority window (see [`mask_authority_windows`]).
353    let after_userinfo = mask_authority_windows(raw);
354    // 2) Redact sensitive query params, keep the rest for diagnosability.
355    let mut out = match after_userinfo.split_once('?') {
356        Some((base, query)) => {
357            let redacted: Vec<String> = query
358                .split('&')
359                .map(|pair| {
360                    let raw_key = pair.split('=').next().unwrap_or(pair);
361                    let match_key = decode_match_key(raw_key);
362                    if sensitive_key_substrings
363                        .iter()
364                        .any(|s| match_key.contains(s))
365                    {
366                        // Key-position symmetry (bd rc-yvjp3): a
367                        // credential-shaped key is fully suppressed — the
368                        // key bytes may themselves be the credential.
369                        if is_credential_shaped(&minimal_decode_pair(raw_key)) {
370                            "<redacted>".to_string()
371                        } else {
372                            format!("{raw_key}=<redacted>")
373                        }
374                    } else if is_credential_shaped(&minimal_decode_pair(pair)) {
375                        "<redacted>".to_string()
376                    } else {
377                        pair.to_string()
378                    }
379                })
380                .collect();
381            format!("{base}?{}", redacted.join("&"))
382        }
383        None => after_userinfo,
384    };
385    // 3) Fragments are never echoed. Reserve the sentinel bytes before
386    // truncating so the cap never splits the appended `#[redacted]`
387    // (e_gpt stage-4); the kept query content is part of the base.
388    if let Some(i) = out.find('#') {
389        out.truncate(i);
390        if let Some(cap) = cap {
391            truncate_utf8_safe(&mut out, cap - 11);
392        }
393        out.push_str("#[redacted]");
394    }
395    if let Some(cap) = cap {
396        truncate_utf8_safe(&mut out, cap);
397    }
398    out
399}
400
401/// Whether a `@` appears in any authority window of `raw`. Windows come
402/// from maximal runs of `/` and `\` characters — the same enumeration the
403/// mask uses: pure-slash runs of two or more characters always open a
404/// window; backslash-bearing runs open one only behind an RFC 3986 scheme
405/// prefix ending immediately before the run. Every run is scanned, so
406/// credentials cannot hide in a later window behind a benign first one
407/// (`http://h/a//user:pass@e/`) or behind a scheme-prefixed backslash
408/// authority (`foo:\u:p@e/`), while scheme-less drive and UNC paths stay
409/// window-free.
410pub fn window_has_at_sign(raw: &str) -> bool {
411    authority_windows(raw)
412        .into_iter()
413        .any(|(start, end)| raw[start..end].contains('@'))
414}
415
416/// Redact a bare host value for logs (ADR-0076, `lint-log-redaction`).
417/// A bare host carries no `//` authority window, so [`redact_url`] is a
418/// semantic no-op on it; this helper masks everything before the LAST
419/// `@` instead (`***@host`). The host grammar carries no userinfo, but
420/// the value can be externally influenced (a config field the URI
421/// parser can route `user:pass@host` into), so userinfo-shaped input is
422/// masked defensively — through the LAST `@`, because over-masking is
423/// safe and under-masking is not. Clean hosts pass through unchanged
424/// for diagnosability. Canonical single source: components log `host`
425/// fields through this helper, not crate-local twins (bd rc-8bxeo).
426pub fn redact_host(host: &str) -> String {
427    match host.rsplit_once('@') {
428        Some((_, after)) => format!("***@{after}"),
429        None => host.to_string(),
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    /// Key denylist used by the camel-jms broker redactor (7 entries, same
438    /// order as the landed `redact_broker_url` list).
439    const JMS_KEYS: &[&str] = &[
440        "password",
441        "passwd",
442        "secret",
443        "credential",
444        "token",
445        "username",
446        "user",
447    ];
448
449    // ── Spec-pinned scenarios (redact2 security requirement) ─────────────
450
451    #[test]
452    fn strict_masks_windows_and_composes_sentinels() {
453        assert_eq!(
454            redact_url("https://user:pass@h/p?a=1#t=x"),
455            "https://***@h/p?[redacted]#[redacted]"
456        );
457        // A later `//` window is masked too: credentials cannot hide behind
458        // a benign first window.
459        assert_eq!(redact_url("https://h//u2:p2@evil/"), "https://h//***@evil/");
460    }
461
462    #[test]
463    fn fail_closed_suppresses_window_with_at() {
464        assert_eq!(
465            redact_url_fail_closed("http://u:secretpw@host:99999/x"),
466            "[redacted]"
467        );
468    }
469
470    #[test]
471    fn sentinel_never_splits_at_cap() {
472        // Pre-sentinel base (up to the earliest `?`) is 316 bytes; the
473        // 3-byte `日` straddles the reserved cut at byte 234, forcing the
474        // boundary walk. Both sentinels must render intact.
475        let mut url = format!("https://host/{}日{}", "x".repeat(220), "x".repeat(80));
476        url.push_str("?a=1#f");
477        assert!(url.len() > 300);
478        let redacted = redact_url(&url);
479        assert!(redacted.len() <= 256, "len={}", redacted.len());
480        assert!(
481            redacted.ends_with("?[redacted]#[redacted]"),
482            "sentinels must render intact: {redacted}"
483        );
484        assert!(std::str::from_utf8(redacted.as_bytes()).is_ok());
485        assert!(
486            !redacted.contains('日'),
487            "straddling char dropped whole: {redacted}"
488        );
489        assert!(
490            redacted.starts_with("https://host/"),
491            "host kept: {redacted}"
492        );
493    }
494
495    #[test]
496    fn idempotent_mask_composition_pin() {
497        // An already-masked window rewrites to itself and the sentinels
498        // still compose.
499        assert_eq!(
500            redact_url("https://***@host/p?a=1#f"),
501            "https://***@host/p?[redacted]#[redacted]"
502        );
503    }
504
505    #[test]
506    fn allowlist_keeps_benign_and_redacts_sensitive() {
507        assert_eq!(
508            redact_url_with_query_allowlist(
509                "tcp://host:61616?password=p&user=u&keepAlive=true",
510                JMS_KEYS
511            ),
512            "tcp://host:61616?password=<redacted>&user=<redacted>&keepAlive=true"
513        );
514    }
515
516    // ── Phase 2: minimal-decode rule (bd rc-r7v8s) ──────────────────────
517
518    /// Percent-encoded credentials under a benign key must not smuggle
519    /// through: the single-pass minimal decode of the pair carries `@` and
520    /// `//`, so the whole pair redacts. Uppercase hex form.
521    #[test]
522    fn allowlist_masks_percent_encoded_credentials_uppercase() {
523        let redacted = redact_url_with_query_allowlist(
524            "tcp://h:61616?redirect=http%3A%2F%2Fuser%3Asecret%40host",
525            JMS_KEYS,
526        );
527        assert!(
528            !redacted.contains("secret"),
529            "percent-encoded credential leaked: {redacted}"
530        );
531        assert!(
532            !redacted.contains("user%3Asecret%40"),
533            "encoded userinfo leaked: {redacted}"
534        );
535        assert_eq!(redacted, "tcp://h:61616?<redacted>");
536    }
537
538    /// Fully-lowercase hex variant of the same smuggling shape.
539    #[test]
540    fn allowlist_masks_percent_encoded_credentials_lowercase() {
541        let redacted = redact_url_with_query_allowlist(
542            "tcp://h:61616?redirect=http%3a%2f%2fuser%3asecret%40host",
543            JMS_KEYS,
544        );
545        assert_eq!(redacted, "tcp://h:61616?<redacted>");
546        assert!(
547            !redacted.contains("secret"),
548            "percent-encoded credential leaked: {redacted}"
549        );
550    }
551
552    /// bd rc-yvjp3 (ADR-0076 appendix, key-position symmetry): a pair
553    /// whose KEY embeds credentials (`user%3Asecret%40host` decodes to
554    /// `user:secret@host`) renders as a bare `<redacted>` — the key bytes
555    /// never echo, in either hex case.
556    #[test]
557    fn allowlist_suppresses_credential_shaped_key_uppercase() {
558        let redacted =
559            redact_url_with_query_allowlist("tcp://h:61616?user%3Asecret%40host=1", JMS_KEYS);
560        assert_eq!(redacted, "tcp://h:61616?<redacted>");
561        assert!(
562            !redacted.contains("user%3Asecret%40"),
563            "credential-shaped key leaked: {redacted}"
564        );
565        assert!(
566            !redacted.contains("secret"),
567            "key credential bytes leaked: {redacted}"
568        );
569    }
570
571    /// Fully-lowercase hex variant of the key-position shape.
572    #[test]
573    fn allowlist_suppresses_credential_shaped_key_lowercase() {
574        let redacted =
575            redact_url_with_query_allowlist("tcp://h:61616?user%3asecret%40host=1", JMS_KEYS);
576        assert_eq!(redacted, "tcp://h:61616?<redacted>");
577    }
578
579    /// Key-position symmetry, literal form: a fully literal
580    /// `user:pass@host` key is suppressed whole.
581    #[test]
582    fn allowlist_suppresses_literal_credential_shaped_key() {
583        let redacted = redact_url_with_query_allowlist("tcp://h:61616?user:pass@host=1", JMS_KEYS);
584        assert_eq!(redacted, "tcp://h:61616?<redacted>");
585        assert!(
586            !redacted.contains("user:pass"),
587            "literal key credentials leaked: {redacted}"
588        );
589    }
590
591    /// bd rc-yvjp3 non-regression: well-known sensitive keys that are NOT
592    /// credential-shaped keep echoing their names — the ADR-0076 exception
593    /// names transport-policy parameter visibility as the sole diagnostic
594    /// value of the broker URL, and no real parameter name decodes to an
595    /// `@`+`:`/`//` shape.
596    #[test]
597    fn allowlist_keeps_well_known_key_names_visible() {
598        let redacted = redact_url_with_query_allowlist(
599            "tcp://h:61616?password=p&jms.userName=admin&user=u&keepAlive=true",
600            JMS_KEYS,
601        );
602        assert_eq!(
603            redacted,
604            "tcp://h:61616?password=<redacted>&jms.userName=<redacted>&user=<redacted>&keepAlive=true"
605        );
606    }
607
608    /// bd rc-yvjp3 boundary pin: a denylist-matching key with a lone `@`
609    /// (no `:`, no `//`) is NOT credential-shaped — it renders
610    /// `user@host=<redacted>` and the key echoes, mirroring the lone-`@`
611    /// email rule of the benign-key branch.
612    #[test]
613    fn allowlist_keeps_lone_at_key_visible() {
614        let redacted = redact_url_with_query_allowlist("tcp://h:61616?user@host=1", JMS_KEYS);
615        assert_eq!(redacted, "tcp://h:61616?user@host=<redacted>");
616    }
617
618    /// bd rc-yvjp3 non-regression: an encoded-but-not-credential-shaped
619    /// key (`%77` is outside the minimal-decode triple) still echoes its
620    /// authored bytes when the decoded name hits the denylist — the
621    /// `pass%77ord` match rule (bd rc-r7v8s) is unchanged.
622    #[test]
623    fn allowlist_keeps_encoded_but_benign_shaped_key_visible() {
624        let redacted = redact_url_with_query_allowlist("tcp://h:61616?pass%77ord=p", JMS_KEYS);
625        assert_eq!(redacted, "tcp://h:61616?pass%77ord=<redacted>");
626    }
627
628    /// Encoded slashes plus a literal `@` decode to a `//`-bearing
629    /// credential shape under a benign key.
630    #[test]
631    fn allowlist_masks_literal_at_bypass() {
632        let redacted =
633            redact_url_with_query_allowlist("tcp://h:61616?next=%2F%2Fuser:pass@host", JMS_KEYS);
634        assert_eq!(redacted, "tcp://h:61616?<redacted>");
635        assert!(
636            !redacted.contains("pass"),
637            "literal credential leaked: {redacted}"
638        );
639    }
640
641    /// Fully literal `user:pass@host` under a benign key.
642    #[test]
643    fn allowlist_masks_fully_literal_credential_pair() {
644        let redacted =
645            redact_url_with_query_allowlist("tcp://h:61616?next=user:pass@host", JMS_KEYS);
646        assert_eq!(redacted, "tcp://h:61616?<redacted>");
647        assert!(
648            !redacted.contains("pass"),
649            "literal credential leaked: {redacted}"
650        );
651    }
652
653    /// Regression pin at the under-redaction boundary: `@` without `:` or
654    /// `//` is an email address, not a credential — the pair stays visible.
655    #[test]
656    fn allowlist_keeps_lone_email_value() {
657        assert_eq!(
658            redact_url_with_query_allowlist("tcp://h:61616?contact=admin%40corp.example", JMS_KEYS),
659            "tcp://h:61616?contact=admin%40corp.example"
660        );
661    }
662
663    /// Over-mask stance (ADR-0051): a value decoding to `user@host:port`
664    /// is credential-shaped even though no literal `//` or `:` is present.
665    #[test]
666    fn allowlist_masks_credential_shaped_userhostport() {
667        let redacted =
668            redact_url_with_query_allowlist("tcp://h:61616?next=user%40host%3Aport", JMS_KEYS);
669        assert_eq!(redacted, "tcp://h:61616?<redacted>");
670    }
671
672    /// A percent-encoded sensitive key decodes to `password` and must
673    /// redact; the rendered key keeps its original encoded bytes.
674    #[test]
675    fn allowlist_decodes_percent_encoded_sensitive_key() {
676        let redacted =
677            redact_url_with_query_allowlist("tcp://host:61616?pass%77ord=shortsecret", JMS_KEYS);
678        assert!(
679            redacted.contains("pass%77ord=<redacted>"),
680            "encoded key must redact keeping original bytes: {redacted}"
681        );
682        assert!(
683            !redacted.contains("shortsecret"),
684            "secret value leaked: {redacted}"
685        );
686    }
687
688    /// Regression pin (redact2): non-ASCII bytes in a pair are preserved
689    /// byte-for-byte (valid UTF-8), never Latin-1-transcoded, and a decoded
690    /// invalid byte becomes `U+FFFD` — the decoded output feeds only ASCII
691    /// shape checks, so neither case can flip a redaction decision.
692    #[test]
693    fn minimal_decode_preserves_non_ascii_bytes() {
694        // `é` (0xC3 0xA9) must survive as itself, not as `é`.
695        assert_eq!(minimal_decode_pair("café"), "café");
696        // Escapes still decode around non-ASCII bytes.
697        assert_eq!(minimal_decode_pair("café%40x"), "café@x");
698        // A decoded invalid byte (0xFF from `%FF`) becomes U+FFFD, which
699        // cannot match an ASCII needle (exercised via the full-hex decoder).
700        assert_eq!(decode_match_key("a%FFb"), "a\u{FFFD}b");
701    }
702
703    // ── Migrated: camel-config config_tests/url_redaction_tests.rs ──────
704
705    #[test]
706    fn redact_url_keeps_userinfo_mask_shape() {
707        assert_eq!(
708            redact_url("redis://user:secret@h:6379"),
709            "redis://***@h:6379"
710        );
711    }
712
713    /// Intentional semantic change: the query was previously echoed verbatim,
714    /// leaking `?password=...` cache credentials into Debug output.
715    #[test]
716    fn redact_url_drops_query_secrets() {
717        assert_eq!(
718            redact_url("redis://h:6379/0?password=hunter2"),
719            "redis://h:6379/0?[redacted]"
720        );
721    }
722
723    #[test]
724    fn redact_url_drops_fragment() {
725        assert_eq!(
726            redact_url("redis://h:6379/0#tok=x"),
727            "redis://h:6379/0#[redacted]"
728        );
729    }
730
731    /// Multiple `@` in the window: mask through the LAST one — over-masking is
732    /// safe, under-masking is not.
733    #[test]
734    fn redact_url_masks_through_last_at() {
735        assert_eq!(redact_url("redis://user:p@ss@h:6379"), "redis://***@h:6379");
736    }
737
738    /// Bare-host masking (bd rc-8bxeo, promoted from the camel-ws /
739    /// camel-http twins): clean hosts pass through unchanged for
740    /// diagnosability; userinfo-shaped values mask through the LAST `@`.
741    #[test]
742    fn redact_host_masks_userinfo_keeps_clean_hosts() {
743        assert_eq!(redact_host("localhost"), "localhost");
744        assert_eq!(redact_host("broker.example.com"), "broker.example.com");
745        assert_eq!(redact_host("host.example:8080"), "host.example:8080");
746        assert_eq!(redact_host("user:pass@host"), "***@host");
747        assert_eq!(redact_host("bob:p@ss@host"), "***@host");
748        assert_eq!(redact_host("a@b@c"), "***@c");
749    }
750
751    /// A slash run after `//` must not hide userinfo behind it; extra leading
752    /// slashes are kept byte-for-byte.
753    #[test]
754    fn redact_url_slash_run_evader_masked() {
755        assert_eq!(
756            redact_url("redis:////user:pass@h:6379/0"),
757            "redis:////***@h:6379/0"
758        );
759    }
760
761    /// An `@` outside any `//` window (path data here) is not userinfo.
762    #[test]
763    fn redact_url_at_outside_window_visible() {
764        assert_eq!(
765            redact_url("redis://h:6379/0/user@x"),
766            "redis://h:6379/0/user@x"
767        );
768    }
769
770    /// A `//` window after the first is scanned too: credentials cannot hide
771    /// behind a benign first window.
772    #[test]
773    fn redact_url_later_window_masked() {
774        assert_eq!(redact_url("redis://h//user:pass@x/"), "redis://h//***@x/");
775    }
776
777    /// Compose-both rule: one sentinel per distinct introducer found in the
778    /// raw URL, in first-occurrence order.
779    #[test]
780    fn redact_url_sentinels_compose_both() {
781        assert_eq!(
782            redact_url("redis://h:6379/0?password=x#tok=y"),
783            "redis://h:6379/0?[redacted]#[redacted]"
784        );
785    }
786
787    #[test]
788    fn redact_url_sentinels_compose_fragment_first() {
789        assert_eq!(
790            redact_url("redis://h:6379/0#tok=y?password=x"),
791            "redis://h:6379/0#[redacted]?[redacted]"
792        );
793    }
794
795    /// The 256-byte cap must land on a UTF-8 char boundary: a 3-byte char
796    /// straddling byte 256 forces the cut to walk back instead of panicking.
797    #[test]
798    fn redact_url_truncates_256_utf8_safe() {
799        let mut url = format!("redis://{}{}", "x".repeat(246), '日');
800        url.push_str(&"tail".repeat(20));
801        assert!(url.len() > 300);
802        let redacted = redact_url(&url);
803        assert!(redacted.len() <= 256, "len={}", redacted.len());
804        assert!(redacted.starts_with("redis://"));
805    }
806
807    /// e_gpt stage-4: the 256-byte cap must not split an appended sentinel.
808    /// The base is truncated at `256 - sentinel_len` BEFORE the sentinel is
809    /// appended, so the sentinel always renders intact and the total stays
810    /// ≤ 256.
811    #[test]
812    fn redact_url_keeps_sentinel_intact_under_256_cap() {
813        // Base (masked, cut at the `?`) is 248 bytes, so byte 256 lands inside
814        // the appended `?[redacted]` (starts at 248) pre-fix.
815        let url = format!("http://{}?x=1", "a".repeat(240));
816        let redacted = redact_url(&url);
817        assert!(redacted.len() <= 256, "len={}", redacted.len());
818        assert!(
819            redacted.ends_with("?[redacted]"),
820            "sentinel must render intact: {redacted}"
821        );
822    }
823
824    /// The untruncated variant must keep every byte that the 256-byte
825    /// diagnostic cap would otherwise drop, so it can serve as stable
826    /// identity material without conflating two long URLs that share a
827    /// prefix.
828    #[test]
829    fn redact_url_with_query_allowlist_untruncated_keeps_full_length() {
830        let long = format!(
831            "tcp://h:61616?keepAlive=true&brokerName={}",
832            "segment".repeat(60)
833        );
834        assert!(long.len() > 256);
835        let capped = redact_url_with_query_allowlist(&long, &[]);
836        let full = redact_url_with_query_allowlist_untruncated(&long, &[]);
837        assert!(capped.len() <= 256, "len={}", capped.len());
838        assert_eq!(full, long, "untruncated must preserve the whole string");
839        assert!(full.len() > capped.len());
840        // Redaction still applies without the cap.
841        let mut secret = String::from("tcp://admin:secret@h:61616?keepAlive=true&padding=");
842        secret.push_str(&"x".repeat(300));
843        let redacted = redact_url_with_query_allowlist_untruncated(&secret, &["password", "user"]);
844        assert!(!redacted.contains("secret"));
845        assert!(redacted.len() > 256);
846    }
847
848    // ── Migrated: camel-jms config.rs broker allowlist tests ────────────
849
850    /// Audit 2026-08-31, F5-3: JMS broker URLs must not leak credentials
851    /// through Debug output.
852    #[test]
853    fn redact_broker_url_masks_userinfo_and_sensitive_query() {
854        // userinfo form
855        let redacted = redact_url_with_query_allowlist(
856            "tcp://admin:secretpass@broker.example.com:61616",
857            JMS_KEYS,
858        );
859        assert!(
860            !redacted.contains("secretpass"),
861            "password masked: {redacted}"
862        );
863        assert!(
864            redacted.contains("broker.example.com"),
865            "host visible: {redacted}"
866        );
867
868        // failover + query-param form (ActiveMQ style)
869        let redacted = redact_url_with_query_allowlist(
870            "failover:(tcp://host:61616)?jms.userName=admin&jms.password=secret&keepAlive=true",
871            JMS_KEYS,
872        );
873        assert!(
874            !redacted.contains("secret"),
875            "password param masked: {redacted}"
876        );
877        assert!(
878            !redacted.contains("=admin"),
879            "username param masked: {redacted}"
880        );
881        assert!(
882            redacted.contains("keepAlive=true"),
883            "benign param kept: {redacted}"
884        );
885
886        // clean URL untouched
887        assert_eq!(
888            redact_url_with_query_allowlist("tcp://host:61616", JMS_KEYS),
889            "tcp://host:61616"
890        );
891    }
892
893    /// Exact-output pin: userinfo is fully masked by `***`, never partially
894    /// truncated, in scheme://...@ authority position.
895    #[test]
896    fn redact_exact_userinfo_mask() {
897        assert_eq!(
898            redact_url_with_query_allowlist(
899                "tcp://admin:secretpass@broker.example.com:61616",
900                JMS_KEYS
901            ),
902            "tcp://***@broker.example.com:61616"
903        );
904    }
905
906    /// Exact-output pin: sensitive query params redact to `<redacted>`,
907    /// benign params survive byte-for-byte, `&` separators preserved.
908    #[test]
909    fn redact_exact_query_join() {
910        assert_eq!(
911            redact_url_with_query_allowlist(
912                "tcp://host:61616?password=p&user=u&keepAlive=true",
913                JMS_KEYS
914            ),
915            "tcp://host:61616?password=<redacted>&user=<redacted>&keepAlive=true"
916        );
917    }
918
919    /// Exact-output pin: a bare `user@host` (no scheme) is NOT an authority
920    /// position — passthrough untouched.
921    #[test]
922    fn redact_exact_bare_at_passthrough() {
923        assert_eq!(
924            redact_url_with_query_allowlist("admin@host", JMS_KEYS),
925            "admin@host"
926        );
927    }
928
929    /// Exact-output pin: delimiter-exact query redaction on the ActiveMQ
930    /// failover form — the whole query part after '?' must match verbatim,
931    /// no dropped params, no mangled separators.
932    #[test]
933    fn redact_exact_failover_param_boundaries() {
934        let redacted = redact_url_with_query_allowlist(
935            "failover:(tcp://host:61616)?jms.userName=admin&jms.password=secret&keepAlive=true",
936            JMS_KEYS,
937        );
938        let (_, query) = redacted.split_once('?').expect("query segment after '?'");
939        assert_eq!(
940            query,
941            "jms.userName=<redacted>&jms.password=<redacted>&keepAlive=true"
942        );
943    }
944
945    /// bd rc-eh49 exact pin: an `@` riding the query is NOT userinfo — the
946    /// URL must pass through byte-for-byte. The old whole-string
947    /// `split_once('@')` mangled this to `failover:(tcp://***@b`.
948    #[test]
949    fn redact_broker_url_query_at_no_misfire() {
950        assert_eq!(
951            redact_url_with_query_allowlist("failover:(tcp://h:61616)?x=a@b", JMS_KEYS),
952            "failover:(tcp://h:61616)?x=a@b"
953        );
954    }
955
956    /// bd rc-eh49 exact pin: a slash run after `//` is preserved
957    /// byte-for-byte and the windowed mask composes with the per-key query
958    /// allowlist. The old whole-string scan rewrote from the scheme's own
959    /// `://` and swallowed the extra slashes.
960    #[test]
961    fn redact_exact_slash_run_window_composition() {
962        assert_eq!(
963            redact_url_with_query_allowlist("tcp:////user:pass@h:61616?keepAlive=true", JMS_KEYS),
964            "tcp:////***@h:61616?keepAlive=true"
965        );
966    }
967
968    /// bd rc-eh49 exact pin: the window mask consumes through the LAST `@`
969    /// of the window, so an `@` embedded in the userinfo cannot keep a
970    /// spoofable prefix alive (the old first-`@` scan left `a@` visible).
971    #[test]
972    fn redact_exact_last_at_in_window() {
973        assert_eq!(
974            redact_url_with_query_allowlist("tcp://u:p@a@h:61616", JMS_KEYS),
975            "tcp://***@h:61616"
976        );
977    }
978
979    /// bd rc-eh49: the 256-byte cap cuts on a char boundary — a multibyte
980    /// character straddling byte 256 is dropped whole, never split
981    /// mid-encode.
982    #[test]
983    fn redact_broker_url_truncate_multibyte_boundary() {
984        let mut url = String::from("tcp://broker:61616/");
985        url.push_str(&"x".repeat(236)); // 255 ASCII bytes before the multibyte char
986        url.push('日'); // 3 bytes straddling the 256-byte cap
987        url.push_str(&"y".repeat(50)); // push the total past 300 bytes
988        assert!(url.len() > 300);
989        let redacted = redact_url_with_query_allowlist(&url, JMS_KEYS);
990        assert!(redacted.len() <= 256, "len={}", redacted.len());
991        assert!(std::str::from_utf8(redacted.as_bytes()).is_ok());
992        assert!(
993            !redacted.contains('日'),
994            "straddling char dropped whole: {redacted}"
995        );
996    }
997
998    /// bd rc-eh49: a `//` window after the first is scanned too; the
999    /// benign query stays visible per the broker allowlist exception.
1000    #[test]
1001    fn redact_broker_url_later_window_masked() {
1002        assert_eq!(
1003            redact_url_with_query_allowlist("tcp://h//user:pass@x/?keepAlive=true", JMS_KEYS),
1004            "tcp://h//***@x/?keepAlive=true"
1005        );
1006    }
1007
1008    /// bd rc-eh49: fragments are never echoed — everything from the first
1009    /// `#` is dropped and the `#[redacted]` sentinel appended; processed
1010    /// query params stay visible.
1011    #[test]
1012    fn redact_broker_url_drops_fragment() {
1013        assert_eq!(
1014            redact_url_with_query_allowlist("tcp://h:61616?keepAlive=true#tok=x", JMS_KEYS),
1015            "tcp://h:61616?keepAlive=true#[redacted]"
1016        );
1017    }
1018
1019    /// bd rc-eh49: broker URLs are capped at 256 bytes on a UTF-8 char
1020    /// boundary.
1021    #[test]
1022    fn redact_broker_url_truncates() {
1023        let mut url = format!("tcp://broker:61616/{}", "x".repeat(300));
1024        url.push('日');
1025        url.push_str(&"tail".repeat(20));
1026        assert!(url.len() > 300);
1027        let redacted = redact_url_with_query_allowlist(&url, JMS_KEYS);
1028        assert!(redacted.len() <= 256, "len={}", redacted.len());
1029        assert!(redacted.starts_with("tcp://broker:61616/"));
1030    }
1031
1032    // ── Migrated: camel-jms component.rs redact_url_* block ─────────────
1033
1034    #[test]
1035    fn redact_url_strips_userinfo_with_password() {
1036        assert_eq!(
1037            redact_url("tcp://admin:s3cret@broker:61616"),
1038            "tcp://***@broker:61616"
1039        );
1040    }
1041
1042    #[test]
1043    fn redact_url_strips_userinfo_without_password() {
1044        assert_eq!(
1045            redact_url("tcp://admin@broker:61616"),
1046            "tcp://***@broker:61616"
1047        );
1048    }
1049
1050    #[test]
1051    fn redact_url_passes_clean_url_unchanged() {
1052        assert_eq!(redact_url("tcp://localhost:61616"), "tcp://localhost:61616");
1053    }
1054
1055    #[test]
1056    fn redact_url_handles_ssl_scheme() {
1057        assert_eq!(
1058            redact_url("ssl://user:pass@secure-broker:61617"),
1059            "ssl://***@secure-broker:61617"
1060        );
1061    }
1062
1063    /// bd rc-eh49: the earliest `?`/`#` introducer wins; both sentinels
1064    /// compose in first-occurrence order when the raw URL carries both
1065    /// introducers, and fragment bytes after the cut are dropped with it.
1066    #[test]
1067    fn redact_url_drops_query_and_fragment() {
1068        assert_eq!(
1069            redact_url("tcp://broker:61616?user=a#tok=x"),
1070            "tcp://broker:61616?[redacted]#[redacted]"
1071        );
1072    }
1073
1074    /// bd rc-eh49 compose-both rule: a `#` before `?` flips the sentinel
1075    /// order accordingly. (Name prefixed `jms_`: the redis fixture from
1076    /// camel-config already owns the unprefixed name above.)
1077    #[test]
1078    fn jms_redact_url_sentinels_compose_fragment_first() {
1079        assert_eq!(
1080            redact_url("tcp://broker:61616#tok=x?user=a"),
1081            "tcp://broker:61616#[redacted]?[redacted]"
1082        );
1083    }
1084
1085    /// bd rc-eh49: a `//` window after the first is scanned too —
1086    /// credentials cannot hide behind a benign first window. (Name prefixed
1087    /// `jms_`: the redis fixture from camel-config already owns the
1088    /// unprefixed name above.)
1089    #[test]
1090    fn jms_redact_url_later_window_masked() {
1091        assert_eq!(redact_url("tcp://h//user:pass@x/"), "tcp://h//***@x/");
1092    }
1093
1094    /// bd rc-eh49: a slash run after `//` cannot hide userinfo from the
1095    /// window scan.
1096    #[test]
1097    fn redact_url_slash_run_masked() {
1098        let redacted = redact_url("tcp:////user:pass@broker:61616");
1099        assert!(!redacted.contains("user:pass"), "leaked: {redacted}");
1100        assert!(
1101            redacted.contains("***@broker:61616"),
1102            "masked in place: {redacted}"
1103        );
1104    }
1105
1106    /// bd rc-eh49: the old first-`@`-anywhere scan masked through an `@`
1107    /// riding the query (`tcp://***@b`); only a window `@` is userinfo.
1108    #[test]
1109    fn redact_url_at_in_query_not_userinfo_mask() {
1110        assert_eq!(
1111            redact_url("tcp://broker:61616?q=a@b"),
1112            "tcp://broker:61616?[redacted]"
1113        );
1114    }
1115
1116    /// bd rc-eh49: the 256-byte cap cuts on a char boundary — a multibyte
1117    /// character straddling byte 256 is dropped whole, never split
1118    /// mid-encode.
1119    #[test]
1120    fn redact_url_truncate_multibyte_boundary() {
1121        let mut url = String::from("tcp://broker:61616/");
1122        url.push_str(&"x".repeat(236)); // 255 ASCII bytes before the multibyte char
1123        url.push('日'); // 3 bytes straddling the 256-byte cap
1124        url.push_str(&"y".repeat(50)); // push the total past 300 bytes
1125        assert!(url.len() > 300);
1126        let redacted = redact_url(&url);
1127        assert!(redacted.len() <= 256, "len={}", redacted.len());
1128        assert!(std::str::from_utf8(redacted.as_bytes()).is_ok());
1129        assert!(
1130            !redacted.contains('日'),
1131            "straddling char dropped whole: {redacted}"
1132        );
1133    }
1134
1135    /// e_gpt stage-4: the 256-byte cap must not split an appended sentinel.
1136    /// The base is truncated at `256 - sentinel_len` BEFORE the sentinel is
1137    /// appended, so the sentinel always renders intact and the total stays
1138    /// ≤ 256. Covers both redactors: `redact_url` (compose sentinels) and
1139    /// `redact_url_with_query_allowlist` (fragment sentinel over kept query
1140    /// content).
1141    #[test]
1142    fn redact_url_keeps_sentinels_intact_under_256_cap() {
1143        // `redact_url`: base (masked, cut at the `?`) is 255 bytes, so byte
1144        // 256 lands inside the appended `?[redacted]` (starts at 255) pre-fix.
1145        let url = format!("tcp://{}?x=1", "a".repeat(250));
1146        let redacted = redact_url(&url);
1147        assert!(redacted.len() <= 256, "len={}", redacted.len());
1148        assert!(
1149            redacted.ends_with("?[redacted]"),
1150            "redact_url sentinel must render intact: {redacted}"
1151        );
1152
1153        // `redact_url_with_query_allowlist`: base + kept query is 252 bytes,
1154        // so byte 256 lands inside the appended `#[redacted]` (starts at
1155        // 252) pre-fix.
1156        let broker = format!("tcp://{}?keep=1#frag", "a".repeat(240));
1157        let redacted = redact_url_with_query_allowlist(&broker, JMS_KEYS);
1158        assert!(redacted.len() <= 256, "len={}", redacted.len());
1159        assert!(
1160            redacted.ends_with("#[redacted]"),
1161            "allowlist sentinel must render intact: {redacted}"
1162        );
1163    }
1164
1165    // ── Migrated: camel-http lib.rs pure-string redact tests ────────────
1166    // These landed against the Err arm of `redact_url_for_diagnostics`
1167    // (unparseable inputs); that arm delegates to `redact_url` /
1168    // `redact_url_fail_closed`, so the fixtures pin the canonical helpers
1169    // byte-identically. The `url::Url::parse` precondition asserts of the
1170    // landed tests do not apply here: this module never parses.
1171
1172    #[test]
1173    fn redact_url_unparseable_fragment_credentials_dropped() {
1174        let raw = "ht tps://app.example/cb#access_token=SECRET";
1175        let redacted = redact_url(raw);
1176        assert!(
1177            !redacted.contains("SECRET"),
1178            "unparseable fragment token leaked: {redacted}"
1179        );
1180        assert!(
1181            !redacted.contains("access_token"),
1182            "unparseable fragment bytes leaked: {redacted}"
1183        );
1184        assert!(
1185            redacted.contains("#[redacted]"),
1186            "unparseable fragment must end in the sentinel: {redacted}"
1187        );
1188    }
1189
1190    #[test]
1191    fn redact_url_empty_host_userinfo_sentinel() {
1192        // The landed http test drives this through the parse-failure arm;
1193        // the canonical helper fails closed on the window `@` directly.
1194        let redacted = redact_url_fail_closed("scheme://user@");
1195        assert_eq!(
1196            redacted, "[redacted]",
1197            "empty-host userinfo must fail closed: {redacted}"
1198        );
1199    }
1200
1201    #[test]
1202    fn redact_url_unparseable_slash_run_evader_sentinel() {
1203        let raw = "schem e:////user:pass@evil/";
1204        let redacted = redact_url_fail_closed(raw);
1205        assert_eq!(
1206            redacted, "[redacted]",
1207            "unparseable slash-run evader must fail closed: {redacted}"
1208        );
1209    }
1210
1211    #[test]
1212    fn redact_url_unparseable_later_window_userinfo_sentinel() {
1213        // The first `//` window ("ho st") carries no `@`, but a later
1214        // `//user:pass@evil/` window does. The scan must consider every
1215        // `//` window, not just the first, or the credentials echo.
1216        let raw = "http://ho st/a//user:pass@evil/";
1217        let redacted = redact_url_fail_closed(raw);
1218        assert_eq!(
1219            redacted, "[redacted]",
1220            "userinfo in a later // window must fail closed: {redacted}"
1221        );
1222    }
1223
1224    #[test]
1225    fn redact_url_truncates_unparseable() {
1226        let long = "x".repeat(1000);
1227        let redacted = redact_url(&long);
1228        assert_eq!(redacted.len(), 256, "unparseable URL must be truncated");
1229    }
1230
1231    #[test]
1232    fn redact_url_suppresses_unparseable_authority_credentials() {
1233        let fixtures = [
1234            "http://u:secretpw@/x",
1235            "http://u:secretpw@host:99999/x",
1236            "http://u:secretpw@host:99999",
1237            "//u:secretpw@h/x",
1238        ];
1239        for fixture in fixtures {
1240            assert_eq!(
1241                redact_url_fail_closed(fixture),
1242                "[redacted]",
1243                "credential-bearing authority must be suppressed: {fixture}"
1244            );
1245        }
1246    }
1247
1248    #[test]
1249    fn redact_url_unparseable_query_redacted_short_and_long() {
1250        let short = "http://host:99999/path?token=shortsecret";
1251        let redacted = redact_url(short);
1252        assert_eq!(
1253            redacted, "http://host:99999/path?[redacted]",
1254            "short unparseable query must end with the suffix: {redacted}"
1255        );
1256
1257        let mut long = String::from("http://host:99999/");
1258        long.push_str(&"a".repeat(300));
1259        long.push_str("?token=longsecret");
1260        let redacted = redact_url(&long);
1261        assert!(
1262            !redacted.contains("longsecret"),
1263            "long unparseable query leaked a query byte: {redacted}"
1264        );
1265        assert!(
1266            redacted.len() <= 256,
1267            "long unparseable query must be capped: {} bytes",
1268            redacted.len()
1269        );
1270    }
1271
1272    #[test]
1273    fn redact_url_unparseable_sentinels_compose_both() {
1274        // Compose-both rule: one sentinel per distinct introducer found in
1275        // the raw string, in first-occurrence order.
1276        let raw = "ht tp://h.example/p?a=1#tok=x";
1277        assert_eq!(
1278            redact_url(raw),
1279            "ht tp://h.example/p?[redacted]#[redacted]",
1280            "query and fragment sentinels must compose: {raw}"
1281        );
1282    }
1283
1284    #[test]
1285    fn redact_url_unparseable_sentinels_compose_fragment_first() {
1286        let raw = "ht tp://h.example/p#tok=x?a=1";
1287        assert_eq!(
1288            redact_url(raw),
1289            "ht tp://h.example/p#[redacted]?[redacted]",
1290            "sentinels must follow the introducers' first-occurrence order: {raw}"
1291        );
1292    }
1293
1294    #[test]
1295    fn redact_url_unparseable_utf8_straddle_no_panic() {
1296        let fixture = format!("a{}", "é".repeat(200));
1297        let redacted = redact_url(&fixture);
1298        assert!(
1299            redacted.len() <= 256,
1300            "straddle fixture must be capped: {} bytes",
1301            redacted.len()
1302        );
1303        assert!(
1304            redacted.len() >= 253,
1305            "straddle fixture must not over-truncate: {} bytes",
1306            redacted.len()
1307        );
1308        assert!(
1309            fixture.is_char_boundary(redacted.len()),
1310            "cut must land on a UTF-8 char boundary: {} bytes",
1311            redacted.len()
1312        );
1313    }
1314
1315    #[test]
1316    fn redact_url_at_sign_outside_authority_window_visible() {
1317        let at_sign_in_path = "http://host:99999/x@y";
1318        assert_eq!(
1319            redact_url_fail_closed(at_sign_in_path),
1320            at_sign_in_path,
1321            "at-sign in path must not be suppressed"
1322        );
1323        // Opaque non-hierarchical strings pass through byte-identically.
1324        assert_eq!(
1325            redact_url("mailto:user@example.com"),
1326            "mailto:user@example.com",
1327            "at-sign in mailto must round-trip byte-identically"
1328        );
1329    }
1330
1331    // ── Phase 3: scheme-gated backslash windows (rc-f05q8) ──────────────
1332
1333    /// A backslash authority after a non-special scheme opens a window:
1334    /// `foo:\user:pass@evil/` carries no `//` run, yet the scheme-prefixed
1335    /// backslash run must mask the userinfo. The fail-closed variant
1336    /// suppresses the whole string; the clean sibling stays visible.
1337    #[test]
1338    fn backslash_run_non_special_scheme_masked() {
1339        assert_eq!(
1340            redact_url("foo:\\user:pass@evil/"),
1341            "foo:\\***@evil/",
1342            "non-special-scheme backslash authority must mask userinfo"
1343        );
1344        assert_eq!(
1345            redact_url_fail_closed("foo:\\user:pass@evil/"),
1346            "[redacted]",
1347            "non-special-scheme backslash authority must fail closed"
1348        );
1349        assert_eq!(
1350            redact_url("foo:\\clean/path"),
1351            "foo:\\clean/path",
1352            "clean backslash sibling stays visible"
1353        );
1354    }
1355
1356    /// A single backslash after a multi-character scheme opens a window
1357    /// even though the run is one character long.
1358    #[test]
1359    fn backslash_single_after_multi_char_scheme_masked() {
1360        let redacted = redact_url("http:\\user:pass@evil\\path");
1361        assert!(
1362            !redacted.contains("user:pass"),
1363            "single-backslash authority leaked: {redacted}"
1364        );
1365        assert!(
1366            redacted.contains("***@"),
1367            "single-backslash authority must mask userinfo: {redacted}"
1368        );
1369    }
1370
1371    /// A single backslash after a one-character scheme opens a window when
1372    /// the candidate window content is credential-shaped (`:` before the
1373    /// last `@`).
1374    #[test]
1375    fn backslash_single_after_one_char_scheme_credential_shaped_masked() {
1376        let redacted = redact_url("x:\\user:pass@evil");
1377        assert!(
1378            !redacted.contains("user:pass"),
1379            "one-char-scheme backslash authority leaked: {redacted}"
1380        );
1381        assert!(
1382            redacted.contains("***@"),
1383            "one-char-scheme backslash authority must mask userinfo: {redacted}"
1384        );
1385    }
1386
1387    /// Windows drive path: single backslash after a one-character scheme
1388    /// with no `:` in the candidate window — no qualifying window, the
1389    /// string renders unchanged.
1390    #[test]
1391    fn drive_path_stays_visible() {
1392        assert_eq!(
1393            redact_url("C:\\Users\\x@corp\\file"),
1394            "C:\\Users\\x@corp\\file"
1395        );
1396    }
1397
1398    /// UNC path: no scheme prefix before the backslash run — no qualifying
1399    /// window, the string renders unchanged.
1400    #[test]
1401    fn unc_path_stays_visible() {
1402        assert_eq!(redact_url("\\\\server\\x@y"), "\\\\server\\x@y");
1403    }
1404
1405    /// The fail-closed window scan must see scheme-prefixed backslash
1406    /// windows, while drive paths stay window-free.
1407    #[test]
1408    fn window_has_at_sign_sees_backslash_windows() {
1409        assert!(
1410            window_has_at_sign("foo:\\u:p@e/"),
1411            "scheme-prefixed backslash window must carry the at-sign"
1412        );
1413        assert!(
1414            !window_has_at_sign("C:\\Users\\x@corp\\file"),
1415            "drive path must not open a backslash window"
1416        );
1417    }
1418
1419    /// Branch pins for the scheme-gated backslash window rule: a len-2
1420    /// backslash run behind a scheme prefix opens (introducer stays
1421    /// visible), a mixed `/`+`\` run of length 2 behind a 1-char scheme
1422    /// opens, an invalid scheme char before the run gates it off, a
1423    /// scheme-less mixed run stays closed, and a single `\` behind a
1424    /// 1-char scheme with no `:` in the candidate content stays closed.
1425    #[test]
1426    fn backslash_gate_branches_pinned() {
1427        assert_eq!(redact_url("foo:\\\\user:pass@evil/"), "foo:\\\\***@evil/");
1428        assert_eq!(redact_url("a:/\\user:pass@evil/"), "a:/\\***@evil/");
1429        assert_eq!(
1430            redact_url("notscheme%\\user:pass@evil/"),
1431            "notscheme%\\user:pass@evil/"
1432        );
1433        assert_eq!(redact_url("/\\user:pass@evil/"), "/\\user:pass@evil/");
1434        assert_eq!(redact_url("a:\\user@evil"), "a:\\user@evil");
1435        assert!(window_has_at_sign("foo:\\\\u:p@e/"));
1436        assert!(!window_has_at_sign("a:\\user@evil"));
1437    }
1438
1439    /// Key-position credential-shape predicate (bd rc-yvjp3): a raw query
1440    /// key that decodes to `user:secret@host` is credential-shaped, while a
1441    /// benign parameter name is not.
1442    #[test]
1443    fn credential_shaped_key_predicate_flags_userinfo() {
1444        assert!(is_credential_shaped_key("user:secret@host"));
1445        assert!(!is_credential_shaped_key("mysteryBox"));
1446    }
1447
1448    /// The same predicate must see through a single-pass percent-encoded
1449    /// credential shape.
1450    #[test]
1451    fn credential_shaped_key_predicate_flags_percent_encoded() {
1452        assert!(is_credential_shaped_key("user%3Asecret%40host"));
1453    }
1454}