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/// Untruncated variant of [`redact_url_with_query_allowlist`]: identical
318/// windowed userinfo masking, per-key query redaction, and fragment
319/// suppression, but WITHOUT the 256-byte diagnostic cap. Callers that use
320/// the redacted string as stable identity material (for example a
321/// broker-key hash) MUST use this variant: the 256-byte cap conflates two
322/// long URLs that share a prefix but differ in a distinct address after
323/// the cap. Never use it as a rendered diagnostic surface.
324pub fn redact_url_with_query_allowlist_untruncated(
325 raw: &str,
326 sensitive_key_substrings: &[&str],
327) -> String {
328 redact_url_with_query_allowlist_impl(raw, sensitive_key_substrings, None)
329}
330
331/// Shared implementation for the truncating and untruncated variants.
332/// `cap` is `Some(256)` for the diagnostic surface and `None` for stable
333/// identity material. When capped, the fragment sentinel is reserved before
334/// the cut so the cap never splits it (e_gpt stage-4).
335fn redact_url_with_query_allowlist_impl(
336 raw: &str,
337 sensitive_key_substrings: &[&str],
338 cap: Option<usize>,
339) -> String {
340 // 1) Mask userinfo in every authority window (see [`mask_authority_windows`]).
341 let after_userinfo = mask_authority_windows(raw);
342 // 2) Redact sensitive query params, keep the rest for diagnosability.
343 let mut out = match after_userinfo.split_once('?') {
344 Some((base, query)) => {
345 let redacted: Vec<String> = query
346 .split('&')
347 .map(|pair| {
348 let raw_key = pair.split('=').next().unwrap_or(pair);
349 let match_key = decode_match_key(raw_key);
350 if sensitive_key_substrings
351 .iter()
352 .any(|s| match_key.contains(s))
353 {
354 // Key-position symmetry (bd rc-yvjp3): a
355 // credential-shaped key is fully suppressed — the
356 // key bytes may themselves be the credential.
357 if is_credential_shaped(&minimal_decode_pair(raw_key)) {
358 "<redacted>".to_string()
359 } else {
360 format!("{raw_key}=<redacted>")
361 }
362 } else if is_credential_shaped(&minimal_decode_pair(pair)) {
363 "<redacted>".to_string()
364 } else {
365 pair.to_string()
366 }
367 })
368 .collect();
369 format!("{base}?{}", redacted.join("&"))
370 }
371 None => after_userinfo,
372 };
373 // 3) Fragments are never echoed. Reserve the sentinel bytes before
374 // truncating so the cap never splits the appended `#[redacted]`
375 // (e_gpt stage-4); the kept query content is part of the base.
376 if let Some(i) = out.find('#') {
377 out.truncate(i);
378 if let Some(cap) = cap {
379 truncate_utf8_safe(&mut out, cap - 11);
380 }
381 out.push_str("#[redacted]");
382 }
383 if let Some(cap) = cap {
384 truncate_utf8_safe(&mut out, cap);
385 }
386 out
387}
388
389/// Whether a `@` appears in any authority window of `raw`. Windows come
390/// from maximal runs of `/` and `\` characters — the same enumeration the
391/// mask uses: pure-slash runs of two or more characters always open a
392/// window; backslash-bearing runs open one only behind an RFC 3986 scheme
393/// prefix ending immediately before the run. Every run is scanned, so
394/// credentials cannot hide in a later window behind a benign first one
395/// (`http://h/a//user:pass@e/`) or behind a scheme-prefixed backslash
396/// authority (`foo:\u:p@e/`), while scheme-less drive and UNC paths stay
397/// window-free.
398pub fn window_has_at_sign(raw: &str) -> bool {
399 authority_windows(raw)
400 .into_iter()
401 .any(|(start, end)| raw[start..end].contains('@'))
402}
403
404/// Redact a bare host value for logs (ADR-0076, `lint-log-redaction`).
405/// A bare host carries no `//` authority window, so [`redact_url`] is a
406/// semantic no-op on it; this helper masks everything before the LAST
407/// `@` instead (`***@host`). The host grammar carries no userinfo, but
408/// the value can be externally influenced (a config field the URI
409/// parser can route `user:pass@host` into), so userinfo-shaped input is
410/// masked defensively — through the LAST `@`, because over-masking is
411/// safe and under-masking is not. Clean hosts pass through unchanged
412/// for diagnosability. Canonical single source: components log `host`
413/// fields through this helper, not crate-local twins (bd rc-8bxeo).
414pub fn redact_host(host: &str) -> String {
415 match host.rsplit_once('@') {
416 Some((_, after)) => format!("***@{after}"),
417 None => host.to_string(),
418 }
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424
425 /// Key denylist used by the camel-jms broker redactor (7 entries, same
426 /// order as the landed `redact_broker_url` list).
427 const JMS_KEYS: &[&str] = &[
428 "password",
429 "passwd",
430 "secret",
431 "credential",
432 "token",
433 "username",
434 "user",
435 ];
436
437 // ── Spec-pinned scenarios (redact2 security requirement) ─────────────
438
439 #[test]
440 fn strict_masks_windows_and_composes_sentinels() {
441 assert_eq!(
442 redact_url("https://user:pass@h/p?a=1#t=x"),
443 "https://***@h/p?[redacted]#[redacted]"
444 );
445 // A later `//` window is masked too: credentials cannot hide behind
446 // a benign first window.
447 assert_eq!(redact_url("https://h//u2:p2@evil/"), "https://h//***@evil/");
448 }
449
450 #[test]
451 fn fail_closed_suppresses_window_with_at() {
452 assert_eq!(
453 redact_url_fail_closed("http://u:secretpw@host:99999/x"),
454 "[redacted]"
455 );
456 }
457
458 #[test]
459 fn sentinel_never_splits_at_cap() {
460 // Pre-sentinel base (up to the earliest `?`) is 316 bytes; the
461 // 3-byte `日` straddles the reserved cut at byte 234, forcing the
462 // boundary walk. Both sentinels must render intact.
463 let mut url = format!("https://host/{}日{}", "x".repeat(220), "x".repeat(80));
464 url.push_str("?a=1#f");
465 assert!(url.len() > 300);
466 let redacted = redact_url(&url);
467 assert!(redacted.len() <= 256, "len={}", redacted.len());
468 assert!(
469 redacted.ends_with("?[redacted]#[redacted]"),
470 "sentinels must render intact: {redacted}"
471 );
472 assert!(std::str::from_utf8(redacted.as_bytes()).is_ok());
473 assert!(
474 !redacted.contains('日'),
475 "straddling char dropped whole: {redacted}"
476 );
477 assert!(
478 redacted.starts_with("https://host/"),
479 "host kept: {redacted}"
480 );
481 }
482
483 #[test]
484 fn idempotent_mask_composition_pin() {
485 // An already-masked window rewrites to itself and the sentinels
486 // still compose.
487 assert_eq!(
488 redact_url("https://***@host/p?a=1#f"),
489 "https://***@host/p?[redacted]#[redacted]"
490 );
491 }
492
493 #[test]
494 fn allowlist_keeps_benign_and_redacts_sensitive() {
495 assert_eq!(
496 redact_url_with_query_allowlist(
497 "tcp://host:61616?password=p&user=u&keepAlive=true",
498 JMS_KEYS
499 ),
500 "tcp://host:61616?password=<redacted>&user=<redacted>&keepAlive=true"
501 );
502 }
503
504 // ── Phase 2: minimal-decode rule (bd rc-r7v8s) ──────────────────────
505
506 /// Percent-encoded credentials under a benign key must not smuggle
507 /// through: the single-pass minimal decode of the pair carries `@` and
508 /// `//`, so the whole pair redacts. Uppercase hex form.
509 #[test]
510 fn allowlist_masks_percent_encoded_credentials_uppercase() {
511 let redacted = redact_url_with_query_allowlist(
512 "tcp://h:61616?redirect=http%3A%2F%2Fuser%3Asecret%40host",
513 JMS_KEYS,
514 );
515 assert!(
516 !redacted.contains("secret"),
517 "percent-encoded credential leaked: {redacted}"
518 );
519 assert!(
520 !redacted.contains("user%3Asecret%40"),
521 "encoded userinfo leaked: {redacted}"
522 );
523 assert_eq!(redacted, "tcp://h:61616?<redacted>");
524 }
525
526 /// Fully-lowercase hex variant of the same smuggling shape.
527 #[test]
528 fn allowlist_masks_percent_encoded_credentials_lowercase() {
529 let redacted = redact_url_with_query_allowlist(
530 "tcp://h:61616?redirect=http%3a%2f%2fuser%3asecret%40host",
531 JMS_KEYS,
532 );
533 assert_eq!(redacted, "tcp://h:61616?<redacted>");
534 assert!(
535 !redacted.contains("secret"),
536 "percent-encoded credential leaked: {redacted}"
537 );
538 }
539
540 /// bd rc-yvjp3 (ADR-0076 appendix, key-position symmetry): a pair
541 /// whose KEY embeds credentials (`user%3Asecret%40host` decodes to
542 /// `user:secret@host`) renders as a bare `<redacted>` — the key bytes
543 /// never echo, in either hex case.
544 #[test]
545 fn allowlist_suppresses_credential_shaped_key_uppercase() {
546 let redacted =
547 redact_url_with_query_allowlist("tcp://h:61616?user%3Asecret%40host=1", JMS_KEYS);
548 assert_eq!(redacted, "tcp://h:61616?<redacted>");
549 assert!(
550 !redacted.contains("user%3Asecret%40"),
551 "credential-shaped key leaked: {redacted}"
552 );
553 assert!(
554 !redacted.contains("secret"),
555 "key credential bytes leaked: {redacted}"
556 );
557 }
558
559 /// Fully-lowercase hex variant of the key-position shape.
560 #[test]
561 fn allowlist_suppresses_credential_shaped_key_lowercase() {
562 let redacted =
563 redact_url_with_query_allowlist("tcp://h:61616?user%3asecret%40host=1", JMS_KEYS);
564 assert_eq!(redacted, "tcp://h:61616?<redacted>");
565 }
566
567 /// Key-position symmetry, literal form: a fully literal
568 /// `user:pass@host` key is suppressed whole.
569 #[test]
570 fn allowlist_suppresses_literal_credential_shaped_key() {
571 let redacted = redact_url_with_query_allowlist("tcp://h:61616?user:pass@host=1", JMS_KEYS);
572 assert_eq!(redacted, "tcp://h:61616?<redacted>");
573 assert!(
574 !redacted.contains("user:pass"),
575 "literal key credentials leaked: {redacted}"
576 );
577 }
578
579 /// bd rc-yvjp3 non-regression: well-known sensitive keys that are NOT
580 /// credential-shaped keep echoing their names — the ADR-0076 exception
581 /// names transport-policy parameter visibility as the sole diagnostic
582 /// value of the broker URL, and no real parameter name decodes to an
583 /// `@`+`:`/`//` shape.
584 #[test]
585 fn allowlist_keeps_well_known_key_names_visible() {
586 let redacted = redact_url_with_query_allowlist(
587 "tcp://h:61616?password=p&jms.userName=admin&user=u&keepAlive=true",
588 JMS_KEYS,
589 );
590 assert_eq!(
591 redacted,
592 "tcp://h:61616?password=<redacted>&jms.userName=<redacted>&user=<redacted>&keepAlive=true"
593 );
594 }
595
596 /// bd rc-yvjp3 boundary pin: a denylist-matching key with a lone `@`
597 /// (no `:`, no `//`) is NOT credential-shaped — it renders
598 /// `user@host=<redacted>` and the key echoes, mirroring the lone-`@`
599 /// email rule of the benign-key branch.
600 #[test]
601 fn allowlist_keeps_lone_at_key_visible() {
602 let redacted = redact_url_with_query_allowlist("tcp://h:61616?user@host=1", JMS_KEYS);
603 assert_eq!(redacted, "tcp://h:61616?user@host=<redacted>");
604 }
605
606 /// bd rc-yvjp3 non-regression: an encoded-but-not-credential-shaped
607 /// key (`%77` is outside the minimal-decode triple) still echoes its
608 /// authored bytes when the decoded name hits the denylist — the
609 /// `pass%77ord` match rule (bd rc-r7v8s) is unchanged.
610 #[test]
611 fn allowlist_keeps_encoded_but_benign_shaped_key_visible() {
612 let redacted = redact_url_with_query_allowlist("tcp://h:61616?pass%77ord=p", JMS_KEYS);
613 assert_eq!(redacted, "tcp://h:61616?pass%77ord=<redacted>");
614 }
615
616 /// Encoded slashes plus a literal `@` decode to a `//`-bearing
617 /// credential shape under a benign key.
618 #[test]
619 fn allowlist_masks_literal_at_bypass() {
620 let redacted =
621 redact_url_with_query_allowlist("tcp://h:61616?next=%2F%2Fuser:pass@host", JMS_KEYS);
622 assert_eq!(redacted, "tcp://h:61616?<redacted>");
623 assert!(
624 !redacted.contains("pass"),
625 "literal credential leaked: {redacted}"
626 );
627 }
628
629 /// Fully literal `user:pass@host` under a benign key.
630 #[test]
631 fn allowlist_masks_fully_literal_credential_pair() {
632 let redacted =
633 redact_url_with_query_allowlist("tcp://h:61616?next=user: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 /// Regression pin at the under-redaction boundary: `@` without `:` or
642 /// `//` is an email address, not a credential — the pair stays visible.
643 #[test]
644 fn allowlist_keeps_lone_email_value() {
645 assert_eq!(
646 redact_url_with_query_allowlist("tcp://h:61616?contact=admin%40corp.example", JMS_KEYS),
647 "tcp://h:61616?contact=admin%40corp.example"
648 );
649 }
650
651 /// Over-mask stance (ADR-0051): a value decoding to `user@host:port`
652 /// is credential-shaped even though no literal `//` or `:` is present.
653 #[test]
654 fn allowlist_masks_credential_shaped_userhostport() {
655 let redacted =
656 redact_url_with_query_allowlist("tcp://h:61616?next=user%40host%3Aport", JMS_KEYS);
657 assert_eq!(redacted, "tcp://h:61616?<redacted>");
658 }
659
660 /// A percent-encoded sensitive key decodes to `password` and must
661 /// redact; the rendered key keeps its original encoded bytes.
662 #[test]
663 fn allowlist_decodes_percent_encoded_sensitive_key() {
664 let redacted =
665 redact_url_with_query_allowlist("tcp://host:61616?pass%77ord=shortsecret", JMS_KEYS);
666 assert!(
667 redacted.contains("pass%77ord=<redacted>"),
668 "encoded key must redact keeping original bytes: {redacted}"
669 );
670 assert!(
671 !redacted.contains("shortsecret"),
672 "secret value leaked: {redacted}"
673 );
674 }
675
676 /// Regression pin (redact2): non-ASCII bytes in a pair are preserved
677 /// byte-for-byte (valid UTF-8), never Latin-1-transcoded, and a decoded
678 /// invalid byte becomes `U+FFFD` — the decoded output feeds only ASCII
679 /// shape checks, so neither case can flip a redaction decision.
680 #[test]
681 fn minimal_decode_preserves_non_ascii_bytes() {
682 // `é` (0xC3 0xA9) must survive as itself, not as `é`.
683 assert_eq!(minimal_decode_pair("café"), "café");
684 // Escapes still decode around non-ASCII bytes.
685 assert_eq!(minimal_decode_pair("café%40x"), "café@x");
686 // A decoded invalid byte (0xFF from `%FF`) becomes U+FFFD, which
687 // cannot match an ASCII needle (exercised via the full-hex decoder).
688 assert_eq!(decode_match_key("a%FFb"), "a\u{FFFD}b");
689 }
690
691 // ── Migrated: camel-config config_tests/url_redaction_tests.rs ──────
692
693 #[test]
694 fn redact_url_keeps_userinfo_mask_shape() {
695 assert_eq!(
696 redact_url("redis://user:secret@h:6379"),
697 "redis://***@h:6379"
698 );
699 }
700
701 /// Intentional semantic change: the query was previously echoed verbatim,
702 /// leaking `?password=...` cache credentials into Debug output.
703 #[test]
704 fn redact_url_drops_query_secrets() {
705 assert_eq!(
706 redact_url("redis://h:6379/0?password=hunter2"),
707 "redis://h:6379/0?[redacted]"
708 );
709 }
710
711 #[test]
712 fn redact_url_drops_fragment() {
713 assert_eq!(
714 redact_url("redis://h:6379/0#tok=x"),
715 "redis://h:6379/0#[redacted]"
716 );
717 }
718
719 /// Multiple `@` in the window: mask through the LAST one — over-masking is
720 /// safe, under-masking is not.
721 #[test]
722 fn redact_url_masks_through_last_at() {
723 assert_eq!(redact_url("redis://user:p@ss@h:6379"), "redis://***@h:6379");
724 }
725
726 /// Bare-host masking (bd rc-8bxeo, promoted from the camel-ws /
727 /// camel-http twins): clean hosts pass through unchanged for
728 /// diagnosability; userinfo-shaped values mask through the LAST `@`.
729 #[test]
730 fn redact_host_masks_userinfo_keeps_clean_hosts() {
731 assert_eq!(redact_host("localhost"), "localhost");
732 assert_eq!(redact_host("broker.example.com"), "broker.example.com");
733 assert_eq!(redact_host("host.example:8080"), "host.example:8080");
734 assert_eq!(redact_host("user:pass@host"), "***@host");
735 assert_eq!(redact_host("bob:p@ss@host"), "***@host");
736 assert_eq!(redact_host("a@b@c"), "***@c");
737 }
738
739 /// A slash run after `//` must not hide userinfo behind it; extra leading
740 /// slashes are kept byte-for-byte.
741 #[test]
742 fn redact_url_slash_run_evader_masked() {
743 assert_eq!(
744 redact_url("redis:////user:pass@h:6379/0"),
745 "redis:////***@h:6379/0"
746 );
747 }
748
749 /// An `@` outside any `//` window (path data here) is not userinfo.
750 #[test]
751 fn redact_url_at_outside_window_visible() {
752 assert_eq!(
753 redact_url("redis://h:6379/0/user@x"),
754 "redis://h:6379/0/user@x"
755 );
756 }
757
758 /// A `//` window after the first is scanned too: credentials cannot hide
759 /// behind a benign first window.
760 #[test]
761 fn redact_url_later_window_masked() {
762 assert_eq!(redact_url("redis://h//user:pass@x/"), "redis://h//***@x/");
763 }
764
765 /// Compose-both rule: one sentinel per distinct introducer found in the
766 /// raw URL, in first-occurrence order.
767 #[test]
768 fn redact_url_sentinels_compose_both() {
769 assert_eq!(
770 redact_url("redis://h:6379/0?password=x#tok=y"),
771 "redis://h:6379/0?[redacted]#[redacted]"
772 );
773 }
774
775 #[test]
776 fn redact_url_sentinels_compose_fragment_first() {
777 assert_eq!(
778 redact_url("redis://h:6379/0#tok=y?password=x"),
779 "redis://h:6379/0#[redacted]?[redacted]"
780 );
781 }
782
783 /// The 256-byte cap must land on a UTF-8 char boundary: a 3-byte char
784 /// straddling byte 256 forces the cut to walk back instead of panicking.
785 #[test]
786 fn redact_url_truncates_256_utf8_safe() {
787 let mut url = format!("redis://{}{}", "x".repeat(246), '日');
788 url.push_str(&"tail".repeat(20));
789 assert!(url.len() > 300);
790 let redacted = redact_url(&url);
791 assert!(redacted.len() <= 256, "len={}", redacted.len());
792 assert!(redacted.starts_with("redis://"));
793 }
794
795 /// e_gpt stage-4: the 256-byte cap must not split an appended sentinel.
796 /// The base is truncated at `256 - sentinel_len` BEFORE the sentinel is
797 /// appended, so the sentinel always renders intact and the total stays
798 /// ≤ 256.
799 #[test]
800 fn redact_url_keeps_sentinel_intact_under_256_cap() {
801 // Base (masked, cut at the `?`) is 248 bytes, so byte 256 lands inside
802 // the appended `?[redacted]` (starts at 248) pre-fix.
803 let url = format!("http://{}?x=1", "a".repeat(240));
804 let redacted = redact_url(&url);
805 assert!(redacted.len() <= 256, "len={}", redacted.len());
806 assert!(
807 redacted.ends_with("?[redacted]"),
808 "sentinel must render intact: {redacted}"
809 );
810 }
811
812 /// The untruncated variant must keep every byte that the 256-byte
813 /// diagnostic cap would otherwise drop, so it can serve as stable
814 /// identity material without conflating two long URLs that share a
815 /// prefix.
816 #[test]
817 fn redact_url_with_query_allowlist_untruncated_keeps_full_length() {
818 let long = format!(
819 "tcp://h:61616?keepAlive=true&brokerName={}",
820 "segment".repeat(60)
821 );
822 assert!(long.len() > 256);
823 let capped = redact_url_with_query_allowlist(&long, &[]);
824 let full = redact_url_with_query_allowlist_untruncated(&long, &[]);
825 assert!(capped.len() <= 256, "len={}", capped.len());
826 assert_eq!(full, long, "untruncated must preserve the whole string");
827 assert!(full.len() > capped.len());
828 // Redaction still applies without the cap.
829 let mut secret = String::from("tcp://admin:secret@h:61616?keepAlive=true&padding=");
830 secret.push_str(&"x".repeat(300));
831 let redacted = redact_url_with_query_allowlist_untruncated(&secret, &["password", "user"]);
832 assert!(!redacted.contains("secret"));
833 assert!(redacted.len() > 256);
834 }
835
836 // ── Migrated: camel-jms config.rs broker allowlist tests ────────────
837
838 /// Audit 2026-08-31, F5-3: JMS broker URLs must not leak credentials
839 /// through Debug output.
840 #[test]
841 fn redact_broker_url_masks_userinfo_and_sensitive_query() {
842 // userinfo form
843 let redacted = redact_url_with_query_allowlist(
844 "tcp://admin:secretpass@broker.example.com:61616",
845 JMS_KEYS,
846 );
847 assert!(
848 !redacted.contains("secretpass"),
849 "password masked: {redacted}"
850 );
851 assert!(
852 redacted.contains("broker.example.com"),
853 "host visible: {redacted}"
854 );
855
856 // failover + query-param form (ActiveMQ style)
857 let redacted = redact_url_with_query_allowlist(
858 "failover:(tcp://host:61616)?jms.userName=admin&jms.password=secret&keepAlive=true",
859 JMS_KEYS,
860 );
861 assert!(
862 !redacted.contains("secret"),
863 "password param masked: {redacted}"
864 );
865 assert!(
866 !redacted.contains("=admin"),
867 "username param masked: {redacted}"
868 );
869 assert!(
870 redacted.contains("keepAlive=true"),
871 "benign param kept: {redacted}"
872 );
873
874 // clean URL untouched
875 assert_eq!(
876 redact_url_with_query_allowlist("tcp://host:61616", JMS_KEYS),
877 "tcp://host:61616"
878 );
879 }
880
881 /// Exact-output pin: userinfo is fully masked by `***`, never partially
882 /// truncated, in scheme://...@ authority position.
883 #[test]
884 fn redact_exact_userinfo_mask() {
885 assert_eq!(
886 redact_url_with_query_allowlist(
887 "tcp://admin:secretpass@broker.example.com:61616",
888 JMS_KEYS
889 ),
890 "tcp://***@broker.example.com:61616"
891 );
892 }
893
894 /// Exact-output pin: sensitive query params redact to `<redacted>`,
895 /// benign params survive byte-for-byte, `&` separators preserved.
896 #[test]
897 fn redact_exact_query_join() {
898 assert_eq!(
899 redact_url_with_query_allowlist(
900 "tcp://host:61616?password=p&user=u&keepAlive=true",
901 JMS_KEYS
902 ),
903 "tcp://host:61616?password=<redacted>&user=<redacted>&keepAlive=true"
904 );
905 }
906
907 /// Exact-output pin: a bare `user@host` (no scheme) is NOT an authority
908 /// position — passthrough untouched.
909 #[test]
910 fn redact_exact_bare_at_passthrough() {
911 assert_eq!(
912 redact_url_with_query_allowlist("admin@host", JMS_KEYS),
913 "admin@host"
914 );
915 }
916
917 /// Exact-output pin: delimiter-exact query redaction on the ActiveMQ
918 /// failover form — the whole query part after '?' must match verbatim,
919 /// no dropped params, no mangled separators.
920 #[test]
921 fn redact_exact_failover_param_boundaries() {
922 let redacted = redact_url_with_query_allowlist(
923 "failover:(tcp://host:61616)?jms.userName=admin&jms.password=secret&keepAlive=true",
924 JMS_KEYS,
925 );
926 let (_, query) = redacted.split_once('?').expect("query segment after '?'");
927 assert_eq!(
928 query,
929 "jms.userName=<redacted>&jms.password=<redacted>&keepAlive=true"
930 );
931 }
932
933 /// bd rc-eh49 exact pin: an `@` riding the query is NOT userinfo — the
934 /// URL must pass through byte-for-byte. The old whole-string
935 /// `split_once('@')` mangled this to `failover:(tcp://***@b`.
936 #[test]
937 fn redact_broker_url_query_at_no_misfire() {
938 assert_eq!(
939 redact_url_with_query_allowlist("failover:(tcp://h:61616)?x=a@b", JMS_KEYS),
940 "failover:(tcp://h:61616)?x=a@b"
941 );
942 }
943
944 /// bd rc-eh49 exact pin: a slash run after `//` is preserved
945 /// byte-for-byte and the windowed mask composes with the per-key query
946 /// allowlist. The old whole-string scan rewrote from the scheme's own
947 /// `://` and swallowed the extra slashes.
948 #[test]
949 fn redact_exact_slash_run_window_composition() {
950 assert_eq!(
951 redact_url_with_query_allowlist("tcp:////user:pass@h:61616?keepAlive=true", JMS_KEYS),
952 "tcp:////***@h:61616?keepAlive=true"
953 );
954 }
955
956 /// bd rc-eh49 exact pin: the window mask consumes through the LAST `@`
957 /// of the window, so an `@` embedded in the userinfo cannot keep a
958 /// spoofable prefix alive (the old first-`@` scan left `a@` visible).
959 #[test]
960 fn redact_exact_last_at_in_window() {
961 assert_eq!(
962 redact_url_with_query_allowlist("tcp://u:p@a@h:61616", JMS_KEYS),
963 "tcp://***@h:61616"
964 );
965 }
966
967 /// bd rc-eh49: the 256-byte cap cuts on a char boundary — a multibyte
968 /// character straddling byte 256 is dropped whole, never split
969 /// mid-encode.
970 #[test]
971 fn redact_broker_url_truncate_multibyte_boundary() {
972 let mut url = String::from("tcp://broker:61616/");
973 url.push_str(&"x".repeat(236)); // 255 ASCII bytes before the multibyte char
974 url.push('日'); // 3 bytes straddling the 256-byte cap
975 url.push_str(&"y".repeat(50)); // push the total past 300 bytes
976 assert!(url.len() > 300);
977 let redacted = redact_url_with_query_allowlist(&url, JMS_KEYS);
978 assert!(redacted.len() <= 256, "len={}", redacted.len());
979 assert!(std::str::from_utf8(redacted.as_bytes()).is_ok());
980 assert!(
981 !redacted.contains('日'),
982 "straddling char dropped whole: {redacted}"
983 );
984 }
985
986 /// bd rc-eh49: a `//` window after the first is scanned too; the
987 /// benign query stays visible per the broker allowlist exception.
988 #[test]
989 fn redact_broker_url_later_window_masked() {
990 assert_eq!(
991 redact_url_with_query_allowlist("tcp://h//user:pass@x/?keepAlive=true", JMS_KEYS),
992 "tcp://h//***@x/?keepAlive=true"
993 );
994 }
995
996 /// bd rc-eh49: fragments are never echoed — everything from the first
997 /// `#` is dropped and the `#[redacted]` sentinel appended; processed
998 /// query params stay visible.
999 #[test]
1000 fn redact_broker_url_drops_fragment() {
1001 assert_eq!(
1002 redact_url_with_query_allowlist("tcp://h:61616?keepAlive=true#tok=x", JMS_KEYS),
1003 "tcp://h:61616?keepAlive=true#[redacted]"
1004 );
1005 }
1006
1007 /// bd rc-eh49: broker URLs are capped at 256 bytes on a UTF-8 char
1008 /// boundary.
1009 #[test]
1010 fn redact_broker_url_truncates() {
1011 let mut url = format!("tcp://broker:61616/{}", "x".repeat(300));
1012 url.push('日');
1013 url.push_str(&"tail".repeat(20));
1014 assert!(url.len() > 300);
1015 let redacted = redact_url_with_query_allowlist(&url, JMS_KEYS);
1016 assert!(redacted.len() <= 256, "len={}", redacted.len());
1017 assert!(redacted.starts_with("tcp://broker:61616/"));
1018 }
1019
1020 // ── Migrated: camel-jms component.rs redact_url_* block ─────────────
1021
1022 #[test]
1023 fn redact_url_strips_userinfo_with_password() {
1024 assert_eq!(
1025 redact_url("tcp://admin:s3cret@broker:61616"),
1026 "tcp://***@broker:61616"
1027 );
1028 }
1029
1030 #[test]
1031 fn redact_url_strips_userinfo_without_password() {
1032 assert_eq!(
1033 redact_url("tcp://admin@broker:61616"),
1034 "tcp://***@broker:61616"
1035 );
1036 }
1037
1038 #[test]
1039 fn redact_url_passes_clean_url_unchanged() {
1040 assert_eq!(redact_url("tcp://localhost:61616"), "tcp://localhost:61616");
1041 }
1042
1043 #[test]
1044 fn redact_url_handles_ssl_scheme() {
1045 assert_eq!(
1046 redact_url("ssl://user:pass@secure-broker:61617"),
1047 "ssl://***@secure-broker:61617"
1048 );
1049 }
1050
1051 /// bd rc-eh49: the earliest `?`/`#` introducer wins; both sentinels
1052 /// compose in first-occurrence order when the raw URL carries both
1053 /// introducers, and fragment bytes after the cut are dropped with it.
1054 #[test]
1055 fn redact_url_drops_query_and_fragment() {
1056 assert_eq!(
1057 redact_url("tcp://broker:61616?user=a#tok=x"),
1058 "tcp://broker:61616?[redacted]#[redacted]"
1059 );
1060 }
1061
1062 /// bd rc-eh49 compose-both rule: a `#` before `?` flips the sentinel
1063 /// order accordingly. (Name prefixed `jms_`: the redis fixture from
1064 /// camel-config already owns the unprefixed name above.)
1065 #[test]
1066 fn jms_redact_url_sentinels_compose_fragment_first() {
1067 assert_eq!(
1068 redact_url("tcp://broker:61616#tok=x?user=a"),
1069 "tcp://broker:61616#[redacted]?[redacted]"
1070 );
1071 }
1072
1073 /// bd rc-eh49: a `//` window after the first is scanned too —
1074 /// credentials cannot hide behind a benign first window. (Name prefixed
1075 /// `jms_`: the redis fixture from camel-config already owns the
1076 /// unprefixed name above.)
1077 #[test]
1078 fn jms_redact_url_later_window_masked() {
1079 assert_eq!(redact_url("tcp://h//user:pass@x/"), "tcp://h//***@x/");
1080 }
1081
1082 /// bd rc-eh49: a slash run after `//` cannot hide userinfo from the
1083 /// window scan.
1084 #[test]
1085 fn redact_url_slash_run_masked() {
1086 let redacted = redact_url("tcp:////user:pass@broker:61616");
1087 assert!(!redacted.contains("user:pass"), "leaked: {redacted}");
1088 assert!(
1089 redacted.contains("***@broker:61616"),
1090 "masked in place: {redacted}"
1091 );
1092 }
1093
1094 /// bd rc-eh49: the old first-`@`-anywhere scan masked through an `@`
1095 /// riding the query (`tcp://***@b`); only a window `@` is userinfo.
1096 #[test]
1097 fn redact_url_at_in_query_not_userinfo_mask() {
1098 assert_eq!(
1099 redact_url("tcp://broker:61616?q=a@b"),
1100 "tcp://broker:61616?[redacted]"
1101 );
1102 }
1103
1104 /// bd rc-eh49: the 256-byte cap cuts on a char boundary — a multibyte
1105 /// character straddling byte 256 is dropped whole, never split
1106 /// mid-encode.
1107 #[test]
1108 fn redact_url_truncate_multibyte_boundary() {
1109 let mut url = String::from("tcp://broker:61616/");
1110 url.push_str(&"x".repeat(236)); // 255 ASCII bytes before the multibyte char
1111 url.push('日'); // 3 bytes straddling the 256-byte cap
1112 url.push_str(&"y".repeat(50)); // push the total past 300 bytes
1113 assert!(url.len() > 300);
1114 let redacted = redact_url(&url);
1115 assert!(redacted.len() <= 256, "len={}", redacted.len());
1116 assert!(std::str::from_utf8(redacted.as_bytes()).is_ok());
1117 assert!(
1118 !redacted.contains('日'),
1119 "straddling char dropped whole: {redacted}"
1120 );
1121 }
1122
1123 /// e_gpt stage-4: the 256-byte cap must not split an appended sentinel.
1124 /// The base is truncated at `256 - sentinel_len` BEFORE the sentinel is
1125 /// appended, so the sentinel always renders intact and the total stays
1126 /// ≤ 256. Covers both redactors: `redact_url` (compose sentinels) and
1127 /// `redact_url_with_query_allowlist` (fragment sentinel over kept query
1128 /// content).
1129 #[test]
1130 fn redact_url_keeps_sentinels_intact_under_256_cap() {
1131 // `redact_url`: base (masked, cut at the `?`) is 255 bytes, so byte
1132 // 256 lands inside the appended `?[redacted]` (starts at 255) pre-fix.
1133 let url = format!("tcp://{}?x=1", "a".repeat(250));
1134 let redacted = redact_url(&url);
1135 assert!(redacted.len() <= 256, "len={}", redacted.len());
1136 assert!(
1137 redacted.ends_with("?[redacted]"),
1138 "redact_url sentinel must render intact: {redacted}"
1139 );
1140
1141 // `redact_url_with_query_allowlist`: base + kept query is 252 bytes,
1142 // so byte 256 lands inside the appended `#[redacted]` (starts at
1143 // 252) pre-fix.
1144 let broker = format!("tcp://{}?keep=1#frag", "a".repeat(240));
1145 let redacted = redact_url_with_query_allowlist(&broker, JMS_KEYS);
1146 assert!(redacted.len() <= 256, "len={}", redacted.len());
1147 assert!(
1148 redacted.ends_with("#[redacted]"),
1149 "allowlist sentinel must render intact: {redacted}"
1150 );
1151 }
1152
1153 // ── Migrated: camel-http lib.rs pure-string redact tests ────────────
1154 // These landed against the Err arm of `redact_url_for_diagnostics`
1155 // (unparseable inputs); that arm delegates to `redact_url` /
1156 // `redact_url_fail_closed`, so the fixtures pin the canonical helpers
1157 // byte-identically. The `url::Url::parse` precondition asserts of the
1158 // landed tests do not apply here: this module never parses.
1159
1160 #[test]
1161 fn redact_url_unparseable_fragment_credentials_dropped() {
1162 let raw = "ht tps://app.example/cb#access_token=SECRET";
1163 let redacted = redact_url(raw);
1164 assert!(
1165 !redacted.contains("SECRET"),
1166 "unparseable fragment token leaked: {redacted}"
1167 );
1168 assert!(
1169 !redacted.contains("access_token"),
1170 "unparseable fragment bytes leaked: {redacted}"
1171 );
1172 assert!(
1173 redacted.contains("#[redacted]"),
1174 "unparseable fragment must end in the sentinel: {redacted}"
1175 );
1176 }
1177
1178 #[test]
1179 fn redact_url_empty_host_userinfo_sentinel() {
1180 // The landed http test drives this through the parse-failure arm;
1181 // the canonical helper fails closed on the window `@` directly.
1182 let redacted = redact_url_fail_closed("scheme://user@");
1183 assert_eq!(
1184 redacted, "[redacted]",
1185 "empty-host userinfo must fail closed: {redacted}"
1186 );
1187 }
1188
1189 #[test]
1190 fn redact_url_unparseable_slash_run_evader_sentinel() {
1191 let raw = "schem e:////user:pass@evil/";
1192 let redacted = redact_url_fail_closed(raw);
1193 assert_eq!(
1194 redacted, "[redacted]",
1195 "unparseable slash-run evader must fail closed: {redacted}"
1196 );
1197 }
1198
1199 #[test]
1200 fn redact_url_unparseable_later_window_userinfo_sentinel() {
1201 // The first `//` window ("ho st") carries no `@`, but a later
1202 // `//user:pass@evil/` window does. The scan must consider every
1203 // `//` window, not just the first, or the credentials echo.
1204 let raw = "http://ho st/a//user:pass@evil/";
1205 let redacted = redact_url_fail_closed(raw);
1206 assert_eq!(
1207 redacted, "[redacted]",
1208 "userinfo in a later // window must fail closed: {redacted}"
1209 );
1210 }
1211
1212 #[test]
1213 fn redact_url_truncates_unparseable() {
1214 let long = "x".repeat(1000);
1215 let redacted = redact_url(&long);
1216 assert_eq!(redacted.len(), 256, "unparseable URL must be truncated");
1217 }
1218
1219 #[test]
1220 fn redact_url_suppresses_unparseable_authority_credentials() {
1221 let fixtures = [
1222 "http://u:secretpw@/x",
1223 "http://u:secretpw@host:99999/x",
1224 "http://u:secretpw@host:99999",
1225 "//u:secretpw@h/x",
1226 ];
1227 for fixture in fixtures {
1228 assert_eq!(
1229 redact_url_fail_closed(fixture),
1230 "[redacted]",
1231 "credential-bearing authority must be suppressed: {fixture}"
1232 );
1233 }
1234 }
1235
1236 #[test]
1237 fn redact_url_unparseable_query_redacted_short_and_long() {
1238 let short = "http://host:99999/path?token=shortsecret";
1239 let redacted = redact_url(short);
1240 assert_eq!(
1241 redacted, "http://host:99999/path?[redacted]",
1242 "short unparseable query must end with the suffix: {redacted}"
1243 );
1244
1245 let mut long = String::from("http://host:99999/");
1246 long.push_str(&"a".repeat(300));
1247 long.push_str("?token=longsecret");
1248 let redacted = redact_url(&long);
1249 assert!(
1250 !redacted.contains("longsecret"),
1251 "long unparseable query leaked a query byte: {redacted}"
1252 );
1253 assert!(
1254 redacted.len() <= 256,
1255 "long unparseable query must be capped: {} bytes",
1256 redacted.len()
1257 );
1258 }
1259
1260 #[test]
1261 fn redact_url_unparseable_sentinels_compose_both() {
1262 // Compose-both rule: one sentinel per distinct introducer found in
1263 // the raw string, in first-occurrence order.
1264 let raw = "ht tp://h.example/p?a=1#tok=x";
1265 assert_eq!(
1266 redact_url(raw),
1267 "ht tp://h.example/p?[redacted]#[redacted]",
1268 "query and fragment sentinels must compose: {raw}"
1269 );
1270 }
1271
1272 #[test]
1273 fn redact_url_unparseable_sentinels_compose_fragment_first() {
1274 let raw = "ht tp://h.example/p#tok=x?a=1";
1275 assert_eq!(
1276 redact_url(raw),
1277 "ht tp://h.example/p#[redacted]?[redacted]",
1278 "sentinels must follow the introducers' first-occurrence order: {raw}"
1279 );
1280 }
1281
1282 #[test]
1283 fn redact_url_unparseable_utf8_straddle_no_panic() {
1284 let fixture = format!("a{}", "é".repeat(200));
1285 let redacted = redact_url(&fixture);
1286 assert!(
1287 redacted.len() <= 256,
1288 "straddle fixture must be capped: {} bytes",
1289 redacted.len()
1290 );
1291 assert!(
1292 redacted.len() >= 253,
1293 "straddle fixture must not over-truncate: {} bytes",
1294 redacted.len()
1295 );
1296 assert!(
1297 fixture.is_char_boundary(redacted.len()),
1298 "cut must land on a UTF-8 char boundary: {} bytes",
1299 redacted.len()
1300 );
1301 }
1302
1303 #[test]
1304 fn redact_url_at_sign_outside_authority_window_visible() {
1305 let at_sign_in_path = "http://host:99999/x@y";
1306 assert_eq!(
1307 redact_url_fail_closed(at_sign_in_path),
1308 at_sign_in_path,
1309 "at-sign in path must not be suppressed"
1310 );
1311 // Opaque non-hierarchical strings pass through byte-identically.
1312 assert_eq!(
1313 redact_url("mailto:user@example.com"),
1314 "mailto:user@example.com",
1315 "at-sign in mailto must round-trip byte-identically"
1316 );
1317 }
1318
1319 // ── Phase 3: scheme-gated backslash windows (rc-f05q8) ──────────────
1320
1321 /// A backslash authority after a non-special scheme opens a window:
1322 /// `foo:\user:pass@evil/` carries no `//` run, yet the scheme-prefixed
1323 /// backslash run must mask the userinfo. The fail-closed variant
1324 /// suppresses the whole string; the clean sibling stays visible.
1325 #[test]
1326 fn backslash_run_non_special_scheme_masked() {
1327 assert_eq!(
1328 redact_url("foo:\\user:pass@evil/"),
1329 "foo:\\***@evil/",
1330 "non-special-scheme backslash authority must mask userinfo"
1331 );
1332 assert_eq!(
1333 redact_url_fail_closed("foo:\\user:pass@evil/"),
1334 "[redacted]",
1335 "non-special-scheme backslash authority must fail closed"
1336 );
1337 assert_eq!(
1338 redact_url("foo:\\clean/path"),
1339 "foo:\\clean/path",
1340 "clean backslash sibling stays visible"
1341 );
1342 }
1343
1344 /// A single backslash after a multi-character scheme opens a window
1345 /// even though the run is one character long.
1346 #[test]
1347 fn backslash_single_after_multi_char_scheme_masked() {
1348 let redacted = redact_url("http:\\user:pass@evil\\path");
1349 assert!(
1350 !redacted.contains("user:pass"),
1351 "single-backslash authority leaked: {redacted}"
1352 );
1353 assert!(
1354 redacted.contains("***@"),
1355 "single-backslash authority must mask userinfo: {redacted}"
1356 );
1357 }
1358
1359 /// A single backslash after a one-character scheme opens a window when
1360 /// the candidate window content is credential-shaped (`:` before the
1361 /// last `@`).
1362 #[test]
1363 fn backslash_single_after_one_char_scheme_credential_shaped_masked() {
1364 let redacted = redact_url("x:\\user:pass@evil");
1365 assert!(
1366 !redacted.contains("user:pass"),
1367 "one-char-scheme backslash authority leaked: {redacted}"
1368 );
1369 assert!(
1370 redacted.contains("***@"),
1371 "one-char-scheme backslash authority must mask userinfo: {redacted}"
1372 );
1373 }
1374
1375 /// Windows drive path: single backslash after a one-character scheme
1376 /// with no `:` in the candidate window — no qualifying window, the
1377 /// string renders unchanged.
1378 #[test]
1379 fn drive_path_stays_visible() {
1380 assert_eq!(
1381 redact_url("C:\\Users\\x@corp\\file"),
1382 "C:\\Users\\x@corp\\file"
1383 );
1384 }
1385
1386 /// UNC path: no scheme prefix before the backslash run — no qualifying
1387 /// window, the string renders unchanged.
1388 #[test]
1389 fn unc_path_stays_visible() {
1390 assert_eq!(redact_url("\\\\server\\x@y"), "\\\\server\\x@y");
1391 }
1392
1393 /// The fail-closed window scan must see scheme-prefixed backslash
1394 /// windows, while drive paths stay window-free.
1395 #[test]
1396 fn window_has_at_sign_sees_backslash_windows() {
1397 assert!(
1398 window_has_at_sign("foo:\\u:p@e/"),
1399 "scheme-prefixed backslash window must carry the at-sign"
1400 );
1401 assert!(
1402 !window_has_at_sign("C:\\Users\\x@corp\\file"),
1403 "drive path must not open a backslash window"
1404 );
1405 }
1406
1407 /// Branch pins for the scheme-gated backslash window rule: a len-2
1408 /// backslash run behind a scheme prefix opens (introducer stays
1409 /// visible), a mixed `/`+`\` run of length 2 behind a 1-char scheme
1410 /// opens, an invalid scheme char before the run gates it off, a
1411 /// scheme-less mixed run stays closed, and a single `\` behind a
1412 /// 1-char scheme with no `:` in the candidate content stays closed.
1413 #[test]
1414 fn backslash_gate_branches_pinned() {
1415 assert_eq!(redact_url("foo:\\\\user:pass@evil/"), "foo:\\\\***@evil/");
1416 assert_eq!(redact_url("a:/\\user:pass@evil/"), "a:/\\***@evil/");
1417 assert_eq!(
1418 redact_url("notscheme%\\user:pass@evil/"),
1419 "notscheme%\\user:pass@evil/"
1420 );
1421 assert_eq!(redact_url("/\\user:pass@evil/"), "/\\user:pass@evil/");
1422 assert_eq!(redact_url("a:\\user@evil"), "a:\\user@evil");
1423 assert!(window_has_at_sign("foo:\\\\u:p@e/"));
1424 assert!(!window_has_at_sign("a:\\user@evil"));
1425 }
1426}