Skip to main content

khive_runtime/
secret_gate.rs

1//! Write-time secret detection gate.
2//!
3//! Scans caller-supplied content strings before any storage write. A match
4//! causes a hard `RuntimeError::SecretDetected` that names the detector and
5//! carries a masked excerpt internally. Its display names the rule and trigger
6//! without echoing any candidate text.
7//!
8//! Scope: **credentials only** — API keys, tokens, private keys, passwords,
9//! and connection strings with embedded credentials. General PII (emails,
10//! phone numbers, company names) is intentionally NOT blocked.
11//!
12//! Detection is layered, cheap-first:
13//! 1. **Known-prefix / known-shape patterns** — AWS AKIA/ASIA, GitHub tokens,
14//!    OpenAI `sk-proj-`, Anthropic `sk-ant-`, Stripe live keys, Fly.io tokens,
15//!    Vercel secrets, Slack `xox*`, JWT triples, PEM private-key headers, Age
16//!    secret keys, URL userinfo (`scheme://user:pass@`).
17//! 2. **High-entropy token heuristic** — base64/hex/base64url runs ≥ 24 chars
18//!    near a trigger word (key, secret, password, credential, bearer, auth,
19//!    apikey, api_key, access_key, private_key). A standalone `token` still
20//!    triggers opaque entropy detection, but does not by itself label a UUID
21//!    as a credential; compound identifiers such as `tokenizer_*` and
22//!    `token_count` remain excluded.
23//!
24//! Credential-shaped labels and assignments dominate the allowlist below.
25//! Public VCS revisions and plausible file paths remain exempt in ordinary
26//! technical prose, while path segments are still scanned independently.
27//!
28//! Full exemption rules (hex/UUID/SRI-hash passes, non-ASCII token
29//! delimiting, structured-identifier decomposition, trigger word-boundary
30//! matching, the underscore-boundary asymmetry between bare trigger words and
31//! the word `token`, and the adversarial-corpus rationale for why some
32//! false positives are accepted) are documented in full in
33//! `docs/api/secret_gate.md#module-level-detection-algorithm` — read that before
34//! changing any detection or exemption logic in this file.
35//!
36//! The caller-visible block message (`SecretMatch`'s `Display` impl) also
37//! carries actionable guidance (`block_guidance`) to split or reword the
38//! flagged token.
39//!
40//! A production-corpus replay harness (`corpus_replay`, `#[ignore]`d, run via
41//! `KHIVE_REPLAY_DB=<path> cargo test ... -- --ignored --nocapture`) measures
42//! the detector's block rate against real note/entity content; see the
43//! harness's own output for current numbers rather than a point-in-time count
44//! here, which would drift as the corpus changes. A checked-in, sanitized
45//! snapshot of that replay (per-detector block counts and sha256 digests of
46//! blocked content, never the content itself) lives at
47//! `tests/data/secret_gate_corpus_manifest.md`, generated by
48//! `corpus_replay::generate_corpus_manifest`.
49//! A replay that is identical before and after a change certifies preservation
50//! on the corpus population only, never on the shape class the change opens;
51//! that class needs its own before/after arms.
52
53use crate::error::{RuntimeError, RuntimeResult};
54
55mod submitted_atom_digest;
56
57pub use submitted_atom_digest::masked_submitted_atom_digest_v1;
58
59// ─── Public API ──────────────────────────────────────────────────────────────
60
61/// Returned when a write would store credential-looking content.
62///
63/// Carries the detector name and a masked excerpt (`first6...Nchars`).  The
64/// full candidate is never stored in the error.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct SecretMatch {
67    /// Human-readable name of the detector that fired.
68    pub detector: &'static str,
69    /// Canonical trigger from the matched context; known-prefix rules need none.
70    pub trigger: Option<&'static str>,
71    /// `first6...N` — the first 6 chars of the match followed by the total length.
72    pub masked: String,
73    /// Which record and field the match came from. `None` only where the
74    /// caller scanned exactly one string and nothing else, so there is one
75    /// candidate. A single-record verb that scans several fields still needs
76    /// this: the writer sees one refusal and cannot tell whether it was the
77    /// name, the content, a tag or a property that matched.
78    pub location: Option<String>,
79}
80
81impl std::fmt::Display for SecretMatch {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        write!(f, "content matches secret pattern {}", self.detector)?;
84        if let Some(trigger) = self.trigger {
85            write!(f, " near '{trigger}'")?;
86        }
87        if let Some(location) = &self.location {
88            write!(f, " in {location}")?;
89        }
90        write!(f, ". {}", block_guidance(self.detector))
91    }
92}
93
94/// Actionable, caller-visible guidance for a hard block, keyed by detector
95/// name. If the content genuinely is a credential, remove it. If it
96/// is not — the common case for the detectors below, which key off SHAPE
97/// near a trigger word rather than a known credential prefix — the fix is to
98/// break up the flagged token so it no longer reads as one contiguous
99/// high-entropy value: separate it from words like key/secret/auth/token with
100/// a sentence or paragraph boundary, or use an explicit repository revision
101/// reference for a source hash.
102fn block_guidance(detector: &'static str) -> &'static str {
103    match detector {
104        "url-userinfo" => {
105            "Placeholders in URL credential positions still match this pattern. \
106             Replace the whole URL with an environment-variable name or config key, \
107             or remove the entire user/password segment before writing."
108        }
109        "high-entropy-token"
110        | "uuid-near-trigger"
111        | "content-hash-near-trigger"
112        | "hex-credential-token" => {
113            "If this is a real credential, remove it before writing. If it is not \
114             (e.g. a file path, UUID, or hash that happens to sit near a word like \
115             key/secret/auth/token), put the candidate in a separate sentence or \
116             paragraph from those words, or express a source hash as an explicit \
117             commit/revision reference."
118        }
119        _ => {
120            "If this is a real credential, remove it before writing; store secrets \
121              in an env var or secrets manager instead."
122        }
123    }
124}
125
126/// Hard-block content from being written.
127///
128/// Returns `Err(RuntimeError::SecretDetected)` on the first match found, or
129/// `Ok(())` if no secret pattern fires.
130pub fn check(content: &str) -> RuntimeResult<()> {
131    if let Some(m) = scan(content) {
132        return Err(RuntimeError::SecretDetected(m));
133    }
134    Ok(())
135}
136
137/// Recursively scan a JSON value for credential-shaped strings.
138///
139/// Walks every string leaf (object values, array elements, nested objects).
140/// Returns `Err(RuntimeError::SecretDetected)` on the first match found.
141/// `None` / null / numeric / boolean JSON values are skipped.
142pub fn check_json(value: &serde_json::Value) -> RuntimeResult<()> {
143    scan_json_value(value)
144}
145
146/// Scan a string-tagged slice (entity/note tags).
147///
148/// Each tag string is scanned individually.
149pub fn check_tags(tags: &[String]) -> RuntimeResult<()> {
150    for tag in tags {
151        check(tag)?;
152    }
153    Ok(())
154}
155
156/// Name the scope and field a refusal came from.
157///
158/// A batch verb scans each record and returns on the first refusal, so the
159/// caller gets ONE error for N records. Without this the error names only the
160/// matched text, which by construction is text the caller cannot find: it sits
161/// in whichever sibling record refused, and every other record in the call is
162/// rejected with it (khive #2605). Pass through anything that is not a gate
163/// refusal unchanged — this adds identity, it does not reclassify.
164///
165/// `record` names the record the field belongs to, as the caller submitted it:
166/// `entity`, `note`, `task`, `proposal`, `message`, indexed when it came from a
167/// batch (`note[2]`). It answers where in the submitted payload the writer
168/// should look, which is the question a refused writer actually asks.
169pub fn locate<T>(result: RuntimeResult<T>, record: &str, field: &str) -> RuntimeResult<T> {
170    result.map_err(|error| match error {
171        RuntimeError::SecretDetected(matched) => RuntimeError::SecretDetected(SecretMatch {
172            location: Some(format!("{record}.{field}")),
173            ..matched
174        }),
175        other => other,
176    })
177}
178
179/// `check` that names where it looked. `record` is the record noun the caller
180/// submitted, indexed inside a batch; `field` is the field of it that was scanned.
181pub fn check_at(content: &str, record: &str, field: &str) -> RuntimeResult<()> {
182    locate(check(content), record, field)
183}
184
185/// `check_json` that names where it looked. The location is the field holding
186/// the JSON, not the path of the string leaf that matched inside it.
187pub fn check_json_at(value: &serde_json::Value, record: &str, field: &str) -> RuntimeResult<()> {
188    locate(check_json(value), record, field)
189}
190
191/// `check_tags` that names where it looked.
192pub fn check_tags_at(tags: &[String], record: &str, field: &str) -> RuntimeResult<()> {
193    locate(check_tags(tags), record, field)
194}
195
196// ─── Reserved property key (ADR-115 Amendment 1) ────────────────────────────
197
198/// Top-level JSON property key reserved for runtime-owned exemption state.
199///
200/// No caller may create, replace, merge, or remove this key through any
201/// properties-bearing write path — ADR-115 Amendment 1 §3. Reservation binds
202/// unconditionally: the runtime does not yet stamp any record with this key
203/// (the finalizer that would do so is a separate, later increment), so no
204/// caller-supplied occurrence of it can ever be a legitimate echo of
205/// persisted state. Only the exact top-level key is reserved; the same
206/// spelling nested inside an object *value* is ordinary content and remains
207/// subject to [`check_json`], never a posture mutation.
208pub const RESERVED_SECRET_GATE_KEY: &str = "khive:secret_gate";
209
210/// Reject a caller-supplied top-level `khive:secret_gate` property key.
211///
212/// Call this before any diff, merge, or storage preparation touches
213/// caller-supplied `properties` on any properties-bearing write path —
214/// create, patch update, or full replace. Returns `Ok(())` when `properties`
215/// is absent, is not a JSON object, or does not name the reserved key at the
216/// top level.
217///
218/// This is the one shared validator for the reservation rule (ADR-115
219/// Amendment 1 §3); every properties-bearing write path across every crate
220/// must call this instead of re-implementing the check.
221pub fn reject_reserved_secret_gate_property(
222    properties: Option<&serde_json::Value>,
223) -> RuntimeResult<()> {
224    if let Some(serde_json::Value::Object(map)) = properties {
225        if map.contains_key(RESERVED_SECRET_GATE_KEY) {
226            return Err(RuntimeError::InvalidInput(format!(
227                "property key `{RESERVED_SECRET_GATE_KEY}` is runtime-owned and cannot be \
228                 created, replaced, merged, or removed by callers"
229            )));
230        }
231    }
232    Ok(())
233}
234
235fn scan_json_value(value: &serde_json::Value) -> RuntimeResult<()> {
236    match value {
237        serde_json::Value::String(s) => check(s),
238        serde_json::Value::Array(arr) => {
239            for v in arr {
240                scan_json_value(v)?;
241            }
242            Ok(())
243        }
244        serde_json::Value::Object(map) => {
245            for (k, v) in map {
246                // Scan both the key (a credential can appear as a JSON key name)
247                // and the value recursively.
248                check(k)?;
249                scan_json_value(v)?;
250            }
251            Ok(())
252        }
253        _ => Ok(()),
254    }
255}
256
257// ─── Scanner ─────────────────────────────────────────────────────────────────
258
259/// Marker substituted for a detected secret span by [`mask_secrets`].
260const REDACTION_MARKER: &str = "***MASKED***";
261
262/// Maximum cumulative bytes revisited by the per-pass detector sweeps while masking
263/// one input. Entropy tokens are materialized once, but resuming inside a token
264/// rebuilds its member candidates and revisits their context, so that token's
265/// full prefix is also charged. This permits two full-size passes over the 1 MiB
266/// ASCII log-input case; the first pass is always allowed for larger or multibyte
267/// callers. Once exhausted, the remainder is redacted wholesale.
268const MAX_MASK_SCAN_WORK_BYTES: usize = MAX_LOG_TEXT_MASK_INPUT_CHARS * 2;
269
270#[cfg(test)]
271thread_local! {
272    static ENTROPY_TOKENIZATION_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
273}
274
275/// Return the LEFTMOST secret in `text` as `(matched_slice, detector)`.
276///
277/// The matched slice borrows from `text`, so the caller can recover its byte
278/// span via pointer arithmetic — this is what lets [`mask_secrets`] redact in
279/// place while [`scan`] only needs the masked excerpt.
280///
281/// "Leftmost" (smallest start offset), NOT first-by-detector-priority, is the
282/// load-bearing contract: [`mask_secrets`] copies the text *before* each match
283/// verbatim, so a non-leftmost match would leak an earlier secret detected by a
284/// lower-priority detector (e.g. an `sk-ant-` key sitting to the left of a
285/// `ghp_` token). Both detector layers are folded through [`keep_leftmost`].
286#[cfg(test)]
287fn scan_match(text: &str) -> Option<(&str, &'static str)> {
288    let context = EntropyScanContext::new(text);
289    scan_from(text, 0, &context)
290}
291
292/// Like [`scan_match`], but only returns secrets whose span starts at or after
293/// `from`, while still evaluating Layer-2 trigger context against the FULL
294/// `text`. [`mask_secrets`] calls this with an advancing `from` so that an
295/// entropy token is detected even when its only trigger word sits to the left of
296/// an already-redacted earlier secret. Layer-1 known patterns are context-free,
297/// so scanning the `&text[from..]` suffix is equivalent; offsets recovered via
298/// pointer arithmetic against the original `text` base stay absolute. The
299/// pre-tokenized entropy view is shared by all passes.
300fn scan_from<'a>(
301    text: &'a str,
302    from: usize,
303    context: &EntropyScanContext<'a>,
304) -> Option<(&'a str, &'static str)> {
305    scan_from_with_trigger(text, from, context).map(|(slice, detector, _)| (slice, detector))
306}
307
308fn scan_from_with_trigger<'a>(
309    text: &'a str,
310    from: usize,
311    context: &EntropyScanContext<'a>,
312) -> Option<(&'a str, &'static str, Option<&'static str>)> {
313    let mut best =
314        check_known_patterns(&text[from..]).map(|(slice, detector)| (slice, detector, None));
315    if let Some(candidate) = check_entropy_heuristic(text, from, context) {
316        if best
317            .as_ref()
318            .is_none_or(|current| candidate.0.as_ptr() < current.0.as_ptr())
319        {
320            best = Some(candidate);
321        }
322    }
323    best
324}
325
326/// Replace `best` with `cand` when `cand` starts earlier in the original text
327/// (`base` is the start address of that text). On a tie the incumbent wins, so
328/// callers offer more-specific detectors first. This is what makes
329/// [`check_known_patterns`] and [`scan_match`] return the leftmost secret span
330/// rather than the first detector that happens to match anywhere.
331fn keep_leftmost<'a>(
332    best: &mut Option<(&'a str, &'static str)>,
333    cand: Option<(&'a str, &'static str)>,
334    base: usize,
335) {
336    if let Some((slice, name)) = cand {
337        let start = slice.as_ptr() as usize - base;
338        let replace = match *best {
339            Some((incumbent, _)) => start < (incumbent.as_ptr() as usize - base),
340            None => true,
341        };
342        if replace {
343            *best = Some((slice, name));
344        }
345    }
346}
347
348/// Return the first `SecretMatch` found in `text`, or `None`.
349fn scan(text: &str) -> Option<SecretMatch> {
350    let context = EntropyScanContext::new(text);
351    scan_from_with_trigger(text, 0, &context).map(|(slice, detector, trigger)| {
352        let mut matched = build_match(detector, slice);
353        matched.trigger = trigger;
354        matched
355    })
356}
357
358/// A named redact-not-block surface whose contract is intentionally separate
359/// from manifest-backed write admission (ADR-115 Amendment 2).
360#[derive(Debug, Clone, Copy, PartialEq, Eq)]
361pub enum RedactionSurface {
362    /// Git ingestion stores only masked commit, issue, and pull-request text.
363    GitIngest,
364    /// Session mirroring stores only masked `text` and `raw` projections.
365    SessionMirror,
366    /// MCP diagnostics are bounded caller-visible transport data, not records.
367    McpDiagnostic,
368    /// The kg `scan` verb's masked preview: caller-visible, never stored.
369    GateProbe,
370}
371
372/// Whether a redaction surface can consume a secret-gate exemption.
373#[derive(Debug, Clone, Copy, PartialEq, Eq)]
374pub enum RedactionSurfaceMode {
375    /// Always apply the canonical masker; never synthesize a stamp or event.
376    PermanentMaskOnly,
377}
378
379/// Machine-readable contract for a named redaction surface.
380#[derive(Debug, Clone, Copy, PartialEq, Eq)]
381pub struct RedactionSurfaceContract {
382    pub mode: RedactionSurfaceMode,
383    /// Durable target containing the masked result, if this is a write surface.
384    pub final_stored_target: Option<&'static str>,
385    /// Reserved stamp location; absent for permanent mask-only surfaces.
386    pub stamp_property: Option<&'static str>,
387    /// Atomic exemption-success event; absent when admission cannot occur.
388    pub atomic_success_event: Option<&'static str>,
389}
390
391/// Final stored target for [`RedactionSurface::GitIngest`] — see
392/// [`redaction_surface_contract`].
393pub const GIT_INGEST_STORED_TARGET: &str = "final git-ingest entity/note fields";
394
395/// Final stored target for [`RedactionSurface::SessionMirror`] — see
396/// [`redaction_surface_contract`]. Names every column the session mirror
397/// writes a masked provider-export projection into, not just the message
398/// body columns. `cwd`/`git_branch` live only on `sessions`, keyed per
399/// session — `session_messages` carries no such columns of its own (see the
400/// `session_messages` DDL in `khive-pack-session`).
401pub const SESSION_MIRROR_STORED_TARGET: &str =
402    "session_messages.text, session_messages.raw, sessions.cwd, sessions.git_branch, \
403     and sessions.slug";
404
405/// Return the closed contract for a named redact-not-block surface.
406pub const fn redaction_surface_contract(surface: RedactionSurface) -> RedactionSurfaceContract {
407    let final_stored_target = match surface {
408        RedactionSurface::GitIngest => Some(GIT_INGEST_STORED_TARGET),
409        RedactionSurface::SessionMirror => Some(SESSION_MIRROR_STORED_TARGET),
410        RedactionSurface::McpDiagnostic | RedactionSurface::GateProbe => None,
411    };
412
413    RedactionSurfaceContract {
414        mode: RedactionSurfaceMode::PermanentMaskOnly,
415        final_stored_target,
416        stamp_property: None,
417        atomic_success_event: None,
418    }
419}
420
421/// Apply the canonical detector to a named permanent mask-only surface.
422///
423/// This wrapper makes the non-admission decision executable at each call site:
424/// it has no manifest input, cannot return an exemption outcome, and cannot
425/// synthesize the runtime-owned `khive:secret_gate` property or success event.
426pub fn mask_for_redaction_surface(
427    surface: RedactionSurface,
428    text: &str,
429) -> std::borrow::Cow<'_, str> {
430    match redaction_surface_contract(surface).mode {
431        RedactionSurfaceMode::PermanentMaskOnly => mask_secrets(text),
432    }
433}
434
435/// Upper bound on how many characters of a diagnostic-boundary input the
436/// canonical masker is ever asked to scan, regardless of a caller's own
437/// output cap. Chosen comfortably larger than the largest output cap among
438/// the callers of [`mask_bounded`] (1,024, `khive-mcp`'s
439/// `MAX_BACKEND_ERROR_MESSAGE_CHARS`) so a credential's terminating span
440/// remains inside the window for any message that is itself smaller than the
441/// window. Masking cost scales with this constant, never with a caller's raw
442/// input length. Shared by every diagnostic-boundary redaction site so the
443/// window cannot drift between them independently.
444pub const MASK_WINDOW_CHARS: usize = 4_096;
445
446/// Result of [`mask_bounded`]: masked, window- and output-bounded text plus
447/// the flags a caller needs to finish assembling a caller-visible
448/// diagnostic.
449#[derive(Debug, Clone, PartialEq, Eq)]
450pub struct BoundedMask {
451    /// Masked text, never longer than `output_cap_chars` plus one trailing
452    /// truncation-marker character. May be the bare truncation marker alone
453    /// when the retained window held a single token longer than the window.
454    pub text: String,
455    /// True when `text` is not the complete masked input: the raw input
456    /// exceeded `window_chars`, the masked window exceeded `output_cap_chars`,
457    /// or the window held one token longer than the window that had to be
458    /// dropped whole.
459    pub truncated: bool,
460    /// True when the canonical masker replaced a span inside the retained
461    /// window, or the window's only content was dropped whole for being an
462    /// oversized single token. A caller that decides whether raw content is
463    /// safe to echo back verbatim on this flag must also treat `truncated`
464    /// as reason enough on its own — content the window never retained is
465    /// exactly as unverified as content the masker redacted.
466    pub redacted: bool,
467}
468
469/// Bound a diagnostic-boundary masking call to at most `window_chars` of
470/// `text` before the canonical masker ever runs, then cap the masked result
471/// to `output_cap_chars`. Bounding happens BEFORE masking — not after, as a
472/// truncate-then-mask policy would — so scan cost is bounded by
473/// `window_chars` regardless of the caller's input length.
474///
475/// A window cut mid-token would let a masker that never saw the token's
476/// terminating shape (e.g. the `@` closing `scheme://user:pass@host`) emit
477/// the token's visible prefix unmasked — the exact split-secret hole a prior
478/// mask-the-full-input-first policy existed to close, at the cost of
479/// unbounded scan work. This function closes the hole without re-widening
480/// the window to the input's full length: any token straddling the window
481/// boundary is dropped in its entirety (back to the last whitespace inside
482/// the window) rather than masked, because a masker can only vouch for a
483/// token it saw whole. A single token that is itself longer than the window
484/// has no earlier whitespace to fall back to inside the window and is
485/// replaced by the truncation marker alone.
486///
487/// The same drop applies to a chain of bridged fragments straddling the
488/// boundary, not just the one partial token touching it: see
489/// `trailing_bridge_fragment_cut` (private to this module) for why a
490/// forward lookahead cannot bound this instead, and for the backward walk
491/// that closes it purely from data already inside the window.
492///
493/// `window_chars` must be at least `output_cap_chars`; debug builds assert
494/// this so a misconfigured call site fails loudly instead of silently
495/// capping tighter than it windows, and the cap is additionally clamped to
496/// `window_chars` in every build so a misconfigured call site cannot cap
497/// tighter than it windows even outside debug assertions.
498pub fn mask_bounded(
499    surface: RedactionSurface,
500    text: &str,
501    window_chars: usize,
502    output_cap_chars: usize,
503) -> BoundedMask {
504    debug_assert!(
505        window_chars >= output_cap_chars,
506        "the input window must stay at least as large as the output cap"
507    );
508    let output_cap_chars = output_cap_chars.min(window_chars);
509
510    let input_truncated = text.chars().nth(window_chars).is_some();
511    let mut window: String = text.chars().take(window_chars).collect();
512
513    if input_truncated {
514        match window
515            .char_indices()
516            .rev()
517            .find(|(_, ch)| ch.is_whitespace())
518        {
519            // Keep the whitespace itself; drop only the partial token after it.
520            Some((idx, ch)) => window.truncate(idx + ch.len_utf8()),
521            // A single token spans the whole window: nothing can be shown
522            // whole, so show nothing at all.
523            None => window.clear(),
524        }
525    }
526
527    if input_truncated && !window.is_empty() {
528        if let Some(cut) = trailing_bridge_fragment_cut(&window) {
529            window.truncate(cut);
530        }
531    }
532
533    if input_truncated && window.is_empty() {
534        return BoundedMask {
535            text: TRUNCATION_MARKER.to_string(),
536            truncated: true,
537            redacted: true,
538        };
539    }
540
541    debug_assert!(window.chars().count() <= window_chars);
542    let masked = mask_for_redaction_surface(surface, &window);
543    let redacted = masked.as_ref() != window.as_str();
544
545    let mut chars = masked.chars();
546    let mut bounded: String = chars.by_ref().take(output_cap_chars).collect();
547    let output_capped = chars.next().is_some();
548    if output_capped || input_truncated {
549        bounded.push_str(TRUNCATION_MARKER);
550    }
551
552    BoundedMask {
553        text: bounded,
554        truncated: input_truncated || output_capped,
555        redacted,
556    }
557}
558
559const TRUNCATION_MARKER: &str = "…";
560
561/// Redact every detected secret span in `text`, replacing each with
562/// `***MASKED***`.
563///
564/// This is the masking counterpart to [`check`]: where `check` hard-blocks a
565/// write on the first match, `mask_secrets` is for content that must be emitted
566/// or stored with credentials stripped. Named Git/session/MCP callers enter it
567/// through [`mask_for_redaction_surface`]. It reuses the SAME canonical detector
568/// set as `check`/`scan`, so callers must never maintain a second, weaker masker.
569///
570/// Returns `Cow::Borrowed` when no secret is present (the common case), avoiding
571/// an allocation. Spans are discovered left to right against the ORIGINAL text,
572/// always evaluating trigger context over the full input — a high-entropy value
573/// whose only trigger word sits to the left of an earlier-redacted secret is
574/// still detected. Cumulative suffix-scan work is capped; when dense input
575/// exhausts the cap after a match, that match is extended through the remaining
576/// text so unscanned credentials cannot survive. See
577/// `docs/api/secret_gate.md#mask_secrets` for the scan-cursor mechanics.
578pub fn mask_secrets(text: &str) -> std::borrow::Cow<'_, str> {
579    let (spans, _scan_work_bytes) = collect_mask_spans(text);
580    if spans.is_empty() {
581        return std::borrow::Cow::Borrowed(text);
582    }
583    let mut out = String::with_capacity(text.len());
584    let mut cursor = 0;
585    for (start, end) in spans {
586        // Spans are non-overlapping and ascending (each starts at/after the prior
587        // `end`); `max(cursor)` is a defensive guard, never load-bearing.
588        let start = start.max(cursor);
589        out.push_str(&text[cursor..start]);
590        out.push_str(REDACTION_MARKER);
591        cursor = end.max(cursor);
592    }
593    out.push_str(&text[cursor..]);
594    std::borrow::Cow::Owned(out)
595}
596
597/// Collect absolute byte spans to redact and report cumulative scan bytes revisited.
598/// Exhausting the work budget extends the last confirmed secret span through the input tail.
599fn collect_mask_spans(text: &str) -> (Vec<(usize, usize)>, usize) {
600    let base = text.as_ptr() as usize;
601    let context = EntropyScanContext::new(text);
602    let tokens = &context.tokens;
603    // Collect every secret span (absolute byte offsets into `text`) before
604    // writing any output, so trigger-context detection always sees the original
605    // string rather than the suffix after the previous redaction.
606    let mut spans: Vec<(usize, usize)> = Vec::new();
607    let mut from = 0;
608    let mut scan_work_bytes = 0usize;
609    while from < text.len() {
610        // Candidate enumeration may revisit the prefix of the original token
611        // containing `from`. Charge it as well as the remaining suffix; in a
612        // whitespace gap the scan still begins at `from`.
613        let token_index = tokens.partition_point(|&(offset, raw)| offset + raw.len() <= from);
614        let scan_start = tokens
615            .get(token_index)
616            .map_or(from, |&(offset, _)| offset.min(from));
617        let scan_len = text.len() - scan_start;
618        let next_scan_work = scan_work_bytes.saturating_add(scan_len);
619        if scan_work_bytes > 0 && next_scan_work > MAX_MASK_SCAN_WORK_BYTES {
620            // Every previous sweep ended at a confirmed match; extending that
621            // redaction through the remaining tail is fail-closed.
622            spans.last_mut().expect("a prior scan found a span").1 = text.len();
623            break;
624        }
625        scan_work_bytes = next_scan_work;
626        match scan_from(text, from, &context) {
627            Some((sub, _detector)) => {
628                let start = sub.as_ptr() as usize - base;
629                // The prefix detectors return whitespace-delimited tokens, so a
630                // credential glued to structural punctuation (JSON quotes/braces,
631                // sentence commas) carries that trailing punctuation into the
632                // match. Trim a conservative trailing set that can never be part
633                // of a credential, so redacting does not consume surrounding JSON
634                // or prose structure. `=` `/` `+` `.` `-` `_` are intentionally
635                // NOT trimmed — they are valid base64/JWT/key characters.
636                let core_len = sub
637                    .trim_end_matches(['"', '\'', '`', '}', ']', ')', ',', ';'])
638                    .len();
639                let end = extend_across_invisible_bridge(text, start + core_len.max(1));
640                push_mask_spans(text, start, end, &mut spans);
641                // `scan_from` only returns matches with start >= from, and `end`
642                // is strictly greater than `start`, so `from` strictly advances.
643                from = end;
644            }
645            None => break,
646        }
647    }
648    (spans, scan_work_bytes)
649}
650
651/// A character that splits a payload without showing anything: non-ASCII and not
652/// a letter or digit, so U+200B and its neighbours qualify while the letters of a
653/// non-ASCII password do not. The second half of that predicate is load-bearing:
654/// `redis://:密码@host` is ONE credential whose characters are non-ASCII, and a
655/// rule keyed on non-ASCII alone splits it and prints the password between two
656/// redaction markers.
657fn is_invisible_bridge_separator(c: char) -> bool {
658    !c.is_ascii() && !c.is_alphanumeric()
659}
660
661/// Byte offset a redaction must reach when the payload continues past `end`
662/// behind an INVISIBLE separator.
663///
664/// A gap made only of [`is_invisible_bridge_separator`] characters is not something
665/// a person types between a credential and the next word; it is how one payload is
666/// split so each half falls under a detector's length floor. Detection already
667/// reconstructs those chains ([`bridge_fragment_chain`]), but the masker redacted
668/// only the token the scan returned, so the rest of the same payload survived into
669/// stored text. Gaps holding any ASCII character — the ordinary spaces and newlines
670/// between a commit sha and the prose after it — are never walked, so this cannot
671/// eat surrounding text.
672fn extend_across_invisible_bridge(text: &str, end: usize) -> usize {
673    let mut end = end;
674    for _ in 1..MAX_BRIDGE_FRAGMENTS {
675        let rest = &text[end..];
676        let Some(gap_len) = rest.find(|c: char| c.is_ascii_alphanumeric()) else {
677            break;
678        };
679        let gap = &rest[..gap_len];
680        if gap.is_empty() || !gap.chars().all(is_invisible_bridge_separator) {
681            break;
682        }
683        let fragment = &rest[gap_len..];
684        let fragment_len = fragment
685            .find(|c: char| !c.is_ascii_alphanumeric())
686            .unwrap_or(fragment.len());
687        if fragment_len < MIN_BRIDGE_FRAGMENT_LEN {
688            break;
689        }
690        end += gap_len + fragment_len;
691    }
692    end
693}
694
695/// Push the redaction spans for `text[start..end]`, breaking at
696/// [`is_invisible_bridge_separator`] characters so a separator that joined two
697/// fragments of one payload stays visible instead of being swallowed into a single
698/// marker.
699///
700/// A span holding no such character — every ordinary credential, base64 and JWT
701/// forms included, whose `.` `+` `/` `=` are ASCII, and non-ASCII passwords, whose
702/// letters are alphanumeric — is pushed whole, so this changes nothing for them. A
703/// span that yields no run at all is pushed whole as well: redacting more than
704/// necessary is the safe direction.
705fn push_mask_spans(text: &str, start: usize, end: usize, spans: &mut Vec<(usize, usize)>) {
706    let span = &text[start..end];
707    if !span.chars().any(is_invisible_bridge_separator) {
708        spans.push((start, end));
709        return;
710    }
711    let before = spans.len();
712    let mut run_start: Option<usize> = None;
713    for (offset, ch) in span.char_indices() {
714        if is_invisible_bridge_separator(ch) {
715            if let Some(run) = run_start.take() {
716                spans.push((start + run, start + offset));
717            }
718        } else {
719            run_start.get_or_insert(offset);
720        }
721    }
722    if let Some(run) = run_start {
723        spans.push((start + run, end));
724    }
725    if spans.len() == before {
726        spans.push((start, end));
727    }
728}
729
730/// Maximum characters of raw error text admitted to the masking pass.
731///
732/// This is NOT a tight bound like [`MAX_LOG_TEXT_OUTPUT_CHARS`] below — it exists only to
733/// stop a truly pathological input (gigabytes of attacker-controlled text funneled into one
734/// error/log line) from making the masking scan unbounded. It is a pure compute bound, not a
735/// safety bound: [`find_url_userinfo`] has no length limit on the password it recognizes, so
736/// no finite value of this constant can guarantee a credential never straddles it — a
737/// password longer than whatever this is set to always has a crossing case. The actual
738/// safety invariant lives in [`redact_crossing_boundary_url_userinfo`], the fallback
739/// [`bounded_masked_log_text`] runs after [`mask_secrets`]: it redacts any `scheme://user:`
740/// opening whose password run reaches this cut point without a terminating `@`, regardless
741/// of how long that password is. 1 MiB just keeps the scan itself cheap.
742const MAX_LOG_TEXT_MASK_INPUT_CHARS: usize = 1_048_576;
743/// Maximum characters of masked error text emitted to a log record.
744const MAX_LOG_TEXT_OUTPUT_CHARS: usize = 1_024;
745
746/// Bound and mask arbitrary error text for log emission.
747///
748/// Log records are a disclosure surface the same way wire errors are: they are
749/// shipped, aggregated, and read by consumers outside the process. Backend
750/// error text (gate backends included) can embed connection strings or
751/// credentials, so the FULL text (up to `MAX_LOG_TEXT_MASK_INPUT_CHARS`, a
752/// pure compute bound — see its doc comment) is masked with the canonical
753/// detector set before any truncation happens. Masking after truncation would
754/// let a secret whose tail sits past the bound lose the context (e.g. a URL's
755/// terminating `@`) a detector needs to recognize it, leaving its head
756/// unmasked in the log — truncate-then-mask must never replace
757/// mask-then-truncate here. Because `MAX_LOG_TEXT_MASK_INPUT_CHARS` is
758/// finite and `find_url_userinfo` has no bound on password length, a
759/// password long enough still crosses the cut before its terminating `@`
760/// ever appears; `redact_crossing_boundary_url_userinfo` closes that gap
761/// by redacting the unterminated opening directly, so no credential prefix
762/// survives regardless of secret length. Control (`Cc`), format (`Cf`), line
763/// separator (`Zl`), and paragraph separator (`Zp`) Unicode codepoints in the
764/// masked text are then escaped: a log line is plain text read by tooling
765/// outside this process, and an embedded line break or bidi/format override
766/// could forge or visually disguise part of the record. The result is bounded
767/// again for the emitted record. A truncation in either the masking pass or the
768/// output pass appends `…` so the record declares its own incompleteness.
769///
770/// This function, not [`mask_for_redaction_surface`], is the direct
771/// `mask_secrets` caller for general log-text bounding: it is not one of the
772/// three named redact-not-block surfaces (git ingest, session mirror, MCP
773/// diagnostics) that surface owns, and its own mask-then-truncate contract —
774/// with the additional crossing-boundary fallback above — is a strict
775/// superset of what the surface wrapper provides. It lives in this module
776/// specifically so it can stay a direct caller; the call-site census in
777/// `crates/khive-runtime/tests/adr115_redaction_call_site_census.rs` only
778/// requires callers *outside* this file to route through the wrapper.
779pub fn bounded_masked_log_text(text: &str) -> String {
780    let mask_input_truncated = text.chars().nth(MAX_LOG_TEXT_MASK_INPUT_CHARS).is_some();
781    let bounded_input: std::borrow::Cow<'_, str> = if mask_input_truncated {
782        std::borrow::Cow::Owned(text.chars().take(MAX_LOG_TEXT_MASK_INPUT_CHARS).collect())
783    } else {
784        std::borrow::Cow::Borrowed(text)
785    };
786    let masked = mask_secrets(&bounded_input);
787    let masked = if mask_input_truncated {
788        redact_crossing_boundary_url_userinfo(&masked)
789    } else {
790        masked
791    };
792    let neutralized = neutralize_log_unsafe_chars(&masked);
793
794    let mut chars = neutralized.chars();
795    let mut bounded: String = chars.by_ref().take(MAX_LOG_TEXT_OUTPUT_CHARS).collect();
796    if chars.next().is_some() || mask_input_truncated {
797        bounded.push('…');
798    }
799    bounded
800}
801
802/// Fallback for a `scheme://user:<password>` credential whose password run
803/// collided with [`bounded_masked_log_text`]'s truncation of the mask-scan
804/// input at [`MAX_LOG_TEXT_MASK_INPUT_CHARS`]. [`find_url_userinfo`] only
805/// recognizes a credential once it sees the terminating `@`; when that `@`
806/// sits past the truncation point, [`mask_secrets`] never sees the shape at
807/// all and the raw `scheme://user:<password prefix>` reaches the log. No
808/// finite value of [`MAX_LOG_TEXT_MASK_INPUT_CHARS`] can rule this out — the
809/// detector is unbounded, so any cap has a crossing case — so the invariant
810/// has to come from this fallback, not from the cap's size.
811///
812/// Only called when `bounded_masked_log_text` actually truncated the input.
813/// It scans `://` occurrences left to right and redacts at the EARLIEST
814/// unterminated `user:<password-run>` opening: a colon splits the tail into
815/// two non-empty pieces and no `@`, space, or newline appears anywhere from
816/// that occurrence to the end of the truncated text. An occurrence whose
817/// tail does contain one of those terminators is a complete URL or ordinary
818/// prose that ends inside the text (e.g. two URLs logged side by side) and
819/// is skipped, not redacted. The anchor must be the earliest such opening,
820/// never the last: a later `://` can sit INSIDE the crossing password
821/// itself (passwords may contain `://`), and anchoring there would leave
822/// the real `user:<password prefix>` before it in the emitted log. From the
823/// earliest unterminated opening's colon onward everything is redacted —
824/// zero password characters survive, no matter how long the password
825/// actually is.
826fn redact_crossing_boundary_url_userinfo(text: &str) -> std::borrow::Cow<'_, str> {
827    let mut search_from = 0usize;
828    while let Some(rel) = text[search_from..].find("://") {
829        let scheme_pos = search_from + rel;
830        let rest = &text[scheme_pos + 3..];
831        let terminated =
832            rest.contains('@') || rest.contains(' ') || rest.contains('\n') || rest.contains('\r');
833        if !terminated {
834            // Same rules as `find_url_userinfo`: the userinfo colon must sit
835            // in the authority component (before any `/`, `?`, or `#` — a
836            // later colon is path/query text), and only the password must be
837            // non-empty (an empty username, `redis://:pass`, is a standard
838            // connection-string form and no less a credential). The password
839            // run AFTER the colon is unrestricted — a crossing password may
840            // itself contain any of those delimiters.
841            let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
842            if let Some(colon) = rest[..authority_end].find(':') {
843                let pass = &rest[colon + 1..];
844                if !pass.is_empty() {
845                    let redact_from = scheme_pos + 3 + colon;
846                    let mut out = String::with_capacity(redact_from + REDACTION_MARKER.len());
847                    out.push_str(&text[..redact_from]);
848                    out.push_str(REDACTION_MARKER);
849                    return std::borrow::Cow::Owned(out);
850                }
851            }
852        }
853        search_from = scheme_pos + 3;
854    }
855    std::borrow::Cow::Borrowed(text)
856}
857
858/// `true` for a Unicode control (`Cc`), format (`Cf`), line separator (`Zl`), or
859/// paragraph separator (`Zp`) codepoint, tab excepted.
860///
861/// Classification is by Unicode general category rather than an ASCII byte range so that
862/// multi-byte control/format characters (bidi overrides, zero-width joiners, line/paragraph
863/// separators encoded as UTF-8) are caught the same way as single-byte C0 controls like
864/// CR/LF — a byte-range check would only ever see the latter. Tab is excepted: it is
865/// visually inert in a log line and common in legitimately reformatted prose.
866fn is_log_unsafe_char(c: char) -> bool {
867    if c == '\t' {
868        return false;
869    }
870    matches!(
871        unicode_general_category::get_general_category(c),
872        unicode_general_category::GeneralCategory::Control
873            | unicode_general_category::GeneralCategory::Format
874            | unicode_general_category::GeneralCategory::LineSeparator
875            | unicode_general_category::GeneralCategory::ParagraphSeparator
876    )
877}
878
879/// Escape every [`is_log_unsafe_char`] codepoint in `text` as `\u{XXXX}`.
880///
881/// Returns `Cow::Borrowed` when nothing needs escaping (the common case), avoiding an
882/// allocation. This runs on already-masked text: it must never be skipped for text that
883/// bypassed [`mask_secrets`], since a control character can sit inside a would-be secret
884/// span and is a distinct disclosure vector from the credential detectors (log injection /
885/// forgery, not credential leakage).
886fn neutralize_log_unsafe_chars(text: &str) -> std::borrow::Cow<'_, str> {
887    if !text.chars().any(is_log_unsafe_char) {
888        return std::borrow::Cow::Borrowed(text);
889    }
890    let mut out = String::with_capacity(text.len());
891    for c in text.chars() {
892        if is_log_unsafe_char(c) {
893            out.push_str(&format!("\\u{{{:04x}}}", c as u32));
894        } else {
895            out.push(c);
896        }
897    }
898    std::borrow::Cow::Owned(out)
899}
900
901// ─── Layer 1: known patterns ─────────────────────────────────────────────────
902
903/// Each entry: (detector_name, needle, min_total_token_len).
904///
905/// The needle must appear as a word-boundary-adjacent prefix in the token.
906/// `min_total_token_len` is the minimum length the token (needle + remainder)
907/// must have — prevents the prefix alone triggering without a payload.
908const PREFIX_DETECTORS: &[(&str, &str, usize)] = &[
909    // AWS
910    ("aws-access-key-id", "AKIA", 20),
911    ("aws-access-key-id", "ASIA", 20),
912    // GitHub tokens: personal-access (ghp_), OAuth (gho_), GitHub App
913    // user-to-server (ghu_), server-to-server (ghs_), refresh (ghr_), and the
914    // fine-grained PAT (github_pat_). The gh*_ formats carry a 36-character
915    // payload; fine-grained PATs carry an 82-character payload.
916    ("github-token", "ghp_", 36),
917    ("github-token", "gho_", 36),
918    ("github-token", "ghu_", 36),
919    ("github-token", "ghs_", 36),
920    ("github-token", "ghr_", 36),
921    ("github-token", "github_pat_", 93),
922    // OpenAI project keys carry at least an 80-character payload.
923    ("openai-api-key", "sk-proj-", 88),
924    // NOTE: bare "sk-" also matches the more-specific prefixes below. Those
925    // prefixes retain ownership of their candidates so the generic fallback
926    // cannot bypass a vendor-specific minimum.
927    // Anthropic
928    ("anthropic-api-key", "sk-ant-", 108),
929    // Stripe live keys
930    ("stripe-secret-key", "sk_live_", 30),
931    ("stripe-restricted-key", "rk_live_", 30),
932    // Fly.io (fm2_ prefix only — FlyV1 handled separately because it embeds a space)
933    ("fly-token", "fm2_", 20),
934    // Vercel
935    ("vercel-token", "vercel_", 20),
936    // Slack
937    ("slack-token", "xoxb-", 40),
938    ("slack-token", "xoxa-", 40),
939    ("slack-token", "xoxp-", 40),
940    ("slack-token", "xoxr-", 40),
941    ("slack-token", "xoxs-", 40),
942    // Age secret key
943    ("age-secret-key", "AGE-SECRET-KEY-", 60),
944];
945
946/// Known safe compound words that start with `sk-` but are not credentials.
947/// E.g. scikit-learn slugs such as `sk-learn`, `sk-image`, `sk-lego`.
948const SK_SAFE_PREFIXES: &[&str] = &["sk-learn", "sk-image", "sk-lego", "sk-base", "sk-misc"];
949
950/// Shape-based patterns checked with custom logic.
951///
952/// Returns the LEFTMOST match across every detector (see [`keep_leftmost`]). The
953/// detectors are still offered in priority order, so two detectors that match at
954/// the SAME offset (e.g. bare `sk-` and the more-specific `sk-ant-`) resolve to
955/// the first-offered one.
956fn check_known_patterns(text: &str) -> Option<(&str, &'static str)> {
957    let base = text.as_ptr() as usize;
958    let mut best: Option<(&str, &'static str)> = None;
959
960    // --- Prefix patterns ---
961    for &(name, needle, min_len) in PREFIX_DETECTORS {
962        keep_leftmost(
963            &mut best,
964            find_prefix_token(text, needle, min_len).map(|m| (m, name)),
965            base,
966        );
967    }
968
969    // --- Bare `sk-` (after all more-specific sk- detectors above) ---
970    // Require length ≥ 30 AND exclude known safe scikit/library compound words.
971    if let Some(token) = find_bare_sk_token(text) {
972        keep_leftmost(&mut best, Some((token, "openai-api-key")), base);
973    }
974
975    // --- Fly.io FlyV1 token: "FlyV1 <base64-payload>" ---
976    // The format embeds a space, so the generic prefix extractor (which stops at
977    // whitespace) cannot measure the combined length.  Check for `FlyV1 ` followed
978    // by ≥ 4 non-whitespace characters as the payload.
979    let mut from = 0;
980    while let Some(rel) = text[from..].find("FlyV1 ") {
981        let pos = from + rel;
982        let at_boundary = pos == 0 || {
983            text[..pos]
984                .chars()
985                .next_back()
986                .is_none_or(|c| !c.is_ascii_alphanumeric())
987        };
988        if at_boundary {
989            let payload_start = pos + 6; // skip "FlyV1 "
990            let payload = extract_token(&text[payload_start..]);
991            if payload.len() >= 4 {
992                let candidate = &text[pos..payload_start + payload.len()];
993                keep_leftmost(&mut best, Some((candidate, "fly-token")), base);
994                break;
995            }
996        }
997        from = pos + "FlyV1 ".len();
998    }
999
1000    // --- PEM private key block ---
1001    // "-----BEGIN <TYPE> PRIVATE KEY-----" followed by a body.
1002    keep_leftmost(
1003        &mut best,
1004        find_pem_private_key_block(text).map(|m| (m, "pem-private-key")),
1005        base,
1006    );
1007
1008    // --- JWT triple: eyJ...eyJ...eyJ (header.payload.signature) ---
1009    // A JWT starts with "eyJ" (base64url of `{"`) and has exactly two dots.
1010    keep_leftmost(&mut best, find_jwt(text).map(|m| (m, "jwt")), base);
1011
1012    // --- URL userinfo: scheme://user:pass@host ---
1013    keep_leftmost(
1014        &mut best,
1015        find_url_userinfo(text).map(|m| (m, "url-userinfo")),
1016        base,
1017    );
1018
1019    best
1020}
1021
1022/// Locate the first token in `text` that starts with `needle` and has a
1023/// total length >= `min_len`.  Returns a slice of the full token on match.
1024fn find_prefix_token<'a>(text: &'a str, needle: &str, min_len: usize) -> Option<&'a str> {
1025    let mut start = 0;
1026    while let Some(rel) = text[start..].find(needle) {
1027        let abs = start + rel;
1028        // Require that the needle starts at a token boundary (start-of-string
1029        // or preceded by a non-ASCII-alphanumeric char).  The needles are ASCII,
1030        // so only an ASCII alphanumeric can be a real continuation of the same
1031        // token; CJK/accented text (which Rust counts as `is_alphanumeric`) must
1032        // act as a delimiter, else a secret glued to non-Latin prose (`数据AKIA…`)
1033        // is missed.
1034        let at_boundary = abs == 0 || {
1035            let prev = text[..abs].chars().next_back().unwrap_or(' ');
1036            !prev.is_ascii_alphanumeric()
1037        };
1038        if at_boundary {
1039            let token = extract_token(&text[abs..]);
1040            if token.len() >= min_len && !is_filename_shaped_prefix_match(token, needle) {
1041                return Some(token);
1042            }
1043        }
1044        start = abs + needle.len().max(1);
1045    }
1046    None
1047}
1048
1049/// Locate a generic `sk-` token without reclassifying a registered vendor
1050/// prefix that did not meet its own minimum length.
1051fn find_bare_sk_token(text: &str) -> Option<&str> {
1052    let base = text.as_ptr() as usize;
1053    let mut from = 0;
1054    while from < text.len() {
1055        let token = find_prefix_token(&text[from..], "sk-", 30)?;
1056        let belongs_to_specific_detector = PREFIX_DETECTORS
1057            .iter()
1058            .any(|&(_, needle, _)| needle.starts_with("sk-") && token.starts_with(needle));
1059        let is_safe_compound = SK_SAFE_PREFIXES.iter().any(|safe| token.starts_with(safe));
1060        if !belongs_to_specific_detector && !is_safe_compound {
1061            return Some(token);
1062        }
1063
1064        let token_start = token.as_ptr() as usize - base;
1065        // Suppress only this `sk-` occurrence. The same whitespace-delimited
1066        // token may contain a later generic key glued after punctuation, and
1067        // advancing past the whole token would hide it from the fallback.
1068        from = token_start + "sk-".len();
1069    }
1070    None
1071}
1072
1073/// Known source-file extensions that can terminate an ordinary provider-
1074/// prefixed filename. This is deliberately a closed set: an unknown suffix
1075/// is not evidence strong enough to suppress a context-free prefix detector.
1076const SOURCE_FILE_EXTENSIONS: &[&str] =
1077    &[".py", ".rs", ".ts", ".js", ".sh", ".md", ".toml", ".json"];
1078
1079/// Returns `true` for a lowercase source filename after a known provider
1080/// prefix, such as `vercel_deployment_monitor.py`, optionally followed by a
1081/// source citation's line reference: `vercel_deployment_monitor.py:412` or
1082/// `vercel_deployment_monitor.py:412-418`.
1083///
1084/// A source extension alone is not enough. The payload before it must contain
1085/// only lowercase ASCII letters and filename/path punctuation; any uppercase
1086/// letter or digit is credential-value evidence and keeps the prefix match
1087/// fail-closed. Outer Markdown/prose punctuation is ignored, but never any
1088/// payload byte inside the filename itself.
1089///
1090/// The line reference is a POSITIONALLY DISTINCT suffix, never part of the
1091/// stem the checks below apply: it is parsed and stripped first, and only
1092/// then is the extension matched against what remains, so a digit inside the
1093/// stem itself (`some2module.py`) still fails the all-lowercase check exactly
1094/// as before.
1095///
1096/// A trailing colon with nothing after it stays where it has always been: in
1097/// the generic prose-punctuation trim just below, which removes it before any
1098/// of this runs. `<prefix>some_module.py:` in a sentence was admitted before
1099/// line references were understood here and is still admitted, because it is
1100/// the same filename it always was with sentence punctuation after it. Adding
1101/// a line-reference grammar is a widening of what this function accepts; it
1102/// narrows nothing.
1103fn is_filename_shaped_prefix_match(token: &str, needle: &str) -> bool {
1104    let token = token.trim_end_matches(|c: char| {
1105        matches!(
1106            c,
1107            '`' | '"' | '\'' | ')' | ']' | '}' | '>' | ',' | ';' | ':' | '!' | '?'
1108        )
1109    });
1110    let token = token.strip_suffix('.').unwrap_or(token);
1111    let Some(payload) = token.strip_prefix(needle) else {
1112        return false;
1113    };
1114    let payload = strip_source_citation_line_reference(payload);
1115    let Some(stem) = SOURCE_FILE_EXTENSIONS
1116        .iter()
1117        .find_map(|extension| payload.strip_suffix(extension))
1118    else {
1119        return false;
1120    };
1121
1122    stem.bytes().any(|byte| byte.is_ascii_lowercase())
1123        && stem
1124            .bytes()
1125            .any(|byte| matches!(byte, b'_' | b'-' | b'/' | b'.'))
1126        && stem
1127            .bytes()
1128            .all(|byte| byte.is_ascii_lowercase() || matches!(byte, b'_' | b'-' | b'/' | b'.'))
1129}
1130
1131/// Strip a trailing source citation's line reference from `payload` and
1132/// return the remainder; returns `payload` unchanged when the tail is not
1133/// exactly one of these two shapes.
1134///
1135/// Grammar: `:<digits>` or `:<digits>-<digits>` — digits only, at least one
1136/// digit in each run, no leading `+`/`-` on either run, and a single `-`
1137/// separating the two runs in the range form. A second colon, a non-digit
1138/// byte, or more than one `-` all mean this is not a line reference, so the
1139/// caller's extension match declines exactly as it does for any other
1140/// unrecognized suffix.
1141///
1142/// This grammar is intentionally separate from the stem predicate the caller
1143/// applies after this returns: the reference is peeled off BEFORE the
1144/// extension is matched, so the digits admitted here never reach the stem,
1145/// and they do not loosen what that predicate accepts.
1146fn strip_source_citation_line_reference(payload: &str) -> &str {
1147    let Some(colon) = payload.rfind(':') else {
1148        return payload;
1149    };
1150    let (head, reference) = (&payload[..colon], &payload[colon + 1..]);
1151
1152    let is_digit_run = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
1153    let is_line_reference = match reference.split_once('-') {
1154        None => is_digit_run(reference),
1155        Some((start, end)) => is_digit_run(start) && is_digit_run(end),
1156    };
1157
1158    if is_line_reference {
1159        head
1160    } else {
1161        payload
1162    }
1163}
1164
1165/// Shortest line of base64 that counts as PEM key material. Real key blocks
1166/// wrap at 64 columns; the last line of a block can be shorter, but a block
1167/// with no END marker is recognised by a full-width line, so a short tail on
1168/// its own is a mention rather than a key.
1169const PEM_BODY_LINE_MIN: usize = 40;
1170
1171/// A line consisting only of base64 alphabet characters, long enough to be a
1172/// wrapped line of a key block.
1173fn is_pem_body_line(line: &str) -> bool {
1174    line.len() >= PEM_BODY_LINE_MIN
1175        && line
1176            .bytes()
1177            .all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/' || b == b'=')
1178}
1179
1180/// Byte offset of the first line break at or after `from`: a real newline,
1181/// or the two-character escape `\\n` that a newline becomes once the text
1182/// is serialized JSON (a note's properties, a stream payload). Returns the
1183/// break's start and the offset just past it, or `None` when the rest of
1184/// the text is one line.
1185fn next_line_break(text: &str, from: usize) -> Option<(usize, usize)> {
1186    let rest = &text[from..];
1187    let real = rest.find('\n').map(|i| (from + i, from + i + 1));
1188    let escaped = rest.find("\\n").map(|i| (from + i, from + i + 2));
1189    match (real, escaped) {
1190        (Some(r), Some(e)) => Some(if r.0 <= e.0 { r } else { e }),
1191        (r, e) => r.or(e),
1192    }
1193}
1194
1195/// End of the line starting at `from` (exclusive of its break) and the start
1196/// of the following line.
1197fn line_bounds(text: &str, from: usize) -> (usize, usize) {
1198    match next_line_break(text, from) {
1199        Some((end, next)) => (end, next),
1200        None => (text.len(), text.len()),
1201    }
1202}
1203
1204/// Find the leftmost PEM private key block: a `-----BEGIN <TYPE> PRIVATE
1205/// KEY-----` header line followed by a body. The body is either a matching
1206/// `-----END ... PRIVATE KEY-----` marker before the next BEGIN, or at
1207/// least one line of base64 of key-block width directly under it. A header
1208/// with neither is a mention of the format (a documentation page, code that
1209/// prints the label) and carries no key, so it is not a candidate. Lines
1210/// break on a newline or on its JSON escape, so a key inside a serialized
1211/// document is read the same way as one in plain text. The returned slice
1212/// is bounded to the block: through the END line when one is present,
1213/// otherwise through the last base64 line under the header.
1214fn find_pem_private_key_block(text: &str) -> Option<&str> {
1215    let mut search = 0;
1216    while let Some(rel) = text[search..].find("-----BEGIN") {
1217        let pos = search + rel;
1218        let (header_end, body_start) = line_bounds(text, pos);
1219        let header = &text[pos..header_end];
1220        // Resume after this header on the next pass whatever it turns out to be.
1221        search = header_end;
1222        if !header.contains("PRIVATE KEY-----") {
1223            continue;
1224        }
1225        // The END marker must belong to this header: stop looking at the
1226        // next BEGIN so a mention above a real block does not claim it.
1227        let next_begin = text[body_start..]
1228            .find("-----BEGIN")
1229            .map(|r| body_start + r)
1230            .unwrap_or(text.len());
1231        if let Some(end_rel) = text[body_start..next_begin].find("-----END") {
1232            let end_pos = body_start + end_rel;
1233            let (end_line, end_next) = line_bounds(text, end_pos);
1234            if text[end_pos..end_line].contains("PRIVATE KEY-----") {
1235                return Some(&text[pos..end_next]);
1236            }
1237        }
1238        let mut body_end = None;
1239        let mut cursor = body_start;
1240        while cursor < text.len() {
1241            let (line_end, next) = line_bounds(text, cursor);
1242            let line = text[cursor..line_end]
1243                .trim_end_matches('\r')
1244                .trim_end_matches("\\r");
1245            if is_pem_body_line(line) {
1246                body_end = Some(next);
1247                cursor = next;
1248                continue;
1249            }
1250            // The last line of a wrapped block is usually shorter than the
1251            // others. Once at least one full-width line has been seen, a
1252            // trailing base64-only run of any length belongs to the block,
1253            // so a masked surface never keeps the tail of the key. Inside a
1254            // serialized JSON string that run ends at the closing quote
1255            // instead of a line break; the block ends where the run ends.
1256            let run = line
1257                .bytes()
1258                .take_while(|b| b.is_ascii_alphanumeric() || *b == b'+' || *b == b'/' || *b == b'=')
1259                .count();
1260            let whole_line = run == line.len();
1261            let json_end = line[run..].starts_with('"');
1262            if run > 0 && (whole_line || json_end) {
1263                if body_end.is_some() {
1264                    body_end = Some(if whole_line { next } else { cursor + run });
1265                } else if run >= PEM_BODY_LINE_MIN && json_end {
1266                    body_end = Some(cursor + run);
1267                }
1268            }
1269            break;
1270        }
1271        if let Some(end) = body_end {
1272            return Some(&text[pos..end]);
1273        }
1274    }
1275    None
1276}
1277
1278/// Scan for a JWT pattern: at least two "eyJ" segments separated by a `.`
1279/// character, with each segment at least 10 chars.
1280fn find_jwt(text: &str) -> Option<&str> {
1281    let bytes = text.as_bytes();
1282    let mut i = 0;
1283    while i + 4 < bytes.len() {
1284        if bytes[i..].starts_with(b"eyJ") {
1285            // Find the end of this JWT (whitespace or string end).
1286            let end = bytes[i..]
1287                .iter()
1288                .position(|&b| b == b' ' || b == b'\n' || b == b'\r' || b == b'\t')
1289                .map(|p| i + p)
1290                .unwrap_or(bytes.len());
1291            let candidate = &text[i..end];
1292            // Must have at least 2 dots and 3 eyJ-prefixed segments.
1293            let dots = candidate.as_bytes().iter().filter(|&&b| b == b'.').count();
1294            if dots >= 2 {
1295                let parts: Vec<&str> = candidate.splitn(3, '.').collect();
1296                if parts.len() == 3
1297                    && parts[0].starts_with("eyJ")
1298                    && parts[1].starts_with("eyJ")
1299                    && parts[0].len() >= 10
1300                    && parts[1].len() >= 10
1301                {
1302                    return Some(candidate);
1303                }
1304            }
1305            i = end + 1;
1306        } else {
1307            i += 1;
1308        }
1309    }
1310    None
1311}
1312
1313/// Detect `scheme://user:pass@host` patterns where the userinfo carries an
1314/// actual credential: a non-empty password. The username may be empty —
1315/// `redis://:secret@host` is a standard empty-user connection string and its
1316/// password is no less a credential for the missing username.
1317fn find_url_userinfo(text: &str) -> Option<&str> {
1318    let mut search = text;
1319    let mut base = 0usize;
1320    while let Some(at_rel) = search.find("://") {
1321        let at_abs = base + at_rel;
1322        // After `://`, only the authority component may carry userinfo: it
1323        // ends at the first `/`, `?`, `#`, space, or newline. An `@` past
1324        // that boundary is path/query text (`https://host/a:x@next`), not a
1325        // credential.
1326        let rest_start = at_abs + 3;
1327        let rest = &text[rest_start..];
1328        let authority_end = rest
1329            .find(['/', '?', '#', ' ', '\n', '\r'])
1330            .unwrap_or(rest.len());
1331        if let Some(at_pos) = rest[..authority_end].rfind('@') {
1332            let userinfo = &rest[..at_pos];
1333            // Must contain a colon with a non-empty password after it.
1334            if let Some(colon) = userinfo.find(':') {
1335                let pass = &userinfo[colon + 1..];
1336                if !pass.is_empty() {
1337                    // Return a slice starting from the scheme.  Walk back from
1338                    // `at_abs` to the first non-scheme char and resume just past
1339                    // it.  Use `char_indices` and skip by the separator's full
1340                    // UTF-8 width: a multibyte separator (e.g. CJK prose before a
1341                    // credential URL) would otherwise leave `scheme_start` inside
1342                    // the codepoint and panic the slice below.
1343                    let scheme_start = text[..at_abs]
1344                        .char_indices()
1345                        .rev()
1346                        .find(|(_, c)| {
1347                            !c.is_ascii_alphanumeric() && *c != '+' && *c != '-' && *c != '.'
1348                        })
1349                        .map(|(idx, c)| idx + c.len_utf8())
1350                        .unwrap_or(0);
1351                    // Ensure there are no spaces in userinfo (not a code snippet).
1352                    if !userinfo.contains(' ') && !userinfo.contains('\n') {
1353                        let end = rest_start
1354                            + at_pos
1355                            + 1
1356                            + rest[at_pos + 1..]
1357                                .find([' ', '\n', '\r'])
1358                                .unwrap_or(rest[at_pos + 1..].len());
1359                        return Some(&text[scheme_start..end.min(text.len())]);
1360                    }
1361                }
1362            }
1363        }
1364        base = at_abs + 3;
1365        search = &text[base..];
1366    }
1367    None
1368}
1369
1370// ─── Layer 2: entropy heuristic ─────────────────────────────────────────────
1371
1372/// Trigger words checked as a bounded standalone word (see
1373/// [`contains_bounded_word`]). `token` is deliberately excluded — see
1374/// `has_standalone_token`/`has_token_assignment` instead.
1375/// See `docs/api/secret_gate.md#trigger_words` for the substring-collision
1376/// rationale (issues #577 / #632).
1377const TRIGGER_WORDS: &[&str] = &[
1378    "key",
1379    "secret",
1380    "password",
1381    "passwd",
1382    "credential",
1383    "bearer",
1384    "auth",
1385    "apikey",
1386];
1387
1388/// Compound triggers that retain suffix matching inside credential labels.
1389/// Their underscore separator disambiguates them from ordinary prose, and
1390/// suffixes are common in versioned credential names such as `api_keyv2`.
1391const COMPOUND_TRIGGER_WORDS: &[&str] = &["api_key", "access_key", "private_key"];
1392
1393/// Minimum token length to apply the entropy check.
1394const MIN_ENTROPY_LEN: usize = 24;
1395
1396/// Shannon entropy threshold (bits per character) above which a token is
1397/// considered high-entropy.  7.0 corresponds to ~99% utilisation of a
1398/// 128-symbol alphabet — typical for random base64/hex.
1399const ENTROPY_THRESHOLD: f64 = 4.5;
1400
1401/// Window around a trigger word in which a high-entropy token must appear.
1402const TRIGGER_WINDOW: usize = 120;
1403
1404/// Credential-shaped exact hex lengths (AWS secret key, SHA-256/git SHA
1405/// doubled, SHA-512 hex, etc.) — checked against a whole token, a single
1406/// separator-delimited run, and a normalized (separator-stripped)
1407/// concatenation of adjacent hex runs/tokens; see
1408/// [`normalized_hex_credential_span`].
1409const HEX_CREDENTIAL_LENGTHS: &[usize] = &[32, 40, 64, 128];
1410
1411/// Max fragments [`bridge_fragment_chain`] concatenates per credential (fragment-count bound,
1412/// not gap byte length — see docs/api/secret_gate.md#bridge-fragment-reconstruction).
1413const MAX_BRIDGE_FRAGMENTS: usize = 6;
1414
1415/// Max delimiter-only glue tokens absorbed per direction while bridging fragments; see
1416/// docs/api/secret_gate.md#bridge-fragment-reconstruction.
1417const MAX_BRIDGE_GLUE_TOKENS: usize = 6;
1418
1419/// Shortest bare token treated as a plausible bridge fragment; see
1420/// docs/api/secret_gate.md#bridge-fragment-reconstruction.
1421const MIN_BRIDGE_FRAGMENT_LEN: usize = 8;
1422
1423/// Largest index `<= i` that lies on a UTF-8 char boundary of `s`. Stable
1424/// replacement for the unstable `str::floor_char_boundary`; used to snap
1425/// byte-offset windows that may land inside a multibyte char before slicing.
1426fn floor_char_boundary(s: &str, i: usize) -> usize {
1427    let mut i = i.min(s.len());
1428    while i > 0 && !s.is_char_boundary(i) {
1429        i -= 1;
1430    }
1431    i
1432}
1433
1434/// Tokenize the full input once for the entropy detector. The returned offsets
1435/// are absolute offsets into `text` and remain valid for every scan cursor.
1436fn tokenize_entropy_tokens(text: &str) -> Vec<(usize, &str)> {
1437    #[cfg(test)]
1438    ENTROPY_TOKENIZATION_COUNT.with(|count| count.set(count.get() + 1));
1439
1440    // Tokenize into maximal ASCII non-whitespace runs; non-ASCII chars are also
1441    // delimiters (see docs/api/secret_gate.md#module-level-detection-algorithm,
1442    // "non-ASCII token delimiting"). Identical to `split_ascii_whitespace` on
1443    // pure-ASCII input.
1444    text.split(|c: char| c.is_ascii_whitespace() || !c.is_ascii())
1445        .filter(|t| !t.is_empty())
1446        .map(|t| {
1447            let offset = t.as_ptr() as usize - text.as_ptr() as usize;
1448            (offset, t)
1449        })
1450        .collect()
1451}
1452
1453#[derive(Clone, Copy)]
1454struct JsonScalarContext {
1455    value_start: usize,
1456    end: usize,
1457    label: Option<(usize, &'static str)>,
1458}
1459
1460struct EntropyScanContext<'a> {
1461    tokens: Vec<(usize, &'a str)>,
1462    scalars: Vec<JsonScalarContext>,
1463}
1464
1465impl<'a> EntropyScanContext<'a> {
1466    fn new(text: &'a str) -> Self {
1467        Self {
1468            tokens: tokenize_entropy_tokens(text),
1469            scalars: json_scalar_contexts(text),
1470        }
1471    }
1472
1473    fn scalar_for(&self, text: &str, value: &str) -> Option<JsonScalarContext> {
1474        let value = wrapper_strip_repeated(value);
1475        if value.is_empty() {
1476            return None;
1477        }
1478        let start = value.as_ptr() as usize - text.as_ptr() as usize;
1479        let end = start + value.len();
1480        let index = self.scalars.partition_point(|scalar| scalar.end <= start);
1481        self.scalars
1482            .get(index)
1483            .copied()
1484            .filter(|scalar| end > scalar.value_start && end <= scalar.end)
1485    }
1486}
1487
1488// Validate once before interpreting punctuation as field boundaries. The
1489// iterative source walk retains offsets for masking; decoding keys prevents
1490// JSON escapes from hiding an owning credential label.
1491fn json_scalar_contexts(text: &str) -> Vec<JsonScalarContext> {
1492    if !text.trim_start().starts_with(['{', '['])
1493        || serde_json::from_str::<serde::de::IgnoredAny>(text).is_err()
1494    {
1495        return Vec::new();
1496    }
1497    let bytes = text.as_bytes();
1498    let mut scalars = Vec::new();
1499    let mut containers: Vec<Option<(usize, &'static str)>> = Vec::new();
1500    let mut pending_label = None;
1501    let mut index = 0;
1502    while index < bytes.len() {
1503        match bytes[index] {
1504            b'{' | b'[' => {
1505                let label = pending_label
1506                    .take()
1507                    .or_else(|| containers.last().copied().flatten());
1508                containers.push(label);
1509                index += 1;
1510            }
1511            b'}' | b']' => {
1512                containers.pop();
1513                pending_label = None;
1514                index += 1;
1515            }
1516            b',' | b':' | b' ' | b'\t' | b'\r' | b'\n' => index += 1,
1517            _ => {
1518                let start = index;
1519                let quoted = bytes[index] == b'"';
1520                let value_start = start + usize::from(quoted);
1521                let end = if quoted {
1522                    index += 1;
1523                    while index < bytes.len() && bytes[index] != b'"' {
1524                        index += if bytes[index] == b'\\' { 2 } else { 1 };
1525                    }
1526                    let end = index;
1527                    index += 1;
1528                    end
1529                } else {
1530                    while index < bytes.len()
1531                        && !bytes[index].is_ascii_whitespace()
1532                        && !matches!(bytes[index], b',' | b'}' | b']')
1533                    {
1534                        index += 1;
1535                    }
1536                    index
1537                };
1538                if index > bytes.len() {
1539                    return Vec::new();
1540                }
1541                if quoted && text[index..].trim_start().starts_with(':') {
1542                    let Ok(key) = serde_json::from_str::<String>(&text[start..index]) else {
1543                        return Vec::new();
1544                    };
1545                    let label =
1546                        find_trigger(&format!("{key}:"), true).map(|trigger| (end, trigger));
1547                    pending_label = label;
1548                    continue;
1549                }
1550                let label = pending_label
1551                    .take()
1552                    .or_else(|| containers.last().copied().flatten());
1553                scalars.push(JsonScalarContext {
1554                    value_start,
1555                    end,
1556                    label,
1557                });
1558            }
1559        }
1560    }
1561    scalars
1562}
1563
1564// A bare Git-length value uses line-local context. The preceding label line
1565// remains authoritative when it explicitly ends in an assignment delimiter.
1566// Bridge anchors retain full-window context so masking cannot leave a fragment behind.
1567fn entropy_trigger(
1568    text: &str,
1569    tokens: &[(usize, &str)],
1570    index: usize,
1571    credential_label_only: bool,
1572    candidate: EntropyCandidate<'_>,
1573) -> Option<&'static str> {
1574    let (mut offset, raw) = tokens[index];
1575    let mut token_end = offset + raw.len();
1576    if let Some(scalar) = candidate.scalar {
1577        offset =
1578            (candidate.value.as_ptr() as usize - text.as_ptr() as usize).max(scalar.value_start);
1579        token_end = (candidate.value.as_ptr() as usize - text.as_ptr() as usize
1580            + candidate.value.len())
1581        .min(scalar.end);
1582    }
1583    let mut window_start = floor_char_boundary(text, offset.saturating_sub(TRIGGER_WINDOW));
1584    let mut window_end = floor_char_boundary(text, token_end + TRIGGER_WINDOW);
1585    if let Some(scalar) = candidate.scalar {
1586        window_start = window_start.max(scalar.value_start);
1587        window_end = window_end.min(scalar.end);
1588    }
1589    let token = strip_delimiters(raw);
1590    let standalone_revision = token.len() == 40
1591        && token.bytes().all(|b| b.is_ascii_hexdigit())
1592        && bridge_fragment_chain(tokens, text, index).len() == 1;
1593    let (start, end, preceding_label) = if standalone_revision {
1594        let line_start = text[window_start..offset]
1595            .rfind(['\r', '\n'])
1596            .map_or(window_start, |i| window_start + i + 1);
1597        let line_end = text[token_end..window_end]
1598            .find(['\r', '\n'])
1599            .map_or(window_end, |i| token_end + i);
1600        let before_line = &text[window_start..line_start];
1601        let previous = before_line
1602            .strip_suffix("\r\n")
1603            .or_else(|| before_line.strip_suffix(['\r', '\n']))
1604            .unwrap_or("");
1605        let previous_start = previous.rfind(['\r', '\n']).map_or(0, |i| i + 1);
1606        let previous = previous[previous_start..].trim_end();
1607        let label = if previous.ends_with([':', '=']) {
1608            find_trigger(previous, credential_label_only)
1609        } else {
1610            None
1611        };
1612        (
1613            line_start.max(window_start),
1614            line_end.min(window_end),
1615            label,
1616        )
1617    } else {
1618        (window_start, window_end, None)
1619    };
1620    // Trigger context stops at a sentence boundary: a detector name or an
1621    // unrelated clause in the preceding sentence is not context for this token
1622    // (issue #2056). The preceding-label fallback is exempt because that line
1623    // was already required to end in an assignment delimiter.
1624    find_trigger(
1625        after_last_sentence_boundary(&text[start..offset]),
1626        credential_label_only,
1627    )
1628    .or_else(|| {
1629        find_trigger(
1630            before_first_sentence_boundary(&text[token_end..end]),
1631            credential_label_only,
1632        )
1633    })
1634    .or(candidate.inline_trigger)
1635    .or(preceding_label)
1636}
1637
1638/// An entropy view with an assignment context bounded to one inline member.
1639#[derive(Clone, Copy)]
1640struct EntropyCandidate<'a> {
1641    value: &'a str,
1642    member: &'a str,
1643    inline_trigger: Option<&'static str>,
1644    bridge_anchor: bool,
1645    scalar: Option<JsonScalarContext>,
1646}
1647
1648fn entropy_candidates(raw: &str) -> Vec<EntropyCandidate<'_>> {
1649    let mut candidates = Vec::new();
1650    // Preserve external-window reconstruction across punctuation. This view
1651    // deliberately supplies no inline label from anywhere in the token.
1652    if raw.contains([',', ';', '&']) {
1653        candidates.push(EntropyCandidate {
1654            value: raw,
1655            member: raw,
1656            inline_trigger: None,
1657            bridge_anchor: true,
1658            scalar: None,
1659        });
1660    }
1661    for member in raw
1662        .split([',', ';', '&'])
1663        .filter(|member| !member.is_empty())
1664    {
1665        let has_assignment = member.contains([':', '=']);
1666        candidates.push(EntropyCandidate {
1667            value: member,
1668            member,
1669            inline_trigger: (!has_assignment)
1670                .then(|| inline_credential_trigger(member))
1671                .flatten(),
1672            bridge_anchor: member.len() == raw.len(),
1673            scalar: None,
1674        });
1675        if !has_assignment {
1676            continue;
1677        }
1678        // An underscore carrier is still a value when an enclosing benign
1679        // assignment or trailing base64 padding introduces delimiters. Bound
1680        // this fallback to its own segment so a later label cannot govern an
1681        // earlier unrelated value.
1682        for segment in member.split([':', '=']) {
1683            if let Some(inline_trigger) = inline_credential_trigger(segment) {
1684                candidates.push(EntropyCandidate {
1685                    value: wrapper_strip_repeated(segment),
1686                    member,
1687                    inline_trigger: Some(inline_trigger),
1688                    bridge_anchor: false,
1689                    scalar: None,
1690                });
1691            }
1692        }
1693        let low = member.to_ascii_lowercase();
1694        let mut assignment_start = 0;
1695        for (offset, separator) in member.char_indices() {
1696            if !matches!(separator, ':' | '=') {
1697                continue;
1698            }
1699            let end = offset + separator.len_utf8();
1700            let inline_trigger = assignment_credential_trigger(&low[assignment_start..end]);
1701            assignment_start = end;
1702            if inline_trigger.is_none() {
1703                continue;
1704            }
1705            let value = wrapper_strip_repeated(&member[end..]);
1706            if value.is_empty() {
1707                continue;
1708            }
1709            // The first credential assignment governs this member's remaining
1710            // value, including nested carriers. Exact UUID/hash extraction
1711            // still tries every suffix via `value_candidates`; the run and
1712            // reconstruction checks see the entire governed value as before.
1713            candidates.push(EntropyCandidate {
1714                value,
1715                member,
1716                inline_trigger,
1717                bridge_anchor: member.len() == raw.len(),
1718                scalar: None,
1719            });
1720            break;
1721        }
1722    }
1723    candidates
1724}
1725
1726/// Preserve the original bounded bridge walk while clipping inline context
1727/// at sibling-member boundaries and the governing value's start. A later
1728/// assignment cannot use an earlier value as its bridge anchor. External-window
1729/// candidates retain the original token and its unchanged bridge reconstruction.
1730fn entropy_bridge_fragments<'a>(
1731    tokens: &[(usize, &'a str)],
1732    text: &'a str,
1733    index: usize,
1734    candidate: EntropyCandidate<'a>,
1735) -> Vec<&'a str> {
1736    let fragments = bridge_fragment_chain(tokens, text, index);
1737    if candidate.inline_trigger.is_none() {
1738        return fragments;
1739    }
1740    let raw = tokens[index].1;
1741    let raw_start = raw.as_ptr() as usize;
1742    let raw_end = raw_start + raw.len();
1743    let value_start = candidate.value.as_ptr() as usize;
1744    let member_end = candidate.member.as_ptr() as usize + candidate.member.len();
1745    fragments
1746        .into_iter()
1747        .filter_map(|fragment| {
1748            let start = fragment.as_ptr() as usize;
1749            if (raw_start..raw_end).contains(&start) {
1750                Some(strip_delimiters(candidate.value))
1751            } else if (start < raw_start && value_start == raw_start)
1752                || (start >= raw_end && member_end == raw_end)
1753            {
1754                Some(fragment)
1755            } else {
1756                None
1757            }
1758        })
1759        .collect()
1760}
1761
1762/// `from` limits returned spans; context remains relative to each original
1763/// whitespace token, including when masking resumes inside one member.
1764fn check_entropy_heuristic<'a>(
1765    text: &'a str,
1766    from: usize,
1767    context: &EntropyScanContext<'a>,
1768) -> Option<(&'a str, &'static str, Option<&'static str>)> {
1769    let tokens = &context.tokens;
1770    let first_token = tokens.partition_point(|&(offset, raw)| offset + raw.len() <= from);
1771    for (idx, &(context_offset, raw)) in tokens.iter().enumerate().skip(first_token) {
1772        let token = strip_delimiters(raw);
1773        if token.len() < MIN_ENTROPY_LEN && !is_bridge_fragment_shape(token) {
1774            continue;
1775        }
1776        let mut best: Option<(&str, &'static str, Option<&'static str>)> = None;
1777        for mut candidate in entropy_candidates(raw) {
1778            candidate.scalar = context.scalar_for(text, candidate.value);
1779            if candidate.scalar.is_none() && !context.scalars.is_empty() {
1780                let core = wrapper_strip_repeated(candidate.value);
1781                let start = core.as_ptr() as usize - text.as_ptr() as usize;
1782                let end = start + core.len();
1783                let first = context
1784                    .scalars
1785                    .partition_point(|scalar| scalar.end <= start);
1786                if context
1787                    .scalars
1788                    .get(first)
1789                    .is_some_and(|scalar| scalar.value_start < end && scalar.end < end)
1790                {
1791                    // The member views below still scan each value; a raw
1792                    // bridge anchor spanning sibling scalars has no shared label.
1793                    continue;
1794                }
1795            }
1796            if let Some(scalar) = candidate.scalar {
1797                let offset = candidate.value.as_ptr() as usize - text.as_ptr() as usize;
1798                let label = scalar
1799                    .label
1800                    .filter(|(end, _)| offset.saturating_sub(*end) <= TRIGGER_WINDOW)
1801                    .map(|(_, trigger)| trigger);
1802                candidate.inline_trigger = candidate.inline_trigger.or(label);
1803            }
1804            let offset = candidate.value.as_ptr() as usize - text.as_ptr() as usize;
1805            if offset + candidate.value.len() <= from {
1806                continue;
1807            }
1808            if let Some(found) =
1809                check_entropy_candidate(text, from, tokens, idx, context_offset, candidate)
1810            {
1811                if best
1812                    .as_ref()
1813                    .is_none_or(|current| found.0.as_ptr() < current.0.as_ptr())
1814                {
1815                    best = Some(found);
1816                }
1817            }
1818        }
1819        if best.is_some() {
1820            return best;
1821        }
1822    }
1823    None
1824}
1825
1826fn check_entropy_candidate<'a>(
1827    text: &'a str,
1828    from: usize,
1829    tokens: &[(usize, &'a str)],
1830    idx: usize,
1831    context_offset: usize,
1832    candidate: EntropyCandidate<'a>,
1833) -> Option<(&'a str, &'static str, Option<&'static str>)> {
1834    let raw_token = candidate.value;
1835    // Strip common delimiters that wrap the actual value.
1836    let original_offset = raw_token.as_ptr() as usize - text.as_ptr() as usize;
1837    let remaining = &raw_token[from.saturating_sub(original_offset).min(raw_token.len())..];
1838    let token = strip_delimiters(remaining);
1839    // Only RETURN tokens at or after `from` (already-redacted spans lie
1840    // before it); the trigger window below still spans the full text.
1841    let token_offset = token.as_ptr() as usize - text.as_ptr() as usize;
1842    if token_offset < from {
1843        return None;
1844    }
1845    // A token below MIN_ENTROPY_LEN still passes through when it's a plausible
1846    // bridge FRAGMENT (see docs/api/secret_gate.md#bridge-fragment-reconstruction);
1847    // gating on alphanumeric runs (not hex-only) covers base64/base64url halves too.
1848    let is_bridge_candidate = is_bridge_fragment_shape(token);
1849    if token.len() < MIN_ENTROPY_LEN && !is_bridge_candidate {
1850        return None;
1851    }
1852
1853    // `token` is ASCII here (non-ASCII was split out at tokenization), so
1854    // `shannon_entropy` over its bytes is a true per-character entropy.
1855
1856    // Compute the trigger window before any shape-based allowlist decision.
1857    // UUIDs require credential-label context rather than a generic mention
1858    // of `token`; base64 content-hash exemptions remain trigger-sensitive.
1859    // VCS revisions and file paths use narrower syntactic context below.
1860    let trigger = entropy_trigger(text, tokens, idx, false, candidate);
1861    let near_trigger = trigger.is_some();
1862    let uuid_trigger = entropy_trigger(text, tokens, idx, true, candidate);
1863    let uuid_near_credential_label = uuid_trigger.is_some();
1864
1865    // Step 1 (see doc: per-token flagging sequence). UUIDs fall through only
1866    // beside an explicit credential label; the generic word `token` remains
1867    // trigger context for opaque values but is common in design prose. Content
1868    // hashes retain the broader trigger rule. Hex-shaped entropy alone (<=4.0
1869    // bits/char) can never reach ENTROPY_THRESHOLD.
1870    let has_uuid_candidate = value_candidates(token).any(is_uuid_canonical);
1871    if uuid_near_credential_label && has_uuid_candidate {
1872        return Some((token, "uuid-near-trigger", uuid_trigger));
1873    }
1874    if near_trigger && value_candidates(token).any(is_base64_content_hash) {
1875        return Some((token, "content-hash-near-trigger", trigger));
1876    }
1877    if !uuid_near_credential_label && is_uuid_canonical(token) {
1878        return None;
1879    }
1880    if !near_trigger && is_base64_content_hash(token) {
1881        return None;
1882    }
1883
1884    // Step 2. Pure hex off-trigger is allowlisted; trigger-adjacent hex needs an
1885    // explicit VCS coordinate marker (see doc).
1886    if !near_trigger && is_pure_hex(token) {
1887        return None;
1888    }
1889
1890    // A fixed-width SHA-256 digest has an independent, explicit source label.
1891    // Defer its admission until after bridge reconstruction: a digest-shaped
1892    // fragment must still be checked as part of a larger candidate.
1893    let labeled_sha256_digest = near_trigger
1894        && candidate.inline_trigger.is_none()
1895        && is_labeled_sha256_digest(text, token_offset, token);
1896
1897    // VCS-marker exemption is a flag over the hex-credential-shape checks only,
1898    // never an early skip of fragment reconstruction below (see doc).
1899    if is_vcs_marker_before_hex(text, candidate.member)
1900        && !has_clause_credential_label_with_inline(
1901            text,
1902            context_offset,
1903            candidate.inline_trigger.is_some(),
1904            ClauseValueKind::VcsReference,
1905        )
1906    {
1907        return None;
1908    }
1909
1910    let repository_revision_reference = is_repository_revision_reference(candidate.member);
1911    let vcs_reference_exempt = if repository_revision_reference {
1912        !has_direct_repository_credential_label_with_inline(
1913            text,
1914            context_offset,
1915            candidate.inline_trigger.is_some(),
1916        )
1917    } else {
1918        is_git_revision_reference(text, context_offset, candidate.member)
1919            && !has_clause_credential_label_with_inline(
1920                text,
1921                context_offset,
1922                candidate.inline_trigger.is_some(),
1923                ClauseValueKind::VcsReference,
1924            )
1925    };
1926
1927    // Dense mathematical notation has the same mixed-character entropy
1928    // profile as an opaque token. Exempt a syntactically recognizable
1929    // LaTeX fragment only when it contains no credential-shaped run and
1930    // the immediately preceding field does not label it as a credential
1931    // (#1988). Known-prefix detectors have already run before this layer.
1932    if near_trigger
1933        && is_latex_fragment_without_credential_run(token)
1934        && !(candidate.inline_trigger.is_some()
1935            || has_immediate_credential_label(text, context_offset))
1936    {
1937        return None;
1938    }
1939
1940    // Step 3. Hex API keys aren't caught by the entropy heuristic (hex tops out at
1941    // 4.0 bits/char, below ENTROPY_THRESHOLD 4.5); flag credential-shaped hex directly.
1942    if !vcs_reference_exempt
1943        && near_trigger
1944        && !labeled_sha256_digest
1945        && is_pure_hex(token)
1946        && HEX_CREDENTIAL_LENGTHS.contains(&token.len())
1947    {
1948        return Some((token, "hex-credential-token", trigger));
1949    }
1950
1951    // Step 4 (issue #1044): a credential can dilute below the whole-token-average
1952    // checks above via low-entropy filler sharing its whitespace token
1953    // (`vault/<payload>/rotate.md`); re-check each `/`-split run independently.
1954    // See doc for the #1040 corpus rationale behind the MIN_ENTROPY_LEN floor.
1955    if near_trigger {
1956        // vcs_reference_exempt also covers single-token forms below (`rev:<hex>`);
1957        // it does not cover fragment reconstruction.
1958        for run in token.split(|c: char| !c.is_ascii_alphanumeric()) {
1959            if run.len() < MIN_ENTROPY_LEN {
1960                continue;
1961            }
1962            if !vcs_reference_exempt
1963                && !labeled_sha256_digest
1964                && is_pure_hex(run)
1965                && HEX_CREDENTIAL_LENGTHS.contains(&run.len())
1966            {
1967                return Some((run, "hex-credential-token", trigger));
1968            }
1969            if shannon_entropy(run.as_bytes()) >= ENTROPY_THRESHOLD {
1970                return Some((token, "high-entropy-token", trigger));
1971            }
1972        }
1973
1974        // Step 5 (#1062): concatenate consecutive pure-hex runs (dropping
1975        // separators) and re-check against HEX_CREDENTIAL_LENGTHS — catches a
1976        // hex payload split into multiple sub-floor runs. See doc.
1977        if !vcs_reference_exempt && !labeled_sha256_digest {
1978            if let Some(candidate) = normalized_hex_credential_span(token) {
1979                return Some((candidate, "hex-credential-token", trigger));
1980            }
1981        }
1982
1983        // Step 6 (#1062, Unicode variant): bridge fragments split across non-ASCII
1984        // tokenizer delimiters (e.g. U+200B) via `bridge_fragment_chain`, which walks
1985        // both directions across a bounded chain (MAX_BRIDGE_FRAGMENTS,
1986        // MAX_BRIDGE_GLUE_TOKENS) rather than one adjacent pair — see
1987        // docs/api/secret_gate.md#check_entropy_heuristic--per-token-flagging-sequence
1988        // for the exact guarantee and its accepted residual (same-uid-host) limits.
1989        if !vcs_reference_exempt
1990            && (candidate.bridge_anchor || candidate.inline_trigger.is_some())
1991            && tokens.len() > 1
1992        {
1993            let fragments = entropy_bridge_fragments(tokens, text, idx, candidate);
1994            if fragments.len() > 1 {
1995                let first = fragments[0];
1996                let last = fragments[fragments.len() - 1];
1997                let chain_start = first.as_ptr() as usize - text.as_ptr() as usize;
1998                let chain_end = last.as_ptr() as usize - text.as_ptr() as usize + last.len();
1999                let search_start = chain_start.max(from);
2000                if let Some(candidate) =
2001                    normalized_hex_credential_span(&text[search_start..chain_end])
2002                {
2003                    return Some((candidate, "hex-credential-token", trigger));
2004                }
2005                let concatenated: String = fragments.concat();
2006                if concatenated.len() >= MIN_ENTROPY_LEN
2007                    && concatenated.bytes().all(|b| b.is_ascii_alphanumeric())
2008                    && shannon_entropy(concatenated.as_bytes()) >= ENTROPY_THRESHOLD
2009                {
2010                    return Some((token, "high-entropy-token", trigger));
2011                }
2012            }
2013        }
2014
2015        if is_plausible_file_path(token)
2016            && !has_clause_credential_label_with_inline(
2017                text,
2018                context_offset,
2019                candidate.inline_trigger.is_some(),
2020                ClauseValueKind::FilePath,
2021            )
2022        {
2023            return None;
2024        }
2025
2026        // Step 8. These shapes are references in technical prose. The prefix, per-run,
2027        // normalized-hex, and bridge detectors above retain priority over
2028        // them; an opaque value inside any of those carriers still refuses.
2029        if labeled_sha256_digest
2030            || is_prose_code_reference(candidate.member, token)
2031            || is_environment_name(token)
2032            || is_latex_prose_macro(token)
2033            || is_aws_resource_name(token)
2034        {
2035            return None;
2036        }
2037    }
2038
2039    // Canonical repository links and href commit targets are source
2040    // coordinates, not standalone values. The direct-label guard above
2041    // keeps `api key: <revision URL>` fail-closed; technical prose such as
2042    // `key-scoped source` can safely retain the citation (#2076).
2043    if vcs_reference_exempt {
2044        return None;
2045    }
2046
2047    // Step 9: structured-identifier exemption, off-trigger only. Must run after the
2048    // UUID/hex checks and before the entropy computation (an identifier can exceed
2049    // ENTROPY_THRESHOLD on Shannon entropy alone).
2050    if !near_trigger && is_structured_identifier(token) {
2051        return None;
2052    }
2053
2054    let entropy = shannon_entropy(token.as_bytes());
2055    if entropy < ENTROPY_THRESHOLD {
2056        return None;
2057    }
2058
2059    // High-entropy token in trigger context — flag it.
2060    if near_trigger {
2061        return Some((token, "high-entropy-token", trigger));
2062    }
2063    None
2064}
2065
2066/// `true` when every byte of `run` is an ASCII hex digit. The
2067/// no-minimum-length, no-`0x`-prefix building block for
2068/// [`normalized_hex_credential_span`]'s intra-token run decomposition.
2069/// Unlike [`is_pure_hex`] this has no 8-char floor of its own — a legitimate
2070/// credential split across separators can leave a shorter individual run
2071/// that still must sum correctly with its neighbors.
2072fn is_hex_run(run: &str) -> bool {
2073    !run.is_empty() && run.bytes().all(|b| b.is_ascii_hexdigit())
2074}
2075
2076const VCS_MARKERS: &[&str] = &["commit", "revision", "rev", "sha"];
2077
2078/// Marker-word form of a VCS reference: `raw_token` is itself a bare marker
2079/// and the next token in `text` is a 40-hex value.
2080fn is_vcs_marker_before_hex(text: &str, raw_token: &str) -> bool {
2081    let token = wrapper_strip_repeated(raw_token);
2082    let marker = strip_delimiters(token);
2083    if !VCS_MARKERS
2084        .iter()
2085        .any(|candidate| marker.eq_ignore_ascii_case(candidate))
2086    {
2087        return false;
2088    }
2089    let raw_offset = raw_token.as_ptr() as usize - text.as_ptr() as usize;
2090    let next = text[raw_offset + raw_token.len()..].trim_start();
2091    let next = wrapper_strip_repeated(extract_token(next));
2092    next.len() == 40 && next.bytes().all(|b| b.is_ascii_hexdigit())
2093}
2094
2095fn is_git_revision_reference(text: &str, token_offset: usize, raw_token: &str) -> bool {
2096    const MARKERS: &[&str] = VCS_MARKERS;
2097
2098    let token = wrapper_strip_repeated(raw_token);
2099    if is_vcs_marker_before_hex(text, raw_token) {
2100        return true;
2101    }
2102
2103    if token.len() == 40 && token.bytes().all(|b| b.is_ascii_hexdigit()) {
2104        let marker = trailing_identifier(&text[..token_offset]);
2105        return MARKERS
2106            .iter()
2107            .any(|candidate| marker.eq_ignore_ascii_case(candidate));
2108    }
2109
2110    let Some((label, value)) = token.rsplit_once(':') else {
2111        return false;
2112    };
2113    let label = wrapper_strip_repeated(label);
2114    let value = wrapper_strip_repeated(value);
2115    MARKERS
2116        .iter()
2117        .any(|candidate| label.eq_ignore_ascii_case(candidate))
2118        && value.len() == 40
2119        && value.bytes().all(|b| b.is_ascii_hexdigit())
2120}
2121
2122fn is_exact_hex_revision(value: &str) -> bool {
2123    value.len() == 40 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
2124}
2125
2126/// Repository-native revision coordinates embedded in a URL/HTML token.
2127///
2128/// This intentionally recognizes only exact 40-hex revisions after a
2129/// canonical repository path marker, plus an exact `href=<40hex>` target.
2130/// Arbitrary query values, prefixes, and credential-length hex elsewhere in
2131/// markup remain subject to the normal detector.
2132fn is_repository_revision_reference(raw_token: &str) -> bool {
2133    let token = wrapper_strip_repeated(raw_token);
2134    let low = token.to_ascii_lowercase();
2135
2136    for marker in ["/blob/", "/tree/", "/commit/", "/commits/"] {
2137        let mut from = 0usize;
2138        while let Some(relative) = low[from..].find(marker) {
2139            let value_start = from + relative + marker.len();
2140            let remainder = &token[value_start..];
2141            let value_end = remainder
2142                .bytes()
2143                .position(|byte| !byte.is_ascii_hexdigit())
2144                .unwrap_or(remainder.len());
2145            if is_exact_hex_revision(&remainder[..value_end]) {
2146                return true;
2147            }
2148            from = value_start.min(low.len());
2149            if from == low.len() {
2150                break;
2151            }
2152        }
2153    }
2154
2155    for (prefix, quote) in [("href=\"", '"'), ("href='", '\'')] {
2156        if let Some(start) = low.find(prefix) {
2157            let value = &token[start + prefix.len()..];
2158            if let Some(end) = value.find(quote) {
2159                if is_exact_hex_revision(&value[..end]) {
2160                    return true;
2161                }
2162            }
2163        }
2164    }
2165
2166    false
2167}
2168
2169fn trailing_identifier(text: &str) -> &str {
2170    let trimmed = text.trim_end_matches(|c: char| !c.is_ascii_alphanumeric() && c != '_');
2171    trimmed
2172        .rsplit(|c: char| !c.is_ascii_alphanumeric() && c != '_')
2173        .next()
2174        .unwrap_or_default()
2175}
2176
2177/// Return whether the identifier immediately preceding a candidate is a
2178/// credential label. This deliberately does not walk through narrative prose:
2179/// notation such as `key estimate uses \\operatorname{softmax}` contains a
2180/// trigger word, but does not assign the LaTeX fragment to that word.
2181fn has_immediate_credential_label(text: &str, token_offset: usize) -> bool {
2182    let before = text[..token_offset].trim_end();
2183    let before = before
2184        .strip_suffix(':')
2185        .or_else(|| before.strip_suffix('='))
2186        .unwrap_or(before);
2187    let label = trailing_identifier(before).to_ascii_lowercase();
2188
2189    label == "token"
2190        || COMPOUND_TRIGGER_WORDS
2191            .iter()
2192            .any(|trigger| label.contains(trigger))
2193        || TRIGGER_WORDS
2194            .iter()
2195            .any(|trigger| contains_bounded_word(&label, trigger))
2196}
2197
2198/// A repository revision URL/href is exempt from broad trigger proximity,
2199/// but never from a direct credential label. When a value separator is
2200/// present immediately before the reference, only its actual field label is
2201/// authoritative: narrative shapes such as `key-scoped source citation:`
2202/// must not turn the earlier adjective into the citation value's label.
2203fn has_direct_repository_credential_label_with_inline(
2204    text: &str,
2205    token_offset: usize,
2206    inline_trigger: bool,
2207) -> bool {
2208    if inline_trigger {
2209        return true;
2210    }
2211    let before = text[..token_offset].trim_end();
2212    if before.ends_with(':') || before.ends_with('=') {
2213        return has_immediate_credential_label(text, token_offset);
2214    }
2215    has_clause_credential_label_with_inline(
2216        text,
2217        token_offset,
2218        false,
2219        ClauseValueKind::VcsReference,
2220    )
2221}
2222
2223/// Words the clause walk in [`has_clause_credential_label_with_inline`] steps over when
2224/// searching backwards for a credential label. Connectors are the words that
2225/// commonly sit between a label and its value in natural assignment prose
2226/// ("api key value is X", "the token was X"); the VCS coordinate markers are
2227/// included so the marker itself cannot shield an earlier label from the
2228/// walk ("api key value is commit <hex>"); prepositions, determiners, and
2229/// possessives are the glue of noun-compound label qualifiers ("api key for
2230/// our production deploy: X") and carry no content of their own.
2231const LABEL_CLAUSE_SKIP_WORDS: &[&str] = &[
2232    "commit",
2233    "revision",
2234    "rev",
2235    "sha",
2236    "is",
2237    "was",
2238    "are",
2239    "were",
2240    "be",
2241    "been",
2242    "being",
2243    "value",
2244    "values",
2245    "the",
2246    "a",
2247    "an",
2248    "this",
2249    "that",
2250    "it",
2251    "its",
2252    "as",
2253    "here",
2254    "now",
2255    "currently",
2256    "equals",
2257    "for",
2258    "of",
2259    "to",
2260    "in",
2261    "on",
2262    "at",
2263    "by",
2264    "with",
2265    "from",
2266    "per",
2267    "and",
2268    "or",
2269    "our",
2270    "my",
2271    "your",
2272    "their",
2273];
2274
2275/// Maximum identifiers the clause walk examines. Bounds the scan cost to one
2276/// short assignment clause. Exhausting the budget is NOT evidence of absence:
2277/// when a value delimiter was crossed, running out of steps fails CLOSED
2278/// (treated as credential-labeled) — a truncated scan cannot prove the clause
2279/// is unlabeled, and any exhaustion-fails-open rule re-admits the labeled
2280/// value bypass one natural word past the budget. Sized so a label separated
2281/// from its value by connectors plus a dotted version qualifier ("api key
2282/// v1.2 value is commit <hex>" — the version costs two identifier steps)
2283/// stays in range without exhaustion.
2284const LABEL_CLAUSE_WALK_LIMIT: usize = 8;
2285
2286/// File-path candidates may walk across at most this many content identifiers
2287/// without a `:`/`=` delimiter. Two reaches the direct-label shapes that need
2288/// protection (`auth scanner found <path>`) while keeping the walk local.
2289const FILE_PATH_NO_DELIMITER_CONTENT_LIMIT: usize = 2;
2290
2291/// The two narrow trigger-context exemptions need different no-delimiter
2292/// behavior. VCS coordinates preserve the strict prose guard that keeps
2293/// `the key changes are in commit <sha>` readable; file paths admit the
2294/// bounded bridge above so a short label cannot disguise a credential value.
2295#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2296enum ClauseValueKind {
2297    VcsReference,
2298    FilePath,
2299}
2300
2301/// End offset of a sentence/paragraph boundary beginning at `index`.
2302///
2303/// A single newline remains intra-sentence so natural credential assignments
2304/// such as `api key:\n<value>` stay protected. Blank lines, clause-ending
2305/// punctuation, and a period followed by a non-alphanumeric byte end the
2306/// surrounding trigger context. The period rule deliberately keeps `v1.2`
2307/// intra-sentence.
2308fn sentence_boundary_end_at(bytes: &[u8], index: usize) -> Option<usize> {
2309    match bytes.get(index).copied()? {
2310        b';' | b'!' | b'?' => Some(index + 1),
2311        b'.' if bytes
2312            .get(index + 1)
2313            .is_some_and(|next| !next.is_ascii_alphanumeric()) =>
2314        {
2315            Some(index + 1)
2316        }
2317        b'\n' if bytes.get(index + 1) == Some(&b'\n') => Some(index + 2),
2318        b'\r'
2319            if bytes
2320                .get(index..index + 4)
2321                .is_some_and(|window| window == b"\r\n\r\n") =>
2322        {
2323            Some(index + 4)
2324        }
2325        _ => None,
2326    }
2327}
2328
2329/// Context after the final sentence boundary in `text`.
2330fn after_last_sentence_boundary(text: &str) -> &str {
2331    let bytes = text.as_bytes();
2332    let mut start = 0usize;
2333    for index in 0..bytes.len() {
2334        if let Some(end) = sentence_boundary_end_at(bytes, index) {
2335            start = end;
2336        }
2337    }
2338    &text[start..]
2339}
2340
2341/// Context before the first sentence boundary in `text`.
2342fn before_first_sentence_boundary(text: &str) -> &str {
2343    let bytes = text.as_bytes();
2344    for index in 0..bytes.len() {
2345        if sentence_boundary_end_at(bytes, index).is_some() {
2346            return &text[..index];
2347        }
2348    }
2349    text
2350}
2351
2352/// Sentence/paragraph boundary inside a clause-walk gap. `;`, `!`, `?`, and
2353/// blank lines always end the clause. `.` ends it only when it is not
2354/// immediately followed by an alphanumeric character: a dot tight between
2355/// identifier fragments ("v1.2") is intra-token punctuation, while a dot at
2356/// the end of the gap abuts the next identifier (gaps end where the adjacent
2357/// identifier begins) and is likewise intra-token.
2358fn gap_has_sentence_boundary(gap: &str) -> bool {
2359    let bytes = gap.as_bytes();
2360    (0..bytes.len()).any(|index| sentence_boundary_end_at(bytes, index).is_some())
2361}
2362
2363/// Version-shaped identifier fragment ("2", "v1", "12") — the pieces a dotted
2364/// version qualifier like `v1.2` splits into under identifier extraction.
2365/// Treated as connector material so a versioned label ("api key v1.2 value
2366/// is …") stays reachable.
2367fn is_version_fragment(word: &str) -> bool {
2368    let digits = word
2369        .strip_prefix('v')
2370        .or_else(|| word.strip_prefix('V'))
2371        .unwrap_or(word);
2372    !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit())
2373}
2374
2375/// Hex-run identifier long enough to be credential material rather than a
2376/// word ("0123456789abcdef01234567"). Treated as connector material by the
2377/// clause walk: a separator-split payload fragment sitting between the
2378/// candidate value and its label is value material, not a label word that
2379/// ends the clause.
2380fn is_hex_fragment_word(word: &str) -> bool {
2381    word.len() >= 12 && word.bytes().all(|b| b.is_ascii_hexdigit())
2382}
2383
2384/// Allocation-free case-insensitive substring search for ASCII identifiers.
2385fn contains_ascii_case_insensitive(haystack: &str, needle: &str) -> bool {
2386    haystack
2387        .as_bytes()
2388        .windows(needle.len())
2389        .any(|window| window.eq_ignore_ascii_case(needle.as_bytes()))
2390}
2391
2392/// A trailing identifier can contain only ASCII alphanumerics and `_`, so
2393/// splitting on `_` exactly preserves the bare-trigger boundary rule used by
2394/// [`contains_bounded_word`] without allocating a lowercase copy.
2395fn clause_label_has_credential_trigger(label: &str) -> bool {
2396    label.eq_ignore_ascii_case("token")
2397        || COMPOUND_TRIGGER_WORDS
2398            .iter()
2399            .any(|trigger| contains_ascii_case_insensitive(label, trigger))
2400        || label.split('_').any(|part| {
2401            TRIGGER_WORDS
2402                .iter()
2403                .any(|trigger| part.eq_ignore_ascii_case(trigger))
2404        })
2405}
2406
2407fn is_label_clause_skip_word(label: &str) -> bool {
2408    LABEL_CLAUSE_SKIP_WORDS
2409        .iter()
2410        .any(|word| label.eq_ignore_ascii_case(word))
2411}
2412
2413/// Words ending in the byte sequence `ed` are only a cheap proxy for a
2414/// regular English participle. Keep known lexical false matches explicit so
2415/// a noun such as `hundred` cannot become evidence that a credential label is
2416/// merely narrative prose. Irregular forms such as `found` deliberately do
2417/// not qualify: they must not shield `api key found <value>`.
2418fn is_clause_narrative_participle(label: &str) -> bool {
2419    const NON_PARTICIPLE_ED_WORDS: &[&str] = &["hundred"];
2420
2421    label.len() >= 5
2422        && label
2423            .get(label.len().saturating_sub(2)..)
2424            .is_some_and(|suffix| suffix.eq_ignore_ascii_case("ed"))
2425        && !NON_PARTICIPLE_ED_WORDS
2426            .iter()
2427            .any(|word| label.eq_ignore_ascii_case(word))
2428}
2429
2430/// Cheap regular-gerund proxy used only after the no-delimiter file-path walk
2431/// has crossed an explicit `in` on the value side (`api_key handling in
2432/// <path>`). Adjacency alone is never narrative evidence: `api key handling
2433/// <value>` must keep walking to the direct credential label.
2434fn is_clause_narrative_gerund(label: &str) -> bool {
2435    label.len() >= 6
2436        && label
2437            .get(label.len().saturating_sub(3)..)
2438            .is_some_and(|suffix| suffix.eq_ignore_ascii_case("ing"))
2439}
2440
2441/// `true` when the candidate token sits in credential-value syntax: an inline
2442/// credential shape on the token itself, or a credential label reachable by
2443/// walking backwards through the current clause. The walk steps over
2444/// [`LABEL_CLAUSE_SKIP_WORDS`], version fragments, and long hex fragments,
2445/// and stops at a sentence/paragraph boundary (see
2446/// [`gap_has_sentence_boundary`]) — a label on the far side of a boundary is
2447/// prose context, not this value's label. Crossing a value delimiter (`:` or
2448/// `=`, including one attached to a VCS marker: "deploy sha: <hex>" is still
2449/// assignment syntax) additionally lets the walk step over content words
2450/// outside those sets, bounded only by [`LABEL_CLAUSE_WALK_LIMIT`], the
2451/// sentence boundary, and the past-participle stop: "label with qualifiers:
2452/// value" names the value regardless of how many qualifier nouns the label
2453/// carries ("api key for production deploy: X", "api key for shared
2454/// encrypted deploy: X"). A per-clause content-word cap was tried here and
2455/// removed — any cap re-admits the labeled-value bypass one natural
2456/// qualifier past the cap. For the same reason, exhausting the walk budget
2457/// after crossing a delimiter fails CLOSED: the clause is assignment-shaped
2458/// and its head was never scanned, so it is treated as credential-labeled.
2459/// A regular past-participle content word ("flagged", "introduced") ends the walk —
2460/// verb-phrase prose narrates an action on the value rather than labeling it
2461/// ("the auth scanner flagged this file: <path>", "one extra token was
2462/// introduced by sha: <hex>"). Coordinating conjunctions are transparent to
2463/// that position test — "shared and encrypted deploy" keeps both participles
2464/// in adjective position. Without a delimiter, VCS references step over only
2465/// the closed connector sets, preserving ordinary prose such as "the key
2466/// changes are in commit <hex>". File paths additionally step over at most
2467/// [`FILE_PATH_NO_DELIMITER_CONTENT_LIMIT`] content words, closing direct
2468/// label shapes such as "auth scanner found <path>" without broadening the
2469/// VCS tier.
2470fn has_clause_credential_label_with_inline(
2471    text: &str,
2472    token_offset: usize,
2473    inline_trigger: bool,
2474    value_kind: ClauseValueKind,
2475) -> bool {
2476    if inline_trigger {
2477        return true;
2478    }
2479
2480    let mut rest = &text[..token_offset];
2481    let mut crossed_value_delimiter = false;
2482    let mut no_delimiter_content_words = 0usize;
2483    // Whether the previously processed identifier (the one nearer the value)
2484    // was the literal preposition `in`. This starts false deliberately:
2485    // adjacency to the value must not make a gerund narrative evidence.
2486    let mut arrived_through_in_preposition = false;
2487    // Whether the previously processed identifier (the one nearer the value)
2488    // was connector material. Starts true: step 0 is adjacent to the value or
2489    // its delimiter.
2490    let mut arrived_through_connector = true;
2491    for step in 0..LABEL_CLAUSE_WALK_LIMIT {
2492        let label = trailing_identifier(rest);
2493        if label.is_empty() {
2494            return false;
2495        }
2496        let gap = &rest[label.as_ptr() as usize - rest.as_ptr() as usize + label.len()..];
2497        if step > 0 && gap_has_sentence_boundary(gap) {
2498            return false;
2499        }
2500        if gap.contains([':', '=']) {
2501            crossed_value_delimiter = true;
2502        }
2503        if clause_label_has_credential_trigger(label) {
2504            return true;
2505        }
2506        let skippable = is_label_clause_skip_word(label)
2507            || is_version_fragment(label)
2508            || is_hex_fragment_word(label);
2509        if !skippable {
2510            // This barrier applies to delimiter-bearing clauses and to the
2511            // bounded file-path bridge. It intentionally recognizes regular
2512            // `-ed` forms only: `found` is a bridge word, not a narrative
2513            // shield for a direct credential label.
2514            let no_delimiter_file_path_narrative = !crossed_value_delimiter
2515                && value_kind == ClauseValueKind::FilePath
2516                && arrived_through_in_preposition
2517                && is_clause_narrative_gerund(label);
2518            // Delimiter-bearing clauses and VCS coordinates preserve their
2519            // existing direct-participle semantics. A no-delimiter file path
2520            // must first process real value-side context: the synthetic
2521            // initial connector state alone cannot let `api key leaked
2522            // <value>` stop before reaching the trigger.
2523            let regular_participle_narrative = is_clause_narrative_participle(label)
2524                && (crossed_value_delimiter || value_kind != ClauseValueKind::FilePath || step > 0);
2525            if arrived_through_connector
2526                && (regular_participle_narrative || no_delimiter_file_path_narrative)
2527            {
2528                return false;
2529            }
2530            if !crossed_value_delimiter {
2531                if value_kind != ClauseValueKind::FilePath
2532                    || no_delimiter_content_words >= FILE_PATH_NO_DELIMITER_CONTENT_LIMIT
2533                {
2534                    return false;
2535                }
2536                no_delimiter_content_words += 1;
2537            }
2538        }
2539        // Coordinating conjunctions are transparent to participle-position
2540        // classification: in "shared and encrypted deploy" the coordination
2541        // as a whole is followed by a content noun, so "shared" is still an
2542        // adjective — the conjunction preserves the arrived state instead of
2543        // marking connector position.
2544        if !label.eq_ignore_ascii_case("and") && !label.eq_ignore_ascii_case("or") {
2545            arrived_through_connector = skippable;
2546        }
2547        arrived_through_in_preposition = label.eq_ignore_ascii_case("in");
2548        let start = label.as_ptr() as usize - rest.as_ptr() as usize;
2549        rest = &rest[..start];
2550    }
2551    // Budget exhausted. A clause that crossed a value delimiter and ran out
2552    // of steps without a sentence boundary or verb-position participle is
2553    // assignment-shaped with an unscanned head — fail closed rather than let
2554    // clause length launder a labeled credential into the exemptions.
2555    crossed_value_delimiter
2556}
2557
2558fn is_plausible_file_path(token: &str) -> bool {
2559    let token = token.trim_start_matches(|c: char| {
2560        matches!(
2561            c,
2562            '"' | '\'' | '`' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';'
2563        )
2564    });
2565    let token = token.trim_end_matches(|c: char| {
2566        matches!(
2567            c,
2568            '"' | '\'' | '`' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';' | '.'
2569        )
2570    });
2571    let path = if let Some(angle_path) = token.strip_prefix('<') {
2572        let Some(close) = angle_path.rfind('>') else {
2573            return false;
2574        };
2575        if !is_line_location_suffix(&angle_path[close + 1..]) {
2576            return false;
2577        }
2578        &angle_path[..close]
2579    } else if let Some((path, suffix)) = token.rsplit_once(':') {
2580        if is_line_location_suffix(suffix) {
2581            path
2582        } else {
2583            token
2584        }
2585    } else {
2586        token
2587    };
2588
2589    if path.contains("://")
2590        || !path.contains('/')
2591        || !path
2592            .bytes()
2593            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'/' | b'.' | b'_' | b'-' | b'~'))
2594    {
2595        return false;
2596    }
2597
2598    let segment_count = path
2599        .split('/')
2600        .filter(|segment| !segment.is_empty())
2601        .count();
2602    segment_count >= 2 || (path.starts_with('/') && segment_count == 1)
2603}
2604
2605/// Narrow high-entropy exemption for mathematical notation (#1988).
2606///
2607/// A fragment needs both a LaTeX control sequence and several structural
2608/// delimiters. Any embedded credential-length hex or independently
2609/// high-entropy alphanumeric run disables the exemption, so wrapping an
2610/// opaque value in `\texttt{...}` does not launder it.
2611fn is_latex_fragment_without_credential_run(token: &str) -> bool {
2612    let bytes = token.as_bytes();
2613    let has_control_sequence = bytes
2614        .windows(2)
2615        .any(|pair| pair[0] == b'\\' && pair[1].is_ascii_alphabetic());
2616    let structural_count = bytes
2617        .iter()
2618        .filter(|byte| matches!(byte, b'\\' | b'{' | b'}' | b'^' | b'_'))
2619        .count();
2620    if !has_control_sequence || structural_count < 3 {
2621        return false;
2622    }
2623
2624    if normalized_hex_credential_span(token).is_some() {
2625        return false;
2626    }
2627
2628    !token
2629        .split(|ch: char| !ch.is_ascii_alphanumeric())
2630        .filter(|run| run.len() >= MIN_ENTROPY_LEN)
2631        .any(|run| shannon_entropy(run.as_bytes()) >= ENTROPY_THRESHOLD)
2632}
2633
2634/// A SHA-256 value is admitted only with a nearby checksum designation, not
2635/// with a credential field that merely happens to describe its encoding.
2636fn is_labeled_sha256_digest(text: &str, token_offset: usize, token: &str) -> bool {
2637    if token.len() != 64 || !token.bytes().all(|byte| byte.is_ascii_hexdigit()) {
2638        return false;
2639    }
2640    let before = text[..token_offset]
2641        .trim_end_matches(|ch: char| ch.is_ascii_whitespace() || matches!(ch, ':' | '='));
2642    let marker = ["sha256 key digest", "sha256 digest", "sha256"]
2643        .into_iter()
2644        .find(|marker| {
2645            before
2646                .get(before.len().saturating_sub(marker.len())..)
2647                .is_some_and(|tail| tail.eq_ignore_ascii_case(marker))
2648        });
2649    let Some(marker) = marker else {
2650        return false;
2651    };
2652    let marker_start = before.len() - marker.len();
2653    if marker_start > 0
2654        && text[..marker_start]
2655            .chars()
2656            .next_back()
2657            .is_some_and(|ch| ch.is_ascii_alphanumeric() || ch == '_')
2658    {
2659        return false;
2660    }
2661    !has_clause_credential_label_with_inline(text, marker_start, false, ClauseValueKind::FilePath)
2662}
2663
2664/// Inline Rust call references have syntax that an opaque credential does not:
2665/// backticks, a qualified symbol, and an empty argument list. Arguments are
2666/// excluded because their values would otherwise become part of the exemption.
2667fn is_prose_code_reference(member: &str, token: &str) -> bool {
2668    let Some(code) = member.strip_prefix('`').and_then(|s| s.strip_suffix('`')) else {
2669        return false;
2670    };
2671    if code != token || !code.ends_with("()") {
2672        return false;
2673    }
2674    let Some((owner, method)) = code[..code.len() - 2].split_once("::") else {
2675        return false;
2676    };
2677    owner
2678        .as_bytes()
2679        .first()
2680        .is_some_and(|b| b.is_ascii_alphabetic())
2681        && owner
2682            .bytes()
2683            .all(|b| b.is_ascii_alphanumeric() || b == b'_')
2684        && method
2685            .as_bytes()
2686            .first()
2687            .is_some_and(|b| b.is_ascii_alphabetic())
2688        && method
2689            .bytes()
2690            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b':' | b'<' | b'>'))
2691        && !method.contains("::")
2692}
2693
2694/// Uppercase environment references are names, not values, when their final
2695/// component names a conventional non-secret field. The run checks above
2696/// still refuse any credential-shaped component inside the name.
2697fn is_environment_name(token: &str) -> bool {
2698    let mut components = token.split('_');
2699    let Some(first) = components.next() else {
2700        return false;
2701    };
2702    if first.is_empty() || !first.bytes().all(|b| b.is_ascii_uppercase()) {
2703        return false;
2704    }
2705    let mut count = 0;
2706    let mut last = "";
2707    for part in components {
2708        if part.is_empty()
2709            || part.len() > 16
2710            || !part
2711                .bytes()
2712                .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())
2713        {
2714            return false;
2715        }
2716        count += 1;
2717        last = part;
2718    }
2719    count >= 3 && matches!(last, "PATH" | "FILE" | "NAME" | "TTL")
2720}
2721
2722/// Closed, familiar math commands keep this exemption on notation rather
2723/// than arbitrary backslash-prefixed strings.
2724fn is_latex_prose_macro(token: &str) -> bool {
2725    [
2726        "\\mathsf{",
2727        "\\mathbf{",
2728        "\\mathcal{",
2729        "\\mathrm{",
2730        "\\operatorname{",
2731    ]
2732    .iter()
2733    .any(|prefix| token.starts_with(prefix))
2734        && is_latex_fragment_without_credential_run(token)
2735}
2736
2737/// Recognize two unambiguous AWS resource address forms. The resource path
2738/// is restricted to short name segments; an embedded long credential run is
2739/// refused before this predicate is reached.
2740fn is_aws_resource_name(token: &str) -> bool {
2741    let mut parts = token.splitn(6, ':');
2742    let (Some("arn"), Some("aws"), Some(service), Some(region), Some(account), Some(resource)) = (
2743        parts.next(),
2744        parts.next(),
2745        parts.next(),
2746        parts.next(),
2747        parts.next(),
2748        parts.next(),
2749    ) else {
2750        return false;
2751    };
2752    let valid_resource = !resource.is_empty()
2753        && resource.split('/').all(|segment| {
2754            !segment.is_empty()
2755                && segment.len() <= 24
2756                && segment
2757                    .bytes()
2758                    .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
2759        });
2760    valid_resource
2761        && ((service == "s3" && region.is_empty() && account.is_empty())
2762            || (service == "iam"
2763                && region.is_empty()
2764                && account.len() == 12
2765                && account.bytes().all(|b| b.is_ascii_digit())
2766                && (resource.starts_with("role/") || resource.starts_with("policy/"))))
2767}
2768
2769fn is_line_location_suffix(suffix: &str) -> bool {
2770    if suffix.is_empty() {
2771        return true;
2772    }
2773    let suffix = suffix.strip_prefix(':').unwrap_or(suffix);
2774    let suffix = suffix.strip_prefix('~').unwrap_or(suffix);
2775    let mut parts = suffix.split('-');
2776    let Some(start) = parts.next() else {
2777        return false;
2778    };
2779    !start.is_empty()
2780        && start.bytes().all(|b| b.is_ascii_digit())
2781        && parts
2782            .next()
2783            .is_none_or(|end| !end.is_empty() && end.bytes().all(|b| b.is_ascii_digit()))
2784        && parts.next().is_none()
2785}
2786
2787/// The raw span whose consecutive pure-hex runs — splitting on every
2788/// non-alphanumeric character and dropping the separators for length
2789/// accounting — reach one of [`HEX_CREDENTIAL_LENGTHS`].
2790///
2791/// Closes the separator-dilution bypass (#1062) where a credential-length
2792/// hex payload is spread across multiple runs each individually below
2793/// [`MIN_ENTROPY_LEN`]: `0123456789abcdef0123/456789abcdef01234567` is two
2794/// 20-char hex runs that never individually reach 24 chars or a
2795/// credential-length boundary, but normalize to one 40-char hex sequence.
2796/// A non-hex, non-empty run resets the running sum — this only bridges
2797/// ADJACENT hex runs, not hex fragments scattered across unrelated filler.
2798fn normalized_hex_credential_span(token: &str) -> Option<&str> {
2799    let mut concatenated_len = 0usize;
2800    let mut span_start = 0usize;
2801    for run in token.split(|c: char| !c.is_ascii_alphanumeric()) {
2802        if run.is_empty() {
2803            continue;
2804        }
2805        if is_hex_run(run) {
2806            let run_start = run.as_ptr() as usize - token.as_ptr() as usize;
2807            if concatenated_len == 0 {
2808                span_start = run_start;
2809            }
2810            concatenated_len += run.len();
2811            if HEX_CREDENTIAL_LENGTHS.contains(&concatenated_len) {
2812                return Some(&token[span_start..run_start + run.len()]);
2813            }
2814        } else {
2815            concatenated_len = 0;
2816        }
2817    }
2818    None
2819}
2820
2821/// `true` when the gap `text[gap_start..gap_end]` between two adjacent
2822/// tokenizer tokens holds no ASCII alphanumeric character — the shape a
2823/// tokenizer-delimiting separator (ASCII whitespace, or a non-ASCII
2824/// character such as U+200B; see the tokenizer comment in
2825/// [`check_entropy_heuristic`]) leaves behind when it splits one credential
2826/// payload into two tokens. Deliberately UNBOUNDED on gap byte length
2827/// (#1062: a byte-length bound here is defeated outright by
2828/// repeating the delimiter character) — [`bridge_fragment_chain`] is what
2829/// keeps the overall reconstruction bounded, via [`MAX_BRIDGE_FRAGMENTS`],
2830/// not this check. This still never bridges tokens separated by a genuine
2831/// word or sentence: any real word in the gap contains an ASCII alphanumeric
2832/// character and fails the check immediately.
2833fn adjacent_gap_is_bridgeable(text: &str, gap_start: usize, gap_end: usize) -> bool {
2834    gap_end >= gap_start && !text[gap_start..gap_end].contains(|c: char| c.is_ascii_alphanumeric())
2835}
2836
2837/// `true` when `s` is shaped like a plausible FRAGMENT of a separator-split
2838/// credential: alphanumeric-only and at least [`MIN_BRIDGE_FRAGMENT_LEN`]
2839/// bytes. Shared by the short-token anchor-admission check in
2840/// [`check_entropy_heuristic`] and by [`bridge_fragment_chain`]'s outward
2841/// walk. A long anchor can reach reconstruction without satisfying this
2842/// shape, but every neighboring fragment merged into its chain must satisfy
2843/// it. This stops the walk at a short trigger/glue word (`key`, `api`, `for`)
2844/// sitting immediately beside the real fragments (#1062).
2845fn is_bridge_fragment_shape(s: &str) -> bool {
2846    s.len() >= MIN_BRIDGE_FRAGMENT_LEN && s.bytes().all(|b| b.is_ascii_alphanumeric())
2847}
2848
2849/// `true` when `s` holds no ASCII alphanumeric character at all — the same
2850/// predicate [`adjacent_gap_is_bridgeable`] applies to the byte-range GAP
2851/// between two tokenizer tokens, applied here to a tokenizer TOKEN itself
2852/// (`s` is always non-empty: the tokenizer filters empty tokens). A
2853/// delimiter-only token such as `---` sitting between two Unicode-separator
2854/// gaps (#1062) carries none of a credential's own
2855/// characters — it is exactly as transparent to reconstruction as the
2856/// surrounding whitespace/Unicode gaps are, so [`bridge_fragment_chain`]
2857/// treats it as glue to walk across, not as a chain-terminating non-fragment
2858/// token. A token can never be both this and [`is_bridge_fragment_shape`]:
2859/// the latter requires only alphanumeric bytes, this requires none.
2860fn is_delimiter_only_token(s: &str) -> bool {
2861    !s.bytes().any(|b| b.is_ascii_alphanumeric())
2862}
2863
2864/// Looks outward from `tokens[edge]` in `dir` (`-1` = toward index 0, `+1` =
2865/// toward the end) for the next [`is_bridge_fragment_shape`] token, walking
2866/// transparently across up to [`MAX_BRIDGE_GLUE_TOKENS`] consecutive
2867/// [`is_delimiter_only_token`] glue tokens along the way. Every gap crossed
2868/// — including the ones on either side of a glue token — must be
2869/// [`adjacent_gap_is_bridgeable`]. Returns the found fragment's index, or
2870/// `None` if the walk runs off the end of `tokens`, meets a token that is
2871/// neither a fragment nor glue, meets a non-bridgeable gap, or exhausts the
2872/// glue budget before finding a fragment.
2873fn probe_bridge_fragment(
2874    tokens: &[(usize, &str)],
2875    text: &str,
2876    edge: usize,
2877    dir: isize,
2878) -> Option<usize> {
2879    let mut i = edge;
2880    let mut glue_skipped = 0usize;
2881    loop {
2882        let next_i = i.checked_add_signed(dir)?;
2883        if next_i >= tokens.len() {
2884            return None;
2885        }
2886        let (lo, hi) = if dir < 0 { (next_i, i) } else { (i, next_i) };
2887        let (lo_offset, lo_raw) = tokens[lo];
2888        let (hi_offset, _) = tokens[hi];
2889        let gap_start = lo_offset + lo_raw.len();
2890        if !adjacent_gap_is_bridgeable(text, gap_start, hi_offset) {
2891            return None;
2892        }
2893        let candidate = strip_delimiters(tokens[next_i].1);
2894        if is_bridge_fragment_shape(candidate) {
2895            return Some(next_i);
2896        }
2897        if is_delimiter_only_token(candidate) && glue_skipped < MAX_BRIDGE_GLUE_TOKENS {
2898            glue_skipped += 1;
2899            i = next_i;
2900            continue;
2901        }
2902        return None;
2903    }
2904}
2905
2906/// Reconstructs the bounded chain of tokenizer fragments containing
2907/// `tokens[anchor_idx]`, by walking outward in both directions via
2908/// [`probe_bridge_fragment`] until the chain has reached
2909/// [`MAX_BRIDGE_FRAGMENTS`] real fragments or neither direction can extend
2910/// further. Returns each REAL fragment's [`strip_delimiters`]-ed body, in
2911/// document order, for the caller to recombine — any delimiter-only glue
2912/// tokens absorbed along the way (#1062) are dropped from the
2913/// result entirely, so a caller joining fragments with a space
2914/// ([`normalized_hex_credential_span`]) or concatenating them directly
2915/// (the generic entropy check) sees only the genuine fragments, exactly as
2916/// if the glue were more gap. Extends both directions every iteration so a
2917/// credential split with fragments on both sides of the anchor (e.g. the
2918/// anchor is the MIDDLE fragment of a three-way split) is fully
2919/// reconstructed, not just one side of it. A length-1 result means no
2920/// extension was possible — callers should skip further work in that case.
2921/// The anchor itself is included without a fragment-shape check; only outward
2922/// extensions are admitted through [`probe_bridge_fragment`].
2923fn bridge_fragment_chain<'a>(
2924    tokens: &[(usize, &'a str)],
2925    text: &str,
2926    anchor_idx: usize,
2927) -> Vec<&'a str> {
2928    let mut start = anchor_idx;
2929    let mut end = anchor_idx;
2930    let mut fragment_count = 1usize;
2931
2932    loop {
2933        let mut extended = false;
2934        if fragment_count < MAX_BRIDGE_FRAGMENTS && start > 0 {
2935            if let Some(new_start) = probe_bridge_fragment(tokens, text, start, -1) {
2936                start = new_start;
2937                fragment_count += 1;
2938                extended = true;
2939            }
2940        }
2941        if fragment_count < MAX_BRIDGE_FRAGMENTS && end + 1 < tokens.len() {
2942            if let Some(new_end) = probe_bridge_fragment(tokens, text, end, 1) {
2943                end = new_end;
2944                fragment_count += 1;
2945                extended = true;
2946            }
2947        }
2948        if !extended {
2949            break;
2950        }
2951    }
2952
2953    tokens[start..=end]
2954        .iter()
2955        .map(|&(_, raw)| strip_delimiters(raw))
2956        .filter(|stripped| !is_delimiter_only_token(stripped))
2957        .collect()
2958}
2959
2960/// Extra backward truncation [`mask_bounded`] applies after it drops the
2961/// token straddling the window boundary.
2962///
2963/// `bridge_fragment_chain` can reconstruct a credential from up to
2964/// [`MAX_BRIDGE_FRAGMENTS`] whitespace-separated pieces, each individually
2965/// too short or low-entropy on its own to be recognized. The gap between two
2966/// fragments is deliberately unbounded in byte length
2967/// (`adjacent_gap_is_bridgeable`), so there is no finite forward lookahead
2968/// past the window boundary that could guarantee seeing every fragment of a
2969/// chain straddling the cut — a chain can be padded arbitrarily far past the
2970/// window by a single long glue token, which still counts as only one hop
2971/// against [`MAX_BRIDGE_GLUE_TOKENS`]. Rather than scan past the boundary,
2972/// this walks BACKWARD from it — data already read into the window, so the
2973/// extra work stays bounded by `window_chars` alone and needs no lookahead —
2974/// dropping every further bridge-fragment-shaped token chained to the
2975/// fragment [`mask_bounded`] already removed, spending the same
2976/// fragment-count and glue-token budgets [`bridge_fragment_chain`] would
2977/// spend walking outward from an anchor.
2978///
2979/// Runs unconditionally on every truncated window, regardless of whether the
2980/// window itself carries trigger-word context. `collect_mask_spans` step 6
2981/// admits trigger context from EITHER side of a fragment chain (a credential
2982/// like `<frag> <frag> <frag> is the api key for ...` is reconstructed by the
2983/// unbounded masker even though the trigger sits after the fragments), so a
2984/// trigger word that would justify keeping this window's tail may sit past
2985/// the window boundary — data this function, by construction, cannot see.
2986/// Gating the walk on an in-window trigger check would leave exactly that
2987/// case unprotected: the window carries no visible trigger, the walk would
2988/// never run, and any whole fragments already read into the window would
2989/// leak. The walk cannot distinguish a genuine chained fragment from an
2990/// unrelated fragment-shaped word sitting at the tail of an untriggered
2991/// window either; the trade this makes is dropping that word too rather than
2992/// risking a leaked credential fragment. The cost is bounded: at most
2993/// `MAX_BRIDGE_FRAGMENTS - 1` tokens of a tail that mask_bounded has already
2994/// decided to truncate.
2995///
2996/// Returns the byte offset to truncate `window` to, or `None` when nothing
2997/// beyond the already-trimmed token needs to go.
2998fn trailing_bridge_fragment_cut(window: &str) -> Option<usize> {
2999    let tokens = tokenize_entropy_tokens(window);
3000    let mut idx = tokens.len().checked_sub(1)?;
3001    let mut fragment_budget = MAX_BRIDGE_FRAGMENTS - 1;
3002    let mut glue_budget = MAX_BRIDGE_GLUE_TOKENS;
3003    let mut cut_at = None;
3004
3005    loop {
3006        let (offset, raw) = tokens[idx];
3007        let candidate = strip_delimiters(raw);
3008        if is_bridge_fragment_shape(candidate) {
3009            if fragment_budget == 0 {
3010                break;
3011            }
3012            fragment_budget -= 1;
3013            glue_budget = MAX_BRIDGE_GLUE_TOKENS;
3014            cut_at = Some(offset);
3015        } else if is_delimiter_only_token(candidate) {
3016            if glue_budget == 0 {
3017                break;
3018            }
3019            glue_budget -= 1;
3020        } else {
3021            break;
3022        }
3023
3024        if idx == 0 {
3025            break;
3026        }
3027        let (prev_offset, prev_raw) = tokens[idx - 1];
3028        let gap_start = prev_offset + prev_raw.len();
3029        if !adjacent_gap_is_bridgeable(window, gap_start, offset) {
3030            break;
3031        }
3032        idx -= 1;
3033    }
3034
3035    cut_at
3036}
3037
3038/// Returns `true` when `low_window` contains `needle` as a standalone word —
3039/// bounded on both sides by a character outside the word-char set (or
3040/// start/end of string) — rather than merely as a substring.
3041/// `underscore_is_word_char` selects the boundary rule the caller needs; see
3042/// `docs/api/secret_gate.md#contains_word` for the two deliberately different
3043/// rules and why each caller needs its own.
3044fn contains_word(low_window: &str, needle: &str, underscore_is_word_char: bool) -> bool {
3045    let is_word_char = |c: char| c.is_ascii_alphanumeric() || (underscore_is_word_char && c == '_');
3046    let mut start = 0;
3047    while let Some(rel) = low_window[start..].find(needle) {
3048        let abs = start + rel;
3049        let before_ok = abs == 0
3050            || low_window[..abs]
3051                .chars()
3052                .next_back()
3053                .is_none_or(|c| !is_word_char(c));
3054        let after_end = abs + needle.len();
3055        let after_ok = after_end >= low_window.len()
3056            || low_window[after_end..]
3057                .chars()
3058                .next()
3059                .is_none_or(|c| !is_word_char(c));
3060        if before_ok && after_ok {
3061            return true;
3062        }
3063        start = abs + needle.len().max(1);
3064    }
3065    false
3066}
3067
3068/// Returns `true` when `low_window` contains the bare trigger word `needle`
3069/// as a standalone word, with underscore treated as a BOUNDARY (see
3070/// [`contains_word`]) — so `secret_key=…`/`auth_token=…`/`signing_key=…`
3071/// still match (on the `secret`/`auth`/`key` half), while pure letter-joined
3072/// collisions like `authorized`/`authentication`/`monkey`/`keyword` do not.
3073fn contains_bounded_word(low_window: &str, needle: &str) -> bool {
3074    if needle != "key" {
3075        return contains_word(low_window, needle, false);
3076    }
3077    low_window
3078        .split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
3079        .any(|label| {
3080            if !contains_word(label, needle, false) {
3081                return false;
3082            }
3083            let end = label.as_ptr() as usize - low_window.as_ptr() as usize + label.len();
3084            let after = low_window[end..].trim_start_matches(is_assignment_label_gap);
3085            !(is_lookup_key_label(label) && after.starts_with([':', '=']))
3086        })
3087}
3088
3089fn is_assignment_label_gap(c: char) -> bool {
3090    matches!(c, '"' | '\'' | '`')
3091}
3092
3093/// Closed vocabulary of member names whose `_key` suffix names a lookup
3094/// identifier rather than a credential (issue #2654). The exception is an
3095/// allowlist on purpose: any other `*_key` label keeps `key` as a credential
3096/// trigger, so unknown compounds such as `hmac_key`, `master_key`, `ssh_key`,
3097/// `jwt_key`, `webhook_key` or `license_key` stay refused. The match is the
3098/// whole label: a qualified spelling such as `left_association_key` or
3099/// `hmac_cache_key` is not in the vocabulary and keeps its trigger, because a
3100/// prefix rule would re-open every compound the list closes (`hmac_` is not a
3101/// trigger word, so `hmac_cache_key` would strip to a listed suffix).
3102const LOOKUP_KEY_LABELS: &[&str] = &[
3103    "association_key",
3104    "cache_key",
3105    "composite_key",
3106    "dedup_key",
3107    "foreign_key",
3108    "idempotency_key",
3109    "index_key",
3110    "lookup_key",
3111    "map_key",
3112    "natural_key",
3113    "partition_key",
3114    "primary_key",
3115    "range_key",
3116    "routing_key",
3117    "row_key",
3118    "search_key",
3119    "shard_key",
3120    "sort_key",
3121    "surrogate_key",
3122    "unique_key",
3123];
3124
3125fn is_lookup_key_label(label: &str) -> bool {
3126    LOOKUP_KEY_LABELS.contains(&label)
3127}
3128
3129/// Finds a canonical compound credential label beginning at an identifier
3130/// boundary. The trailing edge is deliberately unbounded so version suffixes
3131/// and larger underscore-composed labels remain protected.
3132fn compound_trigger(low_text: &str) -> Option<&'static str> {
3133    COMPOUND_TRIGGER_WORDS.iter().copied().find(|needle| {
3134        let mut start = 0;
3135        while let Some(rel) = low_text[start..].find(needle) {
3136            let abs = start + rel;
3137            let before_ok = abs == 0
3138                || low_text[..abs]
3139                    .chars()
3140                    .next_back()
3141                    .is_none_or(|c| !c.is_ascii_alphanumeric());
3142            if before_ok {
3143                return true;
3144            }
3145            start = abs + needle.len();
3146        }
3147        false
3148    })
3149}
3150
3151fn find_trigger(text: &str, credential_label_only: bool) -> Option<&'static str> {
3152    let low = text.to_ascii_lowercase();
3153    TRIGGER_WORDS
3154        .iter()
3155        .copied()
3156        .find(|tw| contains_bounded_word(&low, tw))
3157        .or_else(|| compound_trigger(&low))
3158        .or_else(|| {
3159            ((!credential_label_only && has_standalone_token(&low)) || has_token_assignment(&low))
3160                .then_some("token")
3161        })
3162        .or_else(|| assignment_credential_trigger(&low))
3163}
3164
3165/// Detect a credential-bearing assignment label before an `=` or `:`.
3166///
3167/// The separator may be preceded by whitespace or a JSON quote. Compound
3168/// triggers deliberately retain substring matching inside the label so common
3169/// version suffixes such as `api_keyv2` remain protected.
3170fn assignment_credential_trigger(low_text: &str) -> Option<&'static str> {
3171    low_text.char_indices().find_map(|(index, ch)| {
3172        if !matches!(ch, '=' | ':') {
3173            return None;
3174        }
3175        let before =
3176            low_text[..index].trim_end_matches(|c: char| !c.is_ascii_alphanumeric() && c != '_');
3177        let label = before
3178            .rsplit(|c: char| !c.is_ascii_alphanumeric() && c != '_')
3179            .next()
3180            .unwrap_or_default();
3181        COMPOUND_TRIGGER_WORDS
3182            .iter()
3183            .copied()
3184            .find(|needle| label.contains(needle))
3185            .or_else(|| {
3186                TRIGGER_WORDS.iter().copied().find(|tw| {
3187                    (*tw != "key"
3188                        || !is_lookup_key_label(label)
3189                        || !low_text[before.len()..index]
3190                            .chars()
3191                            .all(is_assignment_label_gap))
3192                        && contains_bounded_word(label, tw)
3193                })
3194            })
3195            .or_else(|| (label == "token").then_some("token"))
3196    })
3197}
3198
3199/// Detect credential labels embedded in the same whitespace token as a value.
3200///
3201/// The surrounding-context scan deliberately excludes the candidate token so
3202/// a trigger word inside a path cannot make that path self-trigger. Credential
3203/// assignments still need to fire when no whitespace separates label and
3204/// value, including JSON-like forms. Underscore-delimited config identifiers
3205/// without an assignment are retained for compatibility with shapes such as
3206/// `session_secret_<value>`.
3207fn inline_credential_trigger(raw_token: &str) -> Option<&'static str> {
3208    let low = raw_token.to_ascii_lowercase();
3209    assignment_credential_trigger(&low).or_else(|| {
3210        if !low.contains(['/', '-', '.']) && low.contains('_') {
3211            COMPOUND_TRIGGER_WORDS
3212                .iter()
3213                .copied()
3214                .find(|needle| low.contains(needle))
3215                .or_else(|| {
3216                    TRIGGER_WORDS
3217                        .iter()
3218                        .copied()
3219                        .find(|tw| contains_bounded_word(&low, tw))
3220                })
3221        } else {
3222            None
3223        }
3224    })
3225}
3226
3227/// Returns `true` when `low_window` contains the word `token` as a standalone
3228/// word, with underscore treated as a WORD CHARACTER / continuation (see
3229/// [`contains_word`]) — but NOT as part of compound identifiers such as
3230/// `tokenizer`, `token_count`, or `next_token`. This underscore-as-
3231/// continuation rule is deliberately different from
3232/// [`contains_bounded_word`]: `token` alone is not a
3233/// credential trigger (it fires on too many benign technical terms), so it
3234/// needs the narrower, underscore-inclusive standalone-word definition,
3235/// whereas the bare `TRIGGER_WORDS` need underscore-joined compounds like
3236/// `secret_key` to still register.
3237fn has_standalone_token(low_window: &str) -> bool {
3238    contains_word(low_window, "token", true)
3239}
3240
3241/// Returns `true` when `low_window` contains the assignment form `token=` or
3242/// `token:` where the `token` identifier has a word boundary BEFORE it.
3243///
3244/// This is boundary-aware so that compound identifiers like `next_token:` or
3245/// `pagination_token=` do NOT trigger — only a standalone `token=`/`token:`
3246/// at the start of a field name does.
3247///
3248/// Examples that return `true`:  `token=<value>`, `token: <value>`,
3249///   `"token": "<value>"` (JSON key-value pairs).
3250/// Examples that return `false`: `next_token: <value>`,
3251///   `pagination_token=<value>`, `token_count: <value>`.
3252fn has_token_assignment(low_window: &str) -> bool {
3253    let needle = "token";
3254    let mut start = 0;
3255    while let Some(rel) = low_window[start..].find(needle) {
3256        let abs = start + rel;
3257        // Require a word boundary BEFORE `token`.
3258        let before_ok = abs == 0
3259            || low_window[..abs]
3260                .chars()
3261                .next_back()
3262                .is_none_or(|c| !c.is_ascii_alphanumeric() && c != '_');
3263        let after_end = abs + needle.len();
3264        // Require `=` or `:` immediately after `token` (possibly with surrounding
3265        // whitespace or quotes stripped by the time we see the lowercased window).
3266        let after_char = low_window[after_end..].chars().next();
3267        let after_is_assign = matches!(after_char, Some('=') | Some(':'));
3268        if before_ok && after_is_assign {
3269            return true;
3270        }
3271        start = abs + needle.len().max(1);
3272    }
3273    false
3274}
3275
3276// ─── Allowlist helpers ───────────────────────────────────────────────────────
3277
3278/// Returns `true` for pure-hex tokens (case-insensitive, optional `0x`/`0X` prefix,
3279/// 8–128 chars) — git SHAs, checksum digests, uuid-hex without hyphens.
3280///
3281/// This helper is used with context: pure-hex tokens near credential trigger words
3282/// are NOT allowlisted (see `check_entropy_heuristic`).  Only call this function
3283/// when you have already confirmed no trigger context is nearby.
3284fn is_pure_hex(token: &str) -> bool {
3285    let hex_part = token
3286        .strip_prefix("0x")
3287        .or(token.strip_prefix("0X"))
3288        .unwrap_or(token);
3289    hex_part.len() >= 8 && hex_part.len() <= 128 && hex_part.bytes().all(|b| b.is_ascii_hexdigit())
3290}
3291
3292/// Returns `true` for tokens that are unambiguous base64/base64url content
3293/// hashes with an explicit `sha<N>-` prefix (SRI hash, npm lockfile integrity).
3294/// Bare base64 of the same length WITHOUT the prefix is NOT allowlisted — see
3295/// `docs/api/secret_gate.md#is_base64_content_hash` for the full criteria list and
3296/// why the explicit prefix is required.
3297fn is_base64_content_hash(token: &str) -> bool {
3298    // Known vendor prefixes — never allowlist even if they look like base64.
3299    // Includes bare `sk-` to prevent OpenAI-shaped tokens from being allowlisted.
3300    const VENDOR_PREFIXES: &[&str] = &[
3301        "sk-",
3302        "rk_live_",
3303        "fm2_",
3304        "vercel_",
3305        "xoxb-",
3306        "xoxa-",
3307        "xoxp-",
3308        "xoxr-",
3309        "xoxs-",
3310        "ghp_",
3311        "gho_",
3312        "ghu_",
3313        "ghs_",
3314        "ghr_",
3315        "github_pat_",
3316        "AKIA",
3317        "ASIA",
3318        "AGE-SECRET-KEY-",
3319        "FlyV1",
3320    ];
3321    if VENDOR_PREFIXES.iter().any(|p| token.starts_with(p)) {
3322        return false;
3323    }
3324    // Require an explicit SRI `sha[0-9]+-` prefix.  Bare base64 at sha-length
3325    // is NOT allowlisted — it is indistinguishable from a real API token.
3326    let body = if let Some(rest) = token.strip_prefix("sha") {
3327        // rest starts with digits followed by '-'
3328        let dash = rest.find('-').unwrap_or(rest.len());
3329        let digits = &rest[..dash];
3330        if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) && dash < rest.len() {
3331            &rest[dash + 1..] // everything after "sha<digits>-"
3332        } else {
3333            return false; // no valid sha<N>- prefix → not a known content hash
3334        }
3335    } else {
3336        return false; // no sha prefix → not allowlisted
3337    };
3338    // Strip optional padding (at most 2 `=`).
3339    let stripped = body.trim_end_matches('=');
3340    let pad_removed = body.len() - stripped.len();
3341    if pad_removed > 2 {
3342        return false;
3343    }
3344    // Accept only SHA-family content-hash lengths (43, 64, 86–88 chars unpadded).
3345    let n = stripped.len();
3346    if n != 43 && n != 64 && !(86..=88).contains(&n) {
3347        return false;
3348    }
3349    // Accept both standard-base64 and URL-safe-base64 alphabets.
3350    stripped
3351        .bytes()
3352        .all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/' || b == b'-' || b == b'_')
3353}
3354
3355/// Structural separators that gate entry into [`is_structured_identifier`]
3356/// (rule 1: the token must contain at least one of these). The actual run
3357/// decomposition (rule 2) splits on every non-alphanumeric character, not
3358/// just these four — see the doc comment on `is_structured_identifier`.
3359const STRUCTURAL_SEPARATORS: [char; 4] = ['/', '-', '_', '.'];
3360
3361/// Largest length a single path/branch/identifier segment (a "run" between
3362/// separators) may have and still be considered word-shaped.
3363const MAX_RUN_LEN: usize = 24;
3364
3365/// Runs whose letter portion is at or below this length skip the
3366/// case-transition-density check: density is not a meaningful signal on very
3367/// short runs (e.g. `R1`, `v2`, `ADR`).
3368const DENSITY_EXEMPT_LETTER_LEN: usize = 4;
3369
3370/// Maximum case-transition density (transitions divided by letter_count - 1)
3371/// a run's letter portion may have and still be considered word-shaped.
3372const MAX_CASE_TRANSITION_DENSITY: f64 = 0.3;
3373
3374/// Returns `true` when `token` is shaped like a file path, branch name, or
3375/// other structured identifier rather than a high-entropy secret (word-shaped
3376/// runs separated by `/`, `-`, `_`, `.`). Exempts from the entropy heuristic
3377/// ONLY outside trigger context — see the module doc and
3378/// `docs/api/secret_gate.md#is_structured_identifier` for the run-shape criteria.
3379fn is_structured_identifier(token: &str) -> bool {
3380    if !token.contains(|c: char| STRUCTURAL_SEPARATORS.contains(&c)) {
3381        return false;
3382    }
3383    let runs: Vec<&str> = token
3384        .split(|c: char| !c.is_ascii_alphanumeric())
3385        .filter(|r| !r.is_empty())
3386        .collect();
3387    runs.len() >= 2 && runs.iter().all(|run| is_word_shaped_run(run))
3388}
3389
3390/// A single run (segment between structural separators) is word-shaped when
3391/// it matches `[A-Za-z]+[0-9]*` or `[0-9]+`, is at most [`MAX_RUN_LEN`] chars,
3392/// and (for the letters-then-digits form) its letter portion has a low
3393/// case-transition density.
3394fn is_word_shaped_run(run: &str) -> bool {
3395    if run.is_empty() || run.len() > MAX_RUN_LEN {
3396        return false;
3397    }
3398    let bytes = run.as_bytes();
3399    if bytes.iter().all(|b| b.is_ascii_digit()) {
3400        return true;
3401    }
3402    let letter_end = bytes
3403        .iter()
3404        .position(|b| !b.is_ascii_alphabetic())
3405        .unwrap_or(bytes.len());
3406    // A run that does not start with a letter, and is not pure digits (ruled
3407    // out above), mixes digits and letters in a shape other than
3408    // letters-then-digits — not word-shaped.
3409    if letter_end == 0 {
3410        return false;
3411    }
3412    // Everything after the leading letters must be digits only (no further
3413    // letters), else the run is not the `[A-Za-z]+[0-9]*` shape.
3414    if !bytes[letter_end..].iter().all(|b| b.is_ascii_digit()) {
3415        return false;
3416    }
3417    case_transition_density_ok(&run[..letter_end])
3418}
3419
3420/// `true` when the case-transition density of `letters` (an all-ASCII-letter
3421/// string) is at or below [`MAX_CASE_TRANSITION_DENSITY`]. A transition is an
3422/// adjacent letter pair where one side is uppercase and the other is not.
3423/// Runs with few enough letters pass automatically (see
3424/// [`DENSITY_EXEMPT_LETTER_LEN`]) since density is noisy on short strings.
3425fn case_transition_density_ok(letters: &str) -> bool {
3426    let chars: Vec<char> = letters.chars().collect();
3427    if chars.len() <= DENSITY_EXEMPT_LETTER_LEN {
3428        return true;
3429    }
3430    let transitions = chars
3431        .windows(2)
3432        .filter(|w| w[0].is_ascii_uppercase() != w[1].is_ascii_uppercase())
3433        .count();
3434    let density = transitions as f64 / (chars.len() - 1) as f64;
3435    density <= MAX_CASE_TRANSITION_DENSITY
3436}
3437
3438/// `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`
3439fn is_uuid_canonical(s: &str) -> bool {
3440    let b = s.as_bytes();
3441    if b.len() != 36 {
3442        return false;
3443    }
3444    b[8] == b'-'
3445        && b[13] == b'-'
3446        && b[18] == b'-'
3447        && b[23] == b'-'
3448        && b[..8].iter().all(|c| c.is_ascii_hexdigit())
3449        && b[9..13].iter().all(|c| c.is_ascii_hexdigit())
3450        && b[14..18].iter().all(|c| c.is_ascii_hexdigit())
3451        && b[19..23].iter().all(|c| c.is_ascii_hexdigit())
3452        && b[24..].iter().all(|c| c.is_ascii_hexdigit())
3453}
3454
3455/// Strip common wrapping characters (`"`, `'`, `` ` ``, `:`, `=`) from both ends.
3456fn strip_delimiters(s: &str) -> &str {
3457    s.trim_matches(|c| matches!(c, '"' | '\'' | '`' | ':' | '=' | ',' | ';'))
3458}
3459
3460/// Strip `{}()[]"'.,;` from both ends of `s`, repeatedly (JSON nests one
3461/// wrapper inside another).
3462fn strip_wrappers(s: &str) -> &str {
3463    s.trim_matches(|c: char| {
3464        matches!(
3465            c,
3466            '{' | '}' | '(' | ')' | '[' | ']' | '"' | '\'' | '`' | '.' | ',' | ';'
3467        )
3468    })
3469}
3470
3471fn wrapper_strip_repeated(token: &str) -> &str {
3472    let mut cur = token;
3473    loop {
3474        let next = strip_wrappers(cur);
3475        if next == cur {
3476            return cur;
3477        }
3478        cur = next;
3479    }
3480}
3481
3482/// Yields every candidate value an assignment/wrapper-glued token could
3483/// contain, for the near-trigger UUID/content-hash exact-shape checks only.
3484/// See `docs/api/secret_gate.md#value_candidates` for why every `=`/`:` suffix
3485/// must be tried rather than just the first or last.
3486fn value_candidates(token: &str) -> impl Iterator<Item = &str> {
3487    let cur = wrapper_strip_repeated(token);
3488    std::iter::once(cur).chain(cur.char_indices().filter_map(move |(i, c)| {
3489        if c == '=' || c == ':' {
3490            let after = strip_wrappers(&cur[i + c.len_utf8()..]);
3491            if !after.is_empty() {
3492                return Some(after);
3493            }
3494        }
3495        None
3496    }))
3497}
3498
3499// ─── Utilities ───────────────────────────────────────────────────────────────
3500
3501/// Extract a contiguous token (non-whitespace chars) starting at the beginning of `s`.
3502fn extract_token(s: &str) -> &str {
3503    let end = s
3504        .find(|c: char| c.is_whitespace() || c == '\n' || c == '\r')
3505        .unwrap_or(s.len());
3506    &s[..end]
3507}
3508
3509/// Shannon entropy in bits per character.
3510///
3511/// H = -∑ p_i log2(p_i)
3512fn shannon_entropy(bytes: &[u8]) -> f64 {
3513    if bytes.is_empty() {
3514        return 0.0;
3515    }
3516    let mut counts = [0u32; 256];
3517    for &b in bytes {
3518        counts[b as usize] += 1;
3519    }
3520    let len = bytes.len() as f64;
3521    counts
3522        .iter()
3523        .filter(|&&c| c > 0)
3524        .map(|&c| {
3525            let p = c as f64 / len;
3526            -p * p.log2()
3527        })
3528        .sum()
3529}
3530
3531/// Build a `SecretMatch` from a detector name and the candidate string.
3532///
3533/// The masked excerpt is: first 6 chars + "..." + total length.
3534/// Never includes more than 6 chars of the actual value.
3535fn build_match(detector: &'static str, candidate: &str) -> SecretMatch {
3536    let chars: Vec<char> = candidate.chars().collect();
3537    let preview: String = chars.iter().take(6).collect();
3538    let masked = format!("{}...{}chars", preview, chars.len());
3539    SecretMatch {
3540        detector,
3541        trigger: None,
3542        masked,
3543        location: None,
3544    }
3545}
3546
3547#[cfg(test)]
3548mod issue_2655_tests {
3549    use super::*;
3550
3551    fn opaque_fixture() -> String {
3552        [
3553            "ABCDEFGHIJKLMNOPQRSTUVWX",
3554            "abcdefghijklmnopqrstuvwxyz",
3555            "0123456789",
3556        ]
3557        .concat()
3558    }
3559
3560    #[test]
3561    fn issue_2655_compact_siblings_keep_all_entropy_shapes_member_local() {
3562        let hex = "0123456789abcdef".repeat(4);
3563        let opaque = opaque_fixture();
3564        let members = [
3565            format!("digest:{hex}"),
3566            "id:550e8400-e29b-41d4-a716-446655440000".to_owned(),
3567            format!("sum:sha256-{}=", "A".repeat(43)),
3568            format!("opaque:{opaque}"),
3569            format!("path:vault/{opaque}/rotate.md"),
3570            format!("digest:{}/{}", &hex[..16], &hex[16..32]),
3571        ];
3572        for separator in [',', ';', '&'] {
3573            for member in &members {
3574                for content in [
3575                    format!("a_secret:x{separator}{member}"),
3576                    format!("{member}{separator}a_secret:x"),
3577                ] {
3578                    assert!(check(&content).is_ok(), "{content}: {:?}", scan(&content));
3579                    assert_eq!(mask_secrets(&content), content);
3580                }
3581            }
3582        }
3583    }
3584
3585    #[test]
3586    fn issue_2655_quote_comma_is_an_inline_member_boundary() {
3587        let hex = "0123456789abcdef".repeat(4);
3588        for content in [
3589            format!(r#"{{"a_secret":"x","digest":"{hex}"}}"#),
3590            format!(r#"{{"digest":"{hex}","a_secret":"x"}}"#),
3591        ] {
3592            assert!(check(&content).is_ok(), "{content}: {:?}", scan(&content));
3593            assert_eq!(mask_secrets(&content), content);
3594        }
3595    }
3596
3597    #[test]
3598    fn issue_2655_external_window_keeps_short_spaced_refusals_and_long_controls() {
3599        let hex = "0123456789abcdef".repeat(4);
3600        for content in [
3601            format!("a_secret:x digest:{hex}"),
3602            format!("digest:{hex} a_secret:x"),
3603        ] {
3604            assert!(
3605                check(&content).is_err(),
3606                "short external context: {content}"
3607            );
3608        }
3609        let gap = " documentation".repeat(50);
3610        assert!(gap.len() > 485);
3611        for content in [
3612            format!("a_secret:x{gap} digest:{hex}"),
3613            format!("digest:{hex}{gap} a_secret:x"),
3614        ] {
3615            assert!(check(&content).is_ok(), "long external context: {content}");
3616            assert_eq!(mask_secrets(&content), content);
3617        }
3618    }
3619
3620    #[test]
3621    fn issue_2655_credential_assignments_and_nested_carriers_stay_refused() {
3622        let id = "550e8400-e29b-41d4-a716-446655440000";
3623        let hex = "0123456789abcdef".repeat(4);
3624        let hash = format!("sha256-{}=", "A".repeat(43));
3625        let opaque = opaque_fixture();
3626        for (content, value) in [
3627            (format!("api_key={id}"), id),
3628            (format!("secret={hex}"), hex.as_str()),
3629            (format!("api_key=label={id}"), id),
3630            (format!("secret=label={hash}"), hash.as_str()),
3631            (format!("secret:label={hash}"), hash.as_str()),
3632            (format!("payload=api_key={id}"), id),
3633            (format!("payload=auth={opaque}"), opaque.as_str()),
3634            (format!("secret=vault/{opaque}/rotate.md"), opaque.as_str()),
3635        ] {
3636            assert!(check(&content).is_err(), "{content}");
3637            assert!(!mask_secrets(&content).contains(value), "{content}");
3638        }
3639    }
3640
3641    #[test]
3642    fn issue_2655_later_assignment_does_not_label_an_earlier_value() {
3643        let id = "550e8400-e29b-41d4-a716-446655440000";
3644        let hex = "0123456789abcdef".repeat(4);
3645        let opaque = opaque_fixture();
3646        for value in [id, hex.as_str(), opaque.as_str()] {
3647            let content = format!("record={value}:a_secret=x");
3648            assert!(check(&content).is_ok(), "{content}: {:?}", scan(&content));
3649            assert_eq!(mask_secrets(&content), content);
3650        }
3651    }
3652
3653    #[test]
3654    fn issue_2655_refusal_reports_the_matched_members_trigger() {
3655        let id = "550e8400-e29b-41d4-a716-446655440000";
3656        let opaque = opaque_fixture();
3657        for (content, trigger) in [
3658            (format!("a_secret:x,api_key={id}"), "api_key"),
3659            (format!("api_key:x;auth={opaque}"), "auth"),
3660            (format!("secret:x&payload=auth={opaque}"), "auth"),
3661        ] {
3662            assert_eq!(
3663                scan(&content).and_then(|matched| matched.trigger),
3664                Some(trigger),
3665                "{content}"
3666            );
3667        }
3668    }
3669
3670    #[test]
3671    fn issue_2655_masking_continues_through_multiple_members() {
3672        let id = "550e8400-e29b-41d4-a716-446655440000";
3673        let hex = "0123456789abcdef".repeat(4);
3674        let benign = "fedcba9876543210".repeat(4);
3675        let opaque = opaque_fixture();
3676        for separator in [',', ';', '&'] {
3677            let content = format!("api_key={id}{separator}digest:{benign}{separator}secret={hex}{separator}auth={opaque}");
3678            ENTROPY_TOKENIZATION_COUNT.with(|count| count.set(0));
3679            let masked = mask_secrets(&content);
3680            assert_eq!(ENTROPY_TOKENIZATION_COUNT.with(|count| count.get()), 1);
3681            assert_eq!(masked.matches(REDACTION_MARKER).count(), 3, "{masked}");
3682            for value in [id, hex.as_str(), opaque.as_str()] {
3683                assert!(!masked.contains(value), "{masked}");
3684            }
3685            assert!(masked.contains(&format!("digest:{benign}")), "{masked}");
3686        }
3687    }
3688
3689    #[test]
3690    fn issue_2655_masking_continues_within_a_governed_member() {
3691        let first = "0123456789abcdef".repeat(2);
3692        let second = "fedcba9876543210".repeat(2);
3693        let content = format!("secret={first}/{second}");
3694        let masked = mask_secrets(&content);
3695        assert_eq!(masked.matches(REDACTION_MARKER).count(), 2, "{masked}");
3696        assert!(!masked.contains(&first));
3697        assert!(!masked.contains(&second));
3698    }
3699
3700    #[test]
3701    fn issue_2655_known_prefix_under_a_benign_member_stays_refused() {
3702        let fake = format!("ghp_{}", "A".repeat(36));
3703        let content = format!(r#"{{"a_secret":"x","digest":"{fake}"}}"#);
3704        assert_eq!(
3705            scan(&content).map(|matched| matched.detector),
3706            Some("github-token")
3707        );
3708        assert!(!mask_secrets(&content).contains(&fake));
3709    }
3710
3711    #[test]
3712    fn issue_2655_path_and_revision_guards_do_not_read_sibling_labels() {
3713        let revision = "0123456789abcdef0123456789abcdef01234567";
3714        for member in [
3715            format!("rev:{revision}"),
3716            format!("source=https://example.test/repo/commit/{revision}"),
3717            "path=docs/platform/credentials-and-authorization-architecture.md".to_owned(),
3718        ] {
3719            let content = format!("a_secret:x,{member}");
3720            assert!(check(&content).is_ok(), "{content}: {:?}", scan(&content));
3721            assert_eq!(mask_secrets(&content), content);
3722        }
3723        let credential = format!("secret=https://example.test/repo/commit/{revision}");
3724        assert!(check(&credential).is_err());
3725        assert!(!mask_secrets(&credential).contains(revision));
3726    }
3727
3728    #[test]
3729    fn issue_2655_inline_assignment_keeps_unicode_bridge_reconstruction() {
3730        // The original assignment token clears the existing 24-byte floor;
3731        // the two bare fragments remain below it and reconstruct to 32 hex.
3732        let first = "0123456789abcdef01";
3733        let second = "fedcba98765432";
3734        let content = format!("secret={first}\u{200b}{second}");
3735        assert!(check(&content).is_err());
3736        let masked = mask_secrets(&content);
3737        assert!(!masked.contains(first));
3738        assert!(!masked.contains(second));
3739    }
3740
3741    #[test]
3742    fn issue_2655_underscore_carriers_keep_nested_and_padded_forms() {
3743        let opaque = opaque_fixture();
3744        for prefix in ["session_secret_", "payload=session_secret_"] {
3745            for padding in ["", "=", "=="] {
3746                let content = format!("{prefix}{opaque}{padding}");
3747                assert!(check(&content).is_err(), "{content}");
3748                assert!(!mask_secrets(&content).contains(&opaque), "{content}");
3749            }
3750        }
3751    }
3752
3753    #[test]
3754    fn issue_2655_inline_bridge_uses_its_final_member() {
3755        let first = "0123456789abcdef01";
3756        let second = "fedcba98765432";
3757        let benign = "fedcba9876543210".repeat(4);
3758        for prefix in ["a:x".to_owned(), format!("digest:{benign}")] {
3759            let content = format!("{prefix},secret={first}\u{200b}{second}");
3760            assert!(check(&content).is_err(), "{content}");
3761            let masked = mask_secrets(&content);
3762            assert!(masked.starts_with(&prefix), "{masked}");
3763            assert!(!masked.contains(first), "{masked}");
3764            assert!(!masked.ends_with(second), "{masked}");
3765        }
3766    }
3767
3768    #[test]
3769    fn issue_2655_inline_bridge_stops_at_sibling_boundaries() {
3770        let first = "0123456789abcdef01";
3771        let second = "fedcba98765432";
3772        let benign = "fedcba9876543210".repeat(4);
3773        for content in [
3774            format!("secret=x,digest:{first}\u{200b}{second}"),
3775            format!("secret={first},digest:x\u{200b}{second}"),
3776            format!("secret={first},digest:{benign}\u{200b}{second}"),
3777            format!("{first}\u{200b}digest:x,secret={second}"),
3778        ] {
3779            assert!(check(&content).is_ok(), "{content}: {:?}", scan(&content));
3780            assert_eq!(mask_secrets(&content), content);
3781        }
3782    }
3783
3784    #[test]
3785    fn issue_2655_mask_budget_charges_compact_token_prefix_revisits() {
3786        let hex = "0123456789abcdef".repeat(2);
3787        let tail = format!(",secret={hex}").repeat(200);
3788        let content = format!("padding:{}{tail}", "a".repeat(900_000));
3789        let (spans, work) = collect_mask_spans(&content);
3790        assert!(
3791            work >= content.len() * 2,
3792            "each continuation revisits the full compact token"
3793        );
3794        assert!(work <= MAX_MASK_SCAN_WORK_BYTES);
3795        assert_eq!(spans.last().map(|span| span.1), Some(content.len()));
3796        let masked = mask_secrets(&content);
3797        assert!(!masked.contains(&hex));
3798        assert!(masked.matches(REDACTION_MARKER).count() < 200);
3799        assert!(masked.ends_with(REDACTION_MARKER));
3800    }
3801
3802    #[test]
3803    fn issue_2655_later_assignment_bridge_preserves_earlier_record_value() {
3804        let benign = "0123456789abcdef".repeat(4);
3805        let first = "a1b2c3d4e5f6071829";
3806        let second = "30415263748596";
3807        let prefix = format!("record={benign}:a_secret=");
3808        let content = format!("{prefix}{first}\u{200b}{second}");
3809        assert_eq!(
3810            scan(&content).and_then(|matched| matched.trigger),
3811            Some("secret")
3812        );
3813        let masked = mask_secrets(&content);
3814        assert!(masked.starts_with(&prefix), "{masked}");
3815        assert!(!masked.contains(first), "{masked}");
3816        assert!(!masked.contains(second), "{masked}");
3817        assert_eq!(masked.matches(REDACTION_MARKER).count(), 2, "{masked}");
3818    }
3819}
3820
3821// ─── Tests ───────────────────────────────────────────────────────────────────
3822
3823#[cfg(test)]
3824mod tests {
3825    use super::*;
3826
3827    fn github_fine_grained_pat_fixture() -> String {
3828        format!("github_pat_{}", "A".repeat(82))
3829    }
3830
3831    fn openai_project_key_fixture() -> String {
3832        format!("sk-proj-{}", "A".repeat(80))
3833    }
3834
3835    fn anthropic_api_key_fixture() -> String {
3836        format!("sk-ant-api03-{}AA", "A".repeat(93))
3837    }
3838
3839    #[test]
3840    fn blocks_aws_akia() {
3841        // FAKE key: prefix is real shape, 16-char suffix invented.
3842        let fake = "AKIAFAKEKEY1234567890";
3843        assert!(scan(fake).is_some(), "AKIA must be caught");
3844        let m = scan(fake).unwrap();
3845        assert_eq!(m.detector, "aws-access-key-id");
3846        // Masked excerpt must not echo the full key.
3847        assert!(
3848            !m.masked.contains("FAKEKEY1234567890"),
3849            "must not echo the secret: {}",
3850            m.masked
3851        );
3852    }
3853
3854    #[test]
3855    fn blocks_aws_asia() {
3856        let fake = "ASIAFAKEKEY00000000000";
3857        let m = scan(fake);
3858        assert!(m.is_some(), "ASIA must be caught");
3859        assert_eq!(m.unwrap().detector, "aws-access-key-id");
3860    }
3861
3862    #[test]
3863    fn blocks_github_ghp() {
3864        // 36 chars total to pass min_len.
3865        let fake = "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
3866        assert!(scan(fake).is_some(), "ghp_ must be caught");
3867    }
3868
3869    #[test]
3870    fn blocks_github_gho() {
3871        let fake = "gho_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
3872        assert!(scan(fake).is_some(), "gho_ must be caught");
3873    }
3874
3875    #[test]
3876    fn blocks_github_pat() {
3877        let fake = github_fine_grained_pat_fixture();
3878        assert!(scan(&fake).is_some(), "github_pat_ must be caught");
3879    }
3880
3881    #[test]
3882    fn blocks_openai_sk() {
3883        let fake = "sk-aaaaaabbbbbbccccccddddddeeeeeeffffgg";
3884        assert!(scan(fake).is_some(), "sk- must be caught");
3885    }
3886
3887    #[test]
3888    fn blocks_anthropic_sk_ant() {
3889        let fake = anthropic_api_key_fixture();
3890        assert!(scan(&fake).is_some(), "sk-ant- must be caught");
3891        assert_eq!(scan(&fake).unwrap().detector, "anthropic-api-key");
3892    }
3893
3894    #[test]
3895    fn vendor_prefix_minimums_allow_short_documentation_fragments() {
3896        let fragments = [
3897            "github_pat_[A-Za-z0-9_]{82}".to_owned(),
3898            "sk-proj-[A-Za-z0-9_-]{80,}".to_owned(),
3899            "sk-ant-api03-[A-Za-z0-9_-]{93}AA".to_owned(),
3900            "github_pat_FAKE_CANARY".to_owned(),
3901            "sk-ant-api03-FAKE_CANARY".to_owned(),
3902            format!("github_pat_{}", "A".repeat(81)),
3903            format!("sk-proj-{}", "A".repeat(79)),
3904            format!("sk-ant-api03-{}AA", "A".repeat(92)),
3905        ];
3906
3907        for fragment in fragments {
3908            assert!(
3909                check(&fragment).is_ok(),
3910                "short documentation fragment must pass: {fragment}, got {:?}",
3911                scan(&fragment)
3912            );
3913        }
3914    }
3915
3916    #[test]
3917    fn vendor_prefix_minimums_keep_plausible_keys_blocked() {
3918        let github = github_fine_grained_pat_fixture();
3919        let openai = openai_project_key_fixture();
3920        let anthropic = anthropic_api_key_fixture();
3921
3922        for (candidate, detector) in [
3923            (github.as_str(), "github-token"),
3924            (openai.as_str(), "openai-api-key"),
3925            (anthropic.as_str(), "anthropic-api-key"),
3926        ] {
3927            let matched = scan(candidate).expect("plausible-length vendor key must remain blocked");
3928            assert_eq!(matched.detector, detector);
3929        }
3930    }
3931
3932    #[test]
3933    fn short_specialized_sk_prefix_does_not_hide_later_generic_key() {
3934        let short_vendor_fragment = format!("sk-proj-{}", "A".repeat(79));
3935        let generic_key = format!("sk-{}", "A1".repeat(20));
3936        let content = format!("{short_vendor_fragment} {generic_key}");
3937
3938        let (matched, detector) =
3939            scan_match(&content).expect("later generic sk key must remain detectable");
3940        assert_eq!(matched, generic_key);
3941        assert_eq!(detector, "openai-api-key");
3942    }
3943
3944    #[test]
3945    fn glued_short_specialized_prefix_does_not_hide_later_generic_key() {
3946        let generic_key = format!("sk-{}A", "A1".repeat(21));
3947        let content = format!("sk-proj-X,{generic_key}");
3948
3949        assert!(
3950            check(&content).is_err(),
3951            "a generic key after a rejected vendor prefix must remain blocked"
3952        );
3953        let masked = mask_secrets(&content).into_owned();
3954        assert!(
3955            !masked.contains(&generic_key),
3956            "the later generic key must not survive masking: {masked}"
3957        );
3958        assert!(
3959            masked.contains(REDACTION_MARKER),
3960            "the later generic key must be replaced: {masked}"
3961        );
3962    }
3963
3964    #[test]
3965    fn blocks_stripe_live() {
3966        let fake = "sk_live_FAKESTRIPE0000000000000"; // gitleaks:allow
3967        assert!(scan(fake).is_some(), "sk_live_ must be caught");
3968        assert_eq!(scan(fake).unwrap().detector, "stripe-secret-key");
3969    }
3970
3971    #[test]
3972    fn blocks_stripe_restricted() {
3973        let fake = "rk_live_FAKESTRIPE0000000000000"; // gitleaks:allow
3974        assert!(scan(fake).is_some(), "rk_live_ must be caught");
3975        assert_eq!(scan(fake).unwrap().detector, "stripe-restricted-key");
3976    }
3977
3978    #[test]
3979    fn blocks_fly_flyv1() {
3980        let fake = "FlyV1 FAKEFLYTOKEN000000000000000000";
3981        assert!(scan(fake).is_some(), "FlyV1 must be caught");
3982        assert_eq!(scan(fake).unwrap().detector, "fly-token");
3983    }
3984
3985    #[test]
3986    fn short_flyv1_marker_does_not_hide_later_token() {
3987        let content = "FlyV1 ab FlyV1 FAKEFLYTOKEN000000000000000000";
3988        let (matched, detector) = scan_match(content).expect("later FlyV1 token must be detected");
3989        assert_eq!(detector, "fly-token");
3990        assert_eq!(matched, "FlyV1 FAKEFLYTOKEN000000000000000000");
3991        assert_eq!(
3992            matched.as_ptr() as usize - content.as_ptr() as usize,
3993            content.rfind("FlyV1 ").unwrap()
3994        );
3995        assert_eq!(scan(content).unwrap().detector, "fly-token");
3996    }
3997
3998    #[test]
3999    fn non_boundary_flyv1_marker_does_not_hide_later_token() {
4000        for content in [
4001            "xFlyV1 abc FlyV1 FAKEFLYTOKEN000000000000000000",
4002            "xFlyV1 FAKE FlyV1 FAKEFLYTOKEN000000000000000000",
4003        ] {
4004            let (matched, detector) =
4005                scan_match(content).expect("later FlyV1 token must be detected");
4006            assert_eq!(detector, "fly-token");
4007            assert_eq!(matched, "FlyV1 FAKEFLYTOKEN000000000000000000");
4008            assert_eq!(
4009                matched.as_ptr() as usize - content.as_ptr() as usize,
4010                content.rfind("FlyV1 ").unwrap()
4011            );
4012            assert_eq!(scan(content).unwrap().detector, "fly-token");
4013        }
4014    }
4015
4016    #[test]
4017    fn blocks_fly_fm2() {
4018        let fake = "fm2_FAKEFLYTOKEN00000000000000000";
4019        assert!(scan(fake).is_some(), "fm2_ must be caught");
4020        assert_eq!(scan(fake).unwrap().detector, "fly-token");
4021    }
4022
4023    #[test]
4024    fn blocks_vercel_token() {
4025        let fake = "vercel_FAKETOKEN00000000000000000";
4026        assert!(scan(fake).is_some(), "vercel_ must be caught");
4027        assert_eq!(scan(fake).unwrap().detector, "vercel-token");
4028    }
4029
4030    #[test]
4031    fn prefix_detectors_allow_lowercase_source_filenames() {
4032        for &(_, needle, min_len) in PREFIX_DETECTORS {
4033            let padding_len = min_len.saturating_sub(needle.len() + "provider_.py".len());
4034            let filename_body = format!("provider_{}.py", "a".repeat(padding_len));
4035            let candidate = format!("{needle}{filename_body}");
4036            assert!(
4037                candidate.len() >= min_len,
4038                "negative control must exercise {needle}'s length tier"
4039            );
4040            assert!(
4041                find_prefix_token(&candidate, needle, min_len).is_none(),
4042                "lowercase source filename must not match prefix {needle}: {candidate}"
4043            );
4044            assert!(
4045                check(&candidate).is_ok(),
4046                "lowercase source filename must pass the canonical scanner: {candidate}, got {:?}",
4047                scan(&candidate)
4048            );
4049        }
4050    }
4051
4052    #[test]
4053    fn prefix_detectors_keep_value_shaped_payloads_with_source_suffixes() {
4054        for &(_, needle, min_len) in PREFIX_DETECTORS {
4055            let required_payload_len = min_len.saturating_sub(needle.len()).max(2);
4056            let value_payload = "A1".repeat(required_payload_len.div_ceil(2));
4057            let candidate = format!("{needle}{value_payload}.py");
4058            assert!(
4059                find_prefix_token(&candidate, needle, min_len).is_some(),
4060                "uppercase/digit value evidence must preserve prefix {needle}: {candidate}"
4061            );
4062        }
4063    }
4064
4065    #[test]
4066    fn prefix_detectors_keep_lowercase_single_run_payloads_with_source_suffixes() {
4067        for &(_, needle, min_len) in PREFIX_DETECTORS {
4068            let required_payload_len = min_len.saturating_sub(needle.len()).max(24);
4069            let value_payload = "a".repeat(required_payload_len);
4070            let candidate = format!("{needle}{value_payload}.py");
4071            assert!(
4072                find_prefix_token(&candidate, needle, min_len).is_some(),
4073                "an extension alone must not exempt prefix {needle}: {candidate}"
4074            );
4075        }
4076    }
4077
4078    #[test]
4079    fn allows_vercel_prefixed_source_filename_on_every_scanner_surface() {
4080        let content = "review `vercel_deployment_monitoring_adapter.py` before release";
4081
4082        assert!(check(content).is_ok(), "write gate must allow filename");
4083        assert!(scan(content).is_none(), "scanner must not report filename");
4084        assert_eq!(
4085            mask_secrets(content).as_ref(),
4086            content,
4087            "masking surface must preserve the filename byte-for-byte"
4088        );
4089    }
4090
4091    #[test]
4092    fn admits_source_citation_line_reference_and_still_blocks_random_payload() {
4093        // Arm 1 — admitted: a file-and-line citation, both the single-line and
4094        // range forms, across two different source extensions, including a
4095        // path-bearing stem (`/` is in the allowed stem punctuation set).
4096        let admitted = [
4097            "vercel_deployment_monitor.py:412",
4098            "vercel_deployment_monitor.py:412-418",
4099            "vercel_src/runtime/checkpoint_loader.rs:97-103",
4100        ];
4101        for content in admitted {
4102            assert!(
4103                find_prefix_token(content, "vercel_", 20).is_none(),
4104                "file-and-line citation must not be flagged as a credential: {content}"
4105            );
4106            assert!(
4107                check(content).is_ok(),
4108                "file-and-line citation must pass the write gate: {content}, got {:?}",
4109                scan(content)
4110            );
4111        }
4112
4113        // Arm 2 — must-FAIL control, in the SAME test as arm 1: a provider
4114        // prefix followed by an ordinary random-looking value is still a
4115        // credential. This is the arm a loose "trailing digits anywhere"
4116        // suffix match would silently stop catching.
4117        let random_payload = "vercel_aB3xQ9mK7pL2wZ8nR4tY6uV1"; // gitleaks:allow
4118        assert_eq!(random_payload.len() - "vercel_".len(), 24);
4119        assert!(
4120            find_prefix_token(random_payload, "vercel_", 20).is_some(),
4121            "random payload after the provider prefix must still be flagged: {random_payload}"
4122        );
4123        assert_eq!(
4124            scan(random_payload).map(|matched| matched.detector),
4125            Some("vercel-token"),
4126            "random-looking payload after the provider prefix must still be scanned as a credential"
4127        );
4128    }
4129
4130    #[test]
4131    fn source_citation_line_reference_boundary_arms_still_refused() {
4132        let needle = "vercel_";
4133        let cases = [
4134            "vercel_deployment_monitor.py:4a2", // non-digit byte in the run
4135            "vercel_deployment_monitor.py:12-", // empty second digit run
4136            "vercel_deployment_monitor.py:12-34-56", // more than one '-'
4137            // Refused by the SEPARATOR predicate, not the digit one: this stem
4138            // carries no `_`, `-`, `/` or `.`. A real shape, but it cannot
4139            // isolate the all-lowercase rule.
4140            "vercel_deployment2monitor.py:412",
4141            // Digit inside the stem WITH a separator present, so the
4142            // all-lowercase predicate is the only thing refusing it. This is
4143            // the isolating arm for that predicate: loosen it to tolerate
4144            // digits and this case alone is admitted.
4145            "vercel_deployment2_monitor.py:412",
4146        ];
4147        for token in cases {
4148            assert!(
4149                !is_filename_shaped_prefix_match(token, needle),
4150                "must still be refused: {token}"
4151            );
4152        }
4153
4154        // A bare trailing colon is NOT a boundary case of the line-reference
4155        // grammar; it is sentence punctuation, and the generic trim has always
4156        // removed it before this function looks at anything. It was admitted
4157        // before line references were understood here and must stay admitted:
4158        // this change widens what the carve-out accepts and narrows nothing.
4159        // Without this arm, a later reading of the boundary list above would
4160        // conclude the colon belongs in the grammar and quietly turn a prose
4161        // citation into a refusal.
4162        assert!(
4163            is_filename_shaped_prefix_match("vercel_deployment_monitor.py:", needle),
4164            "a trailing colon is prose punctuation and was always trimmed"
4165        );
4166    }
4167
4168    #[test]
4169    fn blocks_slack_xoxb() {
4170        let fake = "xoxb-FAKE-SLACKTOKEN-000000000000000000000000";
4171        assert!(scan(fake).is_some(), "xoxb- must be caught");
4172        assert_eq!(scan(fake).unwrap().detector, "slack-token");
4173    }
4174
4175    #[test]
4176    fn blocks_pem_private_key() {
4177        // Split the header so the literal detector-trigger string is not present
4178        // verbatim in source — pre-commit's detect-private-key hook would fire.
4179        // The gate detects it at runtime because scan() sees the assembled string.
4180        let header = ["-----BEGIN RSA", " PRIVATE KEY-----"].concat(); // gitleaks:allow
4181        let fake = format!("{}\nMIIEo\u{2026}\n-----END RSA PRIVATE KEY-----", header);
4182        assert!(scan(&fake).is_some(), "PEM private key must be caught");
4183        assert_eq!(scan(&fake).unwrap().detector, "pem-private-key");
4184    }
4185
4186    #[test]
4187    fn blocks_pem_ec_private_key() {
4188        let header = ["-----BEGIN EC", " PRIVATE KEY-----"].concat(); // gitleaks:allow
4189        let fake = format!("{}\nMHQCAQEE\u{2026}\n-----END EC PRIVATE KEY-----", header);
4190        assert!(scan(&fake).is_some(), "EC PEM must be caught");
4191    }
4192
4193    #[test]
4194    fn pem_header_alone_is_a_mention_not_a_key() {
4195        // A documentation page names the header label with no END marker and
4196        // no base64 under it: the format is mentioned, no key is present.
4197        let header = ["-----BEGIN RSA", " PRIVATE KEY-----"].concat(); // gitleaks:allow
4198        let doc = format!(
4199            "A PEM private key file starts with the line `{}` and the key\n\
4200             material follows it on wrapped lines. Keep such files out of chat.\n",
4201            header
4202        );
4203        assert!(
4204            scan(&doc).is_none(),
4205            "a header with no body must not be reported as a key"
4206        );
4207        // Positive control on the same predicate: the same header with a
4208        // matching END marker is a block, however short its body.
4209        let block = format!("{}\nMIIEo\u{2026}\n-----END RSA PRIVATE KEY-----", header);
4210        assert_eq!(scan(&block).unwrap().detector, "pem-private-key");
4211    }
4212
4213    #[test]
4214    fn pem_many_headers_without_bodies_are_accepted() {
4215        // Eight header mentions across a page (the second reported shape),
4216        // none followed by an END marker or a base64 line.
4217        let header = ["-----BEGIN EC", " PRIVATE KEY-----"].concat(); // gitleaks:allow
4218        let mut doc = String::new();
4219        for kind in [
4220            "RSA",
4221            "EC",
4222            "DSA",
4223            "OPENSSH",
4224            "ENCRYPTED",
4225            "",
4226            "PGP",
4227            "X25519",
4228        ] {
4229            doc.push_str(&format!(
4230                "Use `-----BEGIN {} PRIVATE KEY-----` for this key type.\n",
4231                kind
4232            ));
4233        }
4234        assert!(doc.matches("-----BEGIN").count() == 8);
4235        assert!(scan(&doc).is_none(), "{:?}", scan(&doc));
4236        // Same page with one real block appended is refused, and the
4237        // candidate is that block, not the page from the first mention down.
4238        let block = format!("{header}\nMHQCAQEE\u{2026}\n-----END EC PRIVATE KEY-----\n");
4239        let with_block = format!("{doc}{block}");
4240        let m = scan(&with_block).unwrap();
4241        assert_eq!(m.detector, "pem-private-key");
4242        assert!(
4243            m.masked
4244                .ends_with(&format!("...{}chars", block.chars().count())),
4245            "masked: {}",
4246            m.masked
4247        );
4248    }
4249
4250    #[test]
4251    fn pem_header_with_base64_body_and_no_end_marker_is_refused() {
4252        // Key material pasted without its END line is still a key.
4253        let header = ["-----BEGIN RSA", " PRIVATE KEY-----"].concat(); // gitleaks:allow
4254        let body_line = "MIIEowIBAAKCAQEA0Z3VS5JJcds3xfn/ygWyF8PbnGYPYFqHlZ4kUmUqQ7fEd7Uw";
4255        let trailing =
4256            "and then a long paragraph of ordinary prose that is not part of the key block at all";
4257        let text = format!("{header}\n{body_line}\n{body_line}\n\n{trailing}\n");
4258        let m = scan(&text).expect("a header with a base64 body is a key");
4259        assert_eq!(m.detector, "pem-private-key");
4260        // The candidate is bounded to the header plus its base64 lines.
4261        let block_len = header.chars().count() + 1 + (body_line.len() + 1) * 2;
4262        let reported_len: usize = m
4263            .masked
4264            .trim_end_matches("chars")
4265            .rsplit("...")
4266            .next()
4267            .and_then(|s| s.parse().ok())
4268            .expect("masked preview ends in the candidate length");
4269        assert_eq!(reported_len, block_len, "masked: {}", m.masked);
4270        // A short base64 tail alone (below key-block width) is not a body.
4271        let short = format!("{header}\nMIIEowIBAAKCAQEA\n{trailing}\n");
4272        assert!(scan(&short).is_none(), "{:?}", scan(&short));
4273        // After a full-width line, a short final line is the end of the block
4274        // and stays inside the candidate rather than surviving past it.
4275        let tail = "MIIEowIBAAKCAQEA";
4276        let with_tail = format!("{header}\n{body_line}\n{tail}\n{trailing}\n");
4277        let m = scan(&with_tail).expect("a full line plus a short tail is a key");
4278        let tail_len = header.chars().count() + 1 + body_line.len() + 1 + tail.len() + 1;
4279        assert!(
4280            m.masked.ends_with(&format!("...{tail_len}chars")),
4281            "masked: {}",
4282            m.masked
4283        );
4284        let masked = mask_secrets(&with_tail);
4285        assert!(!masked.contains(tail), "tail survived masking: {masked}");
4286    }
4287
4288    #[test]
4289    fn pem_block_inside_serialized_json_is_still_caught_and_a_mention_is_not() {
4290        // A note's properties or a stream payload reach the gate as compact
4291        // JSON, where every newline is the two-character escape. The same
4292        // rules apply on that form.
4293        let header = ["-----BEGIN RSA", " PRIVATE KEY-----"].concat(); // gitleaks:allow
4294        let body_line = "MIIEowIBAAKCAQEA0Z3VS5JJcds3xfn/ygWyF8PbnGYPYFqHlZ4kUmUqQ7fEd7Uw";
4295        let block = serde_json::json!({"payload": format!("{header}\n{body_line}\n{body_line}")});
4296        let serialized = serde_json::to_string(&block).unwrap();
4297        assert!(
4298            serialized.contains("\\n"),
4299            "fixture must carry escaped newlines"
4300        );
4301        let m = scan(&serialized).expect("a key block in serialized JSON is a key");
4302        assert_eq!(m.detector, "pem-private-key");
4303        // Bounded to the block: the closing quote and brace are not part of it.
4304        let block_len = header.chars().count() + 2 + (body_line.len() + 2) + body_line.len();
4305        assert!(
4306            m.masked.ends_with(&format!("...{block_len}chars")),
4307            "masked: {}",
4308            m.masked
4309        );
4310        let with_end = serde_json::json!({
4311            "payload": format!("{header}\nMIIEo\u{2026}\n-----END RSA PRIVATE KEY-----\ntrailing")
4312        });
4313        let serialized = serde_json::to_string(&with_end).unwrap();
4314        assert_eq!(scan(&serialized).unwrap().detector, "pem-private-key");
4315
4316        let mention = serde_json::json!({
4317            "doc": format!("A key file starts with `{header}` and continues on wrapped lines.")
4318        });
4319        let serialized = serde_json::to_string(&mention).unwrap();
4320        assert!(scan(&serialized).is_none(), "{:?}", scan(&serialized));
4321    }
4322
4323    #[test]
4324    fn pem_block_after_an_unrelated_begin_marker_is_still_caught() {
4325        // A certificate header earlier in the text must not hide the key
4326        // block behind it, and the candidate starts at the key header.
4327        let header = ["-----BEGIN RSA", " PRIVATE KEY-----"].concat(); // gitleaks:allow
4328        let text = format!(
4329            "-----BEGIN CERTIFICATE-----\nMIIB\u{2026}\n-----END CERTIFICATE-----\n{}\nMIIEo\u{2026}\n-----END RSA PRIVATE KEY-----\n",
4330            header
4331        );
4332        let m = scan(&text).expect("the key block must be caught");
4333        assert_eq!(m.detector, "pem-private-key");
4334        assert!(
4335            !m.masked.starts_with("-----BEGIN C"),
4336            "candidate must start at the key header: {}",
4337            m.masked
4338        );
4339    }
4340
4341    #[test]
4342    fn blocks_age_secret_key() {
4343        // AGE-SECRET-KEY- followed by 59 base32 chars (Bech32m body).
4344        let fake = "AGE-SECRET-KEY-1QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ";
4345        assert!(scan(fake).is_some(), "AGE-SECRET-KEY- must be caught");
4346        assert_eq!(scan(fake).unwrap().detector, "age-secret-key");
4347    }
4348
4349    #[test]
4350    fn blocks_jwt_triple() {
4351        // Synthetic JWT structure: header.payload.signature (no real key).
4352        let fake = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.FAKE_SIG_XXXXXXXXXXXX"; // gitleaks:allow
4353        assert!(scan(fake).is_some(), "JWT triple must be caught");
4354        assert_eq!(scan(fake).unwrap().detector, "jwt");
4355    }
4356
4357    #[test]
4358    fn blocks_url_userinfo() {
4359        let fake = "postgresql://dbuser:S3cr3tP4ss@db.example.com:5432/mydb";
4360        assert!(scan(fake).is_some(), "URL userinfo must be caught");
4361        assert_eq!(scan(fake).unwrap().detector, "url-userinfo");
4362    }
4363
4364    #[test]
4365    fn url_userinfo_placeholder_refusal_explains_effective_remedies() {
4366        for content in [
4367            "postgresql://dbuser:<PASSWORD>@db.example.com:5432/mydb",
4368            "postgresql://<USER>:<PASSWORD>@db.example.com:5432/mydb",
4369            "redis://:<PASSWORD>@cache.example.com:6379",
4370        ] {
4371            let error = check(content).expect_err("URL credential placeholders still match");
4372            let RuntimeError::SecretDetected(matched) = &error else {
4373                panic!("expected a secret refusal, got {error}");
4374            };
4375            assert_eq!(matched.detector, "url-userinfo");
4376            let rendered = error.to_string();
4377            for guidance in [
4378                "Placeholders in URL credential positions still match",
4379                "Replace the whole URL",
4380                "environment-variable name or config key",
4381                "remove the entire user/password segment",
4382            ] {
4383                assert!(
4384                    rendered.contains(guidance),
4385                    "missing {guidance:?}: {rendered}"
4386                );
4387            }
4388            assert!(!rendered.contains(content), "refusal must not echo the URL");
4389        }
4390    }
4391
4392    #[test]
4393    fn url_userinfo_guidance_remedies_are_accepted() {
4394        for content in [
4395            "Use the DATABASE_URL environment variable.",
4396            "Use the database.connection_url config key.",
4397            "postgresql://db.example.com:5432/mydb",
4398            "redis://cache.example.com:6379",
4399        ] {
4400            assert!(
4401                check(content).is_ok(),
4402                "URL reference or URL without credentials must be accepted: {content:?}"
4403            );
4404        }
4405    }
4406
4407    #[test]
4408    fn blocks_and_masks_url_userinfo_with_short_passwords() {
4409        for password in ["a", "ab", "abc"] {
4410            let content = format!(
4411                "gate backend probe failed: postgres://svc:{password}@internal-host refused"
4412            );
4413
4414            let detected = scan(&content).expect("short URL password must be detected");
4415            assert_eq!(detected.detector, "url-userinfo");
4416            assert!(
4417                check(&content).is_err(),
4418                "write gate must block {content:?}"
4419            );
4420            assert_eq!(
4421                bounded_masked_log_text(&content),
4422                "gate backend probe failed: ***MASKED*** refused",
4423                "log boundary must mask {content:?}"
4424            );
4425        }
4426    }
4427
4428    #[test]
4429    fn keeps_url_without_userinfo() {
4430        let content = "gate backend probe failed: postgres://host:8080/path refused";
4431
4432        assert!(check(content).is_ok(), "host:port is not URL userinfo");
4433        assert_eq!(bounded_masked_log_text(content), content);
4434    }
4435
4436    #[test]
4437    fn blocks_and_masks_empty_username_url_passwords() {
4438        // Standard empty-user connection strings: the password is the
4439        // credential whether or not a username precedes the colon.
4440        for password in ["a", "ab", "%40", "密码"] {
4441            let content =
4442                format!("gate backend probe failed: redis://:{password}@internal-host refused");
4443
4444            let detected = scan(&content).expect("empty-username URL password must be detected");
4445            assert_eq!(detected.detector, "url-userinfo");
4446            assert!(
4447                check(&content).is_err(),
4448                "write gate must block {content:?}"
4449            );
4450            assert_eq!(
4451                bounded_masked_log_text(&content),
4452                "gate backend probe failed: ***MASKED*** refused",
4453                "log boundary must mask {content:?}"
4454            );
4455        }
4456    }
4457
4458    #[test]
4459    fn keeps_colon_at_pairs_outside_the_authority() {
4460        // An `@` past the authority boundary is path/query/fragment text,
4461        // not userinfo: `host/a:x@next` must not read as user `host/a` with
4462        // password `x`.
4463        for content in [
4464            "see https://host/a:x@next for details",
4465            "see https://host?time=12:30@zone for details",
4466            "see https://host#frag:1@anchor for details",
4467        ] {
4468            assert!(
4469                check(content).is_ok(),
4470                "path/query/fragment `:`+`@` text is not userinfo: {content:?}"
4471            );
4472            assert_eq!(bounded_masked_log_text(content), content);
4473        }
4474    }
4475
4476    #[test]
4477    fn blocks_high_entropy_near_bearer_word() {
4478        // 32 random-looking base64 chars adjacent to the word "bearer".
4479        let fake = "Bearer token: Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM"; // gitleaks:allow
4480        assert!(
4481            scan(fake).is_some(),
4482            "high-entropy value near 'bearer' must be caught"
4483        );
4484        assert_eq!(scan(fake).unwrap().detector, "high-entropy-token");
4485    }
4486
4487    #[test]
4488    fn blocks_high_entropy_near_secret_word() {
4489        let fake = "secret=Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM"; // gitleaks:allow
4490        assert!(
4491            scan(fake).is_some(),
4492            "high-entropy value near 'secret' must be caught"
4493        );
4494    }
4495
4496    #[test]
4497    fn error_message_masks_secret() {
4498        let fake = "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
4499        let m = scan(fake).unwrap();
4500        // Masked form: first 6 chars + "...N chars".
4501        // Must NOT contain the full suffix.
4502        let masked = &m.masked;
4503        assert!(
4504            !masked.contains("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"),
4505            "mask must not echo the full secret value; got: {masked}"
4506        );
4507        // Must start with "ghp_AA" (first 6 chars of the token).
4508        assert!(
4509            masked.starts_with("ghp_AA"),
4510            "mask must show first 6 chars; got: {masked}"
4511        );
4512    }
4513
4514    // ── False-positive suite ─────────────────────────────────────────────────
4515
4516    #[test]
4517    fn allows_sha256_hex() {
4518        // 64-char lowercase hex — typical sha256 digest.
4519        let sha = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
4520        assert!(
4521            scan(sha).is_none(),
4522            "sha256 hex must pass (allowlisted); fired: {:?}",
4523            scan(sha)
4524        );
4525    }
4526
4527    #[test]
4528    fn allows_uuid() {
4529        let uuid = "550e8400-e29b-41d4-a716-446655440000";
4530        assert!(
4531            scan(uuid).is_none(),
4532            "UUID must pass; fired: {:?}",
4533            scan(uuid)
4534        );
4535    }
4536
4537    #[test]
4538    fn allows_git_sha() {
4539        // 40-char lowercase git SHA.
4540        let sha = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
4541        assert!(
4542            scan(sha).is_none(),
4543            "git SHA must pass; fired: {:?}",
4544            scan(sha)
4545        );
4546    }
4547
4548    #[test]
4549    fn allows_normal_prose() {
4550        let prose =
4551            "The FlashAttention paper introduces IO-aware tiling for transformer self-attention.";
4552        assert!(scan(prose).is_none(), "normal prose must pass");
4553    }
4554
4555    #[test]
4556    fn allows_code_snippet() {
4557        let code = r#"fn create_entity(name: &str, kind: &str) -> RuntimeResult<Entity> {
4558    self.validate_entity_kind(kind)?;
4559    Ok(Entity::new("local", kind, name))
4560}"#;
4561        assert!(
4562            scan(code).is_none(),
4563            "code snippet must pass; fired: {:?}",
4564            scan(code)
4565        );
4566    }
4567
4568    #[test]
4569    fn allows_long_url_without_credentials() {
4570        let url = "https://docs.example.com/api/v2/entities?kind=concept&limit=100";
4571        assert!(scan(url).is_none(), "URL without userinfo must pass");
4572    }
4573
4574    #[test]
4575    fn allows_base64_image_stub() {
4576        // Realistic short base64 data URI stub — no trigger words, below threshold length.
4577        let b64 = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQI12NgAAIABQ";
4578        assert!(
4579            scan(b64).is_none(),
4580            "base64 image stub without trigger word must pass; fired: {:?}",
4581            scan(b64)
4582        );
4583    }
4584
4585    #[test]
4586    fn allows_long_plain_url() {
4587        let url = "https://api.github.com/repos/ohdearquant/khive/pulls/76/comments?per_page=100";
4588        assert!(
4589            scan(url).is_none(),
4590            "plain URL must pass; fired: {:?}",
4591            scan(url)
4592        );
4593    }
4594
4595    #[test]
4596    fn allows_manifest_content_hash() {
4597        // A string like what appears in Cargo.lock or npm lockfiles.
4598        let line =
4599            "checksum = \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"";
4600        assert!(
4601            scan(line).is_none(),
4602            "manifest content hash line must pass; fired: {:?}",
4603            scan(line)
4604        );
4605    }
4606
4607    #[test]
4608    fn masked_excerpt_format() {
4609        let fake = "AKIAFAKEKEY1234567890";
4610        let m = scan(fake).unwrap();
4611        // Format: first6...Nchars
4612        assert!(m.masked.contains("..."), "masked must contain '...'");
4613        assert!(m.masked.ends_with("chars"), "masked must end with 'chars'");
4614    }
4615
4616    // ── Gate function ────────────────────────────────────────────────────────
4617
4618    #[test]
4619    fn check_returns_ok_for_safe_content() {
4620        assert!(check("A normal memory note about LoRA.").is_ok());
4621    }
4622
4623    #[test]
4624    fn check_returns_err_for_secret() {
4625        let fake = "AKIAFAKEKEY1234567890";
4626        let result = check(fake);
4627        assert!(result.is_err(), "check must fail for AKIA key");
4628        let err = result.unwrap_err();
4629        assert!(
4630            matches!(err, RuntimeError::SecretDetected(_)),
4631            "error variant must be SecretDetected"
4632        );
4633    }
4634
4635    // ── Entropy helpers ──────────────────────────────────────────────────────
4636
4637    #[test]
4638    fn entropy_of_uniform_string_is_zero() {
4639        let s = "aaaaaaaaaaaaaaaa";
4640        assert!(shannon_entropy(s.as_bytes()) < 0.01);
4641    }
4642
4643    #[test]
4644    fn entropy_of_random_bytes_is_high() {
4645        // A truly random-looking string should exceed 4.5 bits/char.
4646        let s = b"X9kZ2vQpLrT8nJwYuAeHfBsDcGiONvM1"; // 32 mixed base64 chars
4647        assert!(shannon_entropy(s) > 4.5, "entropy={}", shannon_entropy(s));
4648    }
4649
4650    #[test]
4651    fn cjk_prose_near_trigger_is_not_flagged() {
4652        // Regression: a multibyte CJK run (~19 chars = 57 bytes) clears the
4653        // byte-length floor, and `shannon_entropy` over UTF-8 bytes reads it as
4654        // high-entropy — so a Chinese title near the `auth` trigger word used to
4655        // false-positive as `high-entropy-token`.  Non-ASCII tokens are now
4656        // skipped by the entropy heuristic: real base64/hex credentials are
4657        // ASCII, so this cannot hide a secret.
4658        let content = "更新 auth 配置数据库连接管理系统核心模块设计文档";
4659        assert!(
4660            check(content).is_ok(),
4661            "CJK prose near a trigger word must not be flagged as a secret"
4662        );
4663    }
4664
4665    #[test]
4666    fn ascii_secret_near_trigger_still_flagged() {
4667        // The non-ASCII skip must NOT weaken detection of genuine ASCII
4668        // high-entropy credentials near a trigger word.
4669        let content = "api_key X9kZ2vQpLrT8nJwYuAeHfBsDcGiONvM1";
4670        assert!(
4671            check(content).is_err(),
4672            "ASCII high-entropy token near a trigger word must still be blocked"
4673        );
4674    }
4675
4676    #[test]
4677    fn ascii_secret_in_cjk_context_does_not_panic_and_is_flagged() {
4678        // The ±120-byte trigger window around an ASCII token can land in the
4679        // middle of a multibyte CJK character when the token is embedded in
4680        // non-Latin prose.  Slicing on a non-char-boundary would panic — the
4681        // window bounds are snapped via `floor_char_boundary`.  Detection of
4682        // the genuine ASCII secret must still fire.
4683        let cjk = "数据库连接管理系统核心模块设计文档".repeat(6); // 17 chars × 6 = 306 bytes
4684                                                                  // The leading single-byte `x` breaks 3-byte CJK alignment so the window
4685                                                                  // start (token_offset - 120) lands mid-character without the snap.
4686        let content = format!("{cjk}x api_key X9kZ2vQpLrT8nJwYuAeHfBsDcGiONvM1 {cjk}");
4687        assert!(
4688            check(&content).is_err(),
4689            "ASCII secret in CJK context must still be blocked (and must not panic)"
4690        );
4691    }
4692
4693    #[test]
4694    fn ascii_secret_glued_to_cjk_is_still_flagged() {
4695        // Regression: a prefixless high-entropy credential glued (no ASCII
4696        // whitespace) to CJK text, CJK brackets/quotes, a fullwidth space, or a
4697        // fullwidth colon used to slip through, because the whole whitespace token
4698        // contained a non-ASCII byte and was skipped wholesale.  Non-ASCII is now
4699        // a token delimiter, so the ASCII credential run is isolated and
4700        // entropy-checked while the surrounding ±120-byte window still sees the
4701        // trigger word.
4702        let secret = "X9kZ2vQpLrT8nJwYuAeHfBsDcGiONvM1"; // gitleaks:allow
4703        let cases = [
4704            format!("api_key {secret}数据"),     // CJK suffix glued to the token
4705            format!("api_key 「{secret}」"),     // CJK brackets wrap the token
4706            format!("api_key {secret}"),        // U+3000 ideographic space separator
4707            format!("api_key:{secret}"),        // U+FF1A fullwidth colon separator
4708            format!("数据{secret}更新 api_key"), // CJK-glued prefix, trigger after
4709        ];
4710        for content in &cases {
4711            assert!(
4712                check(content).is_err(),
4713                "ASCII secret glued to CJK must be blocked: {content:?}"
4714            );
4715        }
4716    }
4717
4718    #[test]
4719    fn high_entropy_ascii_run_without_trigger_is_not_flagged() {
4720        // The non-ASCII-as-delimiter change must not weaken the trigger-context
4721        // discipline: a high-entropy ASCII run isolated from CJK prose but NOT
4722        // near a credential trigger word is still allowed (only the tokenizer
4723        // changed, not the `near_trigger` gate).
4724        let secret = "X9kZ2vQpLrT8nJwYuAeHfBsDcGiONvM1"; // gitleaks:allow
4725        let content = format!("数据库连接{secret}核心模块设计文档");
4726        assert!(
4727            check(&content).is_ok(),
4728            "high-entropy ASCII run with no trigger word must not be flagged"
4729        );
4730    }
4731
4732    #[test]
4733    fn known_prefix_secret_glued_after_cjk_is_still_flagged() {
4734        // A Layer-1 known-prefix secret glued directly after
4735        // CJK prose (no ASCII whitespace) was missed, because the prefix boundary
4736        // check used `is_alphanumeric` — which Rust counts true for CJK — so the
4737        // preceding ideograph was not treated as a delimiter.  These credentials
4738        // must be caught with no nearby ASCII trigger word, on the left side too.
4739        let cases = [
4740            "数据AKIAIOSFODNN7EXAMPLE".to_owned(), // gitleaks:allow
4741            format!("令牌{}", github_fine_grained_pat_fixture()),
4742            format!("密钥{}", anthropic_api_key_fixture()),
4743            "配置FlyV1 fm2_AAAABBBBCCCCDDDD".to_owned(), // gitleaks:allow
4744        ];
4745        for content in &cases {
4746            assert!(
4747                check(content).is_err(),
4748                "known-prefix secret glued after CJK must be blocked: {content:?}"
4749            );
4750        }
4751    }
4752
4753    #[test]
4754    fn url_userinfo_after_cjk_does_not_panic_and_is_flagged() {
4755        // A credential URL glued after CJK prose panicked,
4756        // because scheme_start was (separator byte index + 1) — one byte into a
4757        // multibyte CJK separator — and the slice fell on a non-char boundary.
4758        // The public check() API must return a controlled error, never panic.
4759        let cases = [
4760            "数据postgresql://dbuser:S3cr3tP4ss@db.example.com/db", // gitleaks:allow
4761            "配置mysql://root:hunter2pw@10.0.0.1:3306/app",         // gitleaks:allow
4762            "连接redis://svc:V3ryS3cretPw@cache.internal:6379",     // gitleaks:allow
4763        ];
4764        for content in cases {
4765            assert!(
4766                check(content).is_err(),
4767                "credential URL after CJK must be blocked, not panic: {content:?}"
4768            );
4769        }
4770    }
4771
4772    #[test]
4773    fn non_ascii_glued_token_trigger_is_still_flagged() {
4774        // `token=`/`token:`/standalone `token` glued directly
4775        // after non-ASCII prose was missed because has_standalone_token /
4776        // has_token_assignment used is_alphanumeric for the word boundary — CJK,
4777        // accented letters, and fullwidth digits all count as alphanumeric in
4778        // Rust, so the preceding char was not seen as a boundary and the `token`
4779        // trigger was suppressed, leaving the high-entropy value unflagged.
4780        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
4781        let blocked = [
4782            format!("数据token={opaque}"),    // CJK + assignment form, ASCII '='
4783            format!("配置token: {opaque}"),   // CJK + assignment form, ASCII ':'
4784            format!("密钥token {opaque}"),    // CJK + standalone-word form
4785            format!("résumétoken: {opaque}"), // accented letter before `token`
4786            format!("1token: {opaque}"),     // fullwidth digit before `token`
4787        ];
4788        for content in &blocked {
4789            assert!(
4790                check(content).is_err(),
4791                "non-ASCII-glued token trigger must flag the value: {content:?}"
4792            );
4793        }
4794        // Compound identifiers stay excluded — the `_` boundary rule is unchanged
4795        // and an ASCII letter before `token` is still a continuation, so these
4796        // (including the pure-ASCII `servicetoken:`) must still pass.
4797        let allowed = [
4798            format!("数据next_token: {opaque}"),
4799            format!("数据token_count: {opaque}"),
4800            format!("servicetoken: {opaque}"),
4801        ];
4802        for content in &allowed {
4803            assert!(
4804                check(content).is_ok(),
4805                "compound token identifier must not be flagged: {content:?}"
4806            );
4807        }
4808    }
4809
4810    #[test]
4811    fn allowlist_passes_sha256() {
4812        // A plain sha256 hex digest passes via `is_pure_hex` (not `is_allowlisted`
4813        // because hex is now context-dependent; this tests the primitive directly).
4814        let sha = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
4815        assert!(is_pure_hex(sha));
4816    }
4817
4818    #[test]
4819    fn allowlist_passes_uuid_canonical() {
4820        assert!(is_uuid_canonical("550e8400-e29b-41d4-a716-446655440000"));
4821    }
4822
4823    #[test]
4824    fn allowlist_does_not_pass_mixed_token() {
4825        // A token that starts with letters but mixes in non-hex chars.
4826        assert!(!is_pure_hex("sk-aaaaaabbbbbbccccccddddddeeeeeeffffgg"));
4827    }
4828
4829    // ── Structured-field gate helpers ────────────────────────────────────────
4830
4831    #[test]
4832    fn check_json_blocks_secret_in_object_value() {
4833        let props = serde_json::json!({ "api_key": "AKIAFAKEKEY1234567890" });
4834        assert!(
4835            check_json(&props).is_err(),
4836            "secret in properties object value must be blocked"
4837        );
4838    }
4839
4840    #[test]
4841    fn check_json_blocks_secret_in_nested_object() {
4842        let props = serde_json::json!({
4843            "credentials": { "token": openai_project_key_fixture() }
4844        });
4845        assert!(
4846            check_json(&props).is_err(),
4847            "secret in nested properties object must be blocked"
4848        );
4849    }
4850
4851    #[test]
4852    fn check_json_blocks_secret_in_array() {
4853        let props = serde_json::json!(["normal", "AKIAFAKEKEY1234567890"]);
4854        assert!(
4855            check_json(&props).is_err(),
4856            "secret in JSON array must be blocked"
4857        );
4858    }
4859
4860    // ── Reserved secret-gate property key (ADR-115 Amendment 1) ─────────────
4861
4862    #[test]
4863    fn reject_reserved_key_passes_absent_properties() {
4864        assert!(reject_reserved_secret_gate_property(None).is_ok());
4865    }
4866
4867    #[test]
4868    fn reject_reserved_key_passes_unrelated_properties() {
4869        let props = serde_json::json!({"name": "value", "tags": ["a", "b"]});
4870        assert!(reject_reserved_secret_gate_property(Some(&props)).is_ok());
4871    }
4872
4873    #[test]
4874    fn reject_reserved_key_passes_non_object_properties() {
4875        // Non-object properties cannot name a top-level key at all.
4876        let props = serde_json::json!("just a string");
4877        assert!(reject_reserved_secret_gate_property(Some(&props)).is_ok());
4878        let arr = serde_json::json!(["a", "b"]);
4879        assert!(reject_reserved_secret_gate_property(Some(&arr)).is_ok());
4880    }
4881
4882    #[test]
4883    fn reject_reserved_key_blocks_top_level_key_creation() {
4884        let props = serde_json::json!({"khive:secret_gate": "exempted:content-sha256-manifest-v1"});
4885        let err = reject_reserved_secret_gate_property(Some(&props)).unwrap_err();
4886        assert!(
4887            matches!(err, RuntimeError::InvalidInput(ref msg) if msg.contains("khive:secret_gate") && msg.contains("runtime-owned")),
4888            "unexpected error: {err:?}"
4889        );
4890    }
4891
4892    #[test]
4893    fn reject_reserved_key_blocks_regardless_of_value_shape() {
4894        // Presence alone is rejected — arbitrary value, null (explicit removal
4895        // shape), and a value that happens to match the real stamp format are
4896        // all rejected identically; a caller can never legitimately write this
4897        // key by any value.
4898        for value in [
4899            serde_json::json!(null),
4900            serde_json::json!(42),
4901            serde_json::json!({"nested": "object"}),
4902            serde_json::json!("exempted:content-sha256-manifest-v1"),
4903        ] {
4904            let props = serde_json::json!({"khive:secret_gate": value});
4905            assert!(
4906                reject_reserved_secret_gate_property(Some(&props)).is_err(),
4907                "must reject value shape: {props:?}"
4908            );
4909        }
4910    }
4911
4912    #[test]
4913    fn reject_reserved_key_allows_nested_non_top_level_occurrence() {
4914        // The same spelling nested inside a value is ordinary content, not a
4915        // posture mutation — only the exact top-level key is reserved.
4916        let props = serde_json::json!({"notes": {"khive:secret_gate": "not-a-stamp"}});
4917        assert!(reject_reserved_secret_gate_property(Some(&props)).is_ok());
4918    }
4919
4920    #[test]
4921    fn reject_reserved_key_blocks_alongside_other_legitimate_keys() {
4922        let props = serde_json::json!({
4923            "name": "value",
4924            "khive:secret_gate": "exempted:content-sha256-manifest-v1",
4925        });
4926        assert!(reject_reserved_secret_gate_property(Some(&props)).is_err());
4927    }
4928
4929    #[test]
4930    fn check_json_passes_safe_properties() {
4931        let props = serde_json::json!({
4932            "domain": "attention",
4933            "status": "researched",
4934            "year": 2024
4935        });
4936        assert!(
4937            check_json(&props).is_ok(),
4938            "normal properties must pass; fired: {:?}",
4939            check_json(&props).err()
4940        );
4941    }
4942
4943    #[test]
4944    fn check_tags_blocks_credential_tag() {
4945        let tags = vec![
4946            "type:concept".to_string(),
4947            "AKIAFAKEKEY1234567890".to_string(),
4948        ];
4949        assert!(
4950            check_tags(&tags).is_err(),
4951            "credential-shaped tag must be blocked"
4952        );
4953    }
4954
4955    #[test]
4956    fn check_tags_passes_normal_tags() {
4957        let tags = vec!["type:concept".to_string(), "domain:attention".to_string()];
4958        assert!(
4959            check_tags(&tags).is_ok(),
4960            "normal tags must pass; fired: {:?}",
4961            check_tags(&tags).err()
4962        );
4963    }
4964
4965    // ── False-positive: sk-learn and scikit-learn slugs ──────────────────────
4966
4967    #[test]
4968    fn allows_sk_learn_prose() {
4969        // scikit-learn slug used as an entity name or knowledge atom.
4970        let texts = &[
4971            "sk-learn is a Python machine learning library",
4972            "sk-learn-compatible transformer pipeline reference",
4973            "sk-learn scikit-learn estimator interface",
4974        ];
4975        for t in texts {
4976            assert!(
4977                scan(t).is_none(),
4978                "sk-learn prose must pass; fired: {:?} on {:?}",
4979                scan(t),
4980                t
4981            );
4982        }
4983    }
4984
4985    #[test]
4986    fn blocks_openai_sk_proj_not_confused_with_sk_learn() {
4987        // Real OpenAI key shape must still be caught.
4988        let fake = openai_project_key_fixture();
4989        assert!(
4990            scan(&fake).is_some(),
4991            "sk-proj- key must still be caught after sk-learn exemption"
4992        );
4993    }
4994
4995    // ── False-positive: SRI / tokenizer hash metadata ────────────────────────
4996
4997    #[test]
4998    fn blocks_sri_hash_near_key_word_accepted_fp() {
4999        // SRI hash as used in HTML integrity attributes (sha384, base64-encoded),
5000        // placed directly beside the trigger word "key". The content-hash
5001        // allowlist is a prose-context exemption, not unconditional: near a
5002        // credential trigger, a sha-prefixed hash falls through to the explicit
5003        // near-trigger content-hash detector like any other high-entropy
5004        // candidate. This is an accepted false positive on a real but rare
5005        // shape (an integrity hash literally next to the word "key").
5006        let line = "integrity key: sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC";
5007        assert!(
5008            scan(line).is_some(),
5009            "SRI hash near trigger word 'key' must now be blocked (accepted FP); passed unexpectedly"
5010        );
5011    }
5012
5013    #[test]
5014    fn allows_base64_tokenizer_hash_metadata() {
5015        // Tokenizer metadata containing a base64 hash near technical keywords.
5016        let line = "tokenizer_vocab_hash: Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM"; // gitleaks:allow
5017        assert!(
5018            scan(line).is_none(),
5019            "tokenizer hash metadata must pass; fired: {:?}",
5020            scan(line)
5021        );
5022    }
5023
5024    #[test]
5025    fn allows_npm_lockfile_integrity() {
5026        // npm lockfile integrity line with sha512 base64url hash (86 base64 chars + ==).
5027        // sha512 digest = 64 bytes → base64 = 88 chars (86 unpadded + ==).
5028        let body_86 = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM1234567890abcdefghijklmnopqrstuvwxABCDEFGHIJKLMNOPQRST";
5029        assert_eq!(body_86.len(), 86, "test body must be exactly 86 chars");
5030        let line = format!(
5031            "resolved: https://registry.npmjs.org/foo/-/foo-1.0.0.tgz\nintegrity: sha512-{body_86}=="
5032        );
5033        assert!(
5034            scan(&line).is_none(),
5035            "npm lockfile integrity must pass; fired: {:?}",
5036            scan(&line)
5037        );
5038    }
5039
5040    // ── False-positive: tokenizer vs token trigger word ─────────────────────
5041
5042    #[test]
5043    fn allows_tokenizer_vocab_hash_no_block() {
5044        // `tokenizer_vocab_hash` contains the substring "token" but NOT as a
5045        // standalone word (followed by 'i' which is alphanumeric), so the
5046        // standalone-token boundary check must not fire here.
5047        let line = "tokenizer_vocab_hash = Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM"; // gitleaks:allow
5048        assert!(
5049            scan(line).is_none(),
5050            "tokenizer_vocab_hash must pass; 'token' is only standalone-word matched; fired: {:?}",
5051            scan(line)
5052        );
5053    }
5054
5055    // ── True-positives: bare base64 at sha-lengths near trigger words ────────
5056
5057    #[test]
5058    fn blocks_bare_base64url_43chars_near_key() {
5059        // A 43-char base64url token (= sha256 body length) near the word "key".
5060        // Without a sha<N>- prefix this MUST be caught, not allowlisted.
5061        let token_43 = "wJalrXUtnFEMI-K7MDENGbPxRfiCYEXAMPLEKEYX123"; // gitleaks:allow
5062        assert_eq!(token_43.len(), 43, "test token must be exactly 43 chars");
5063        let line = format!("api key {token_43}");
5064        assert!(
5065            scan(&line).is_some(),
5066            "43-char base64url token near 'key' must be caught (no sha-prefix = not a hash); fired: {:?}",
5067            scan(&line)
5068        );
5069    }
5070
5071    #[test]
5072    fn blocks_bare_base64url_64chars_near_secret() {
5073        // A 64-char base64url token (= sha384 body length) near "secret".
5074        // Must be caught without sha<N>- prefix.
5075        let token_64 = "wJalrXUtnFEMI-K7MDENGbPxRfiCYEXAMPLEKEYX123wJalrXUtnFEMI-K7MDENa"; // gitleaks:allow
5076        assert_eq!(token_64.len(), 64, "test token must be exactly 64 chars");
5077        let line = format!("secret: {token_64}");
5078        assert!(
5079            scan(&line).is_some(),
5080            "64-char base64url token near 'secret' must be caught; got: {:?}",
5081            scan(&line)
5082        );
5083    }
5084
5085    #[test]
5086    fn blocks_bare_base64url_86chars_near_auth() {
5087        // An 86-char base64url token (= sha512 body length) near "auth".
5088        // Must be caught without sha<N>- prefix.
5089        let token_86 = "wJalrXUtnFEMI-K7MDENGbPxRfiCYEXAMPLEKEYX123wJalrXUtnFEMI-K7MDENwJalrXUtnFEMI-K7MDENabc"; // gitleaks:allow
5090        assert_eq!(token_86.len(), 86, "test token must be exactly 86 chars");
5091        let line = format!("auth header {token_86}");
5092        assert!(
5093            scan(&line).is_some(),
5094            "86-char base64url token near 'auth' must be caught; got: {:?}",
5095            scan(&line)
5096        );
5097    }
5098
5099    // ── True-positives: standalone `token` trigger ───────────────────────────
5100
5101    #[test]
5102    fn blocks_service_token_opaque_value() {
5103        // "service token <opaque-high-entropy>" — `token` as a standalone word
5104        // with a high-entropy value must be caught.
5105        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
5106        assert!(
5107            opaque.len() >= 24,
5108            "opaque must be long enough for entropy check"
5109        );
5110        let line = format!("service token {opaque}");
5111        assert!(
5112            scan(&line).is_some(),
5113            "service token <opaque> must be caught by standalone 'token' check; got: {:?}",
5114            scan(&line)
5115        );
5116    }
5117
5118    #[test]
5119    fn blocks_token_equals_credential() {
5120        // `token=<high-entropy>` (assignment form) must be caught via has_token_assignment.
5121        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
5122        let line = format!("token={opaque}");
5123        assert!(
5124            scan(&line).is_some(),
5125            "token=<value> must be caught via token= trigger; got: {:?}",
5126            scan(&line)
5127        );
5128    }
5129
5130    #[test]
5131    fn blocks_token_colon_credential() {
5132        // `token: <high-entropy>` (key-value form) must be caught via has_token_assignment.
5133        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
5134        let line = format!("token: {opaque}");
5135        assert!(
5136            scan(&line).is_some(),
5137            "token: <value> must be caught via token: trigger; got: {:?}",
5138            scan(&line)
5139        );
5140    }
5141
5142    #[test]
5143    fn allows_next_token_technical_context() {
5144        // `next_token` is a technical term; the high-entropy value here has low
5145        // entropy anyway, so it must pass.
5146        let line = "next_token: cursor-page-2-abcdef12345678";
5147        assert!(
5148            scan(line).is_none(),
5149            "next_token technical context must not be blocked; fired: {:?}",
5150            scan(line)
5151        );
5152    }
5153
5154    // ── Boundary-aware token= / token: (compound identifiers must pass) ─────
5155
5156    #[test]
5157    fn allows_next_token_high_entropy_cursor() {
5158        // `next_token:` with a realistic high-entropy pagination cursor must NOT be
5159        // blocked.  `next_token` has `_token` suffix — not a standalone assignment form.
5160        let cursor = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
5161        let line = format!("next_token: {cursor}");
5162        assert!(
5163            scan(&line).is_none(),
5164            "next_token with high-entropy cursor must pass (compound identifier); fired: {:?}",
5165            scan(&line)
5166        );
5167    }
5168
5169    #[test]
5170    fn allows_token_count_high_entropy() {
5171        // `token_count:` with a high-entropy value must NOT be blocked.
5172        // `token_count` has `token_` prefix — the word boundary after `token` is `_`,
5173        // which is excluded by has_token_assignment.
5174        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
5175        let line = format!("token_count: {opaque}");
5176        assert!(
5177            scan(&line).is_none(),
5178            "token_count with high-entropy value must pass; fired: {:?}",
5179            scan(&line)
5180        );
5181    }
5182
5183    // ── Hex allowlist is not applied when trigger context is present ────────
5184    // Pure hex tops out at log2(16) = 4.0 bits/char, below ENTROPY_THRESHOLD (4.5), so
5185    // the entropy heuristic alone never flags it. The hex allowlist must only apply
5186    // when NOT near a trigger; the tests below guard that ordering.
5187
5188    #[test]
5189    fn hex_near_key_blocked_in_credential_context() {
5190        // A pure-hex 32-char token near "api key" is a credential-shaped hex
5191        // token in trigger context.  Entropy alone cannot flag it (hex max =
5192        // 4.0 < 4.5 threshold), but the explicit hex-credential-token path
5193        // must catch it.
5194        let hex32 = "4f9c2e8a1d3b5c7e9f0a2b4d6e8c0a2b";
5195        assert_eq!(hex32.len(), 32);
5196        let line = format!("api key {hex32}");
5197        assert!(
5198            scan(&line).is_some(),
5199            "32-char pure hex near 'api key' must be blocked; got None"
5200        );
5201    }
5202
5203    #[test]
5204    fn issue_2056_hex_bridge_masks_the_matched_candidate() {
5205        let hex32 = "0af7651916cd43dd8448eb211c80319c";
5206        let content = format!("api key values verbatim:\n  {hex32}");
5207
5208        let (candidate, detector) = scan_match(&content).expect("credential must have a span");
5209        assert_eq!(detector, "hex-credential-token");
5210        assert_eq!(candidate, hex32);
5211        assert!(candidate.bytes().all(|byte| byte.is_ascii_hexdigit()));
5212
5213        let matched = scan(&content).expect("credential-labeled hex must be blocked");
5214        assert_eq!(matched.detector, "hex-credential-token");
5215        assert_eq!(matched.masked, "0af765...32chars");
5216    }
5217
5218    #[test]
5219    fn issue_2056_detector_name_does_not_trigger_across_sentence_boundary() {
5220        let content = "The hex-credential-token bucket held 18. Six are two values verbatim:\n\
5221                       0af7651916cd43dd8448eb211c80319c -- spec example identifier";
5222
5223        assert!(
5224            check(content).is_ok(),
5225            "a detector name in an earlier sentence must not trigger: {:?}",
5226            scan(content)
5227        );
5228    }
5229
5230    #[test]
5231    fn issue_2076_allows_repository_revision_links_near_prose_triggers() {
5232        let revision = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
5233        let contents = [
5234            format!(
5235                "key-scoped source citation: \
5236                 https://github.com/acme/widgets/blob/{revision}/src/runtime/mod.rs"
5237            ),
5238            format!("auth-bound rendering keeps <a href=\"{revision}\">the commit link</a> intact"),
5239        ];
5240
5241        for content in contents {
5242            assert!(
5243                check(&content).is_ok(),
5244                "repository revision reference must pass: {content:?}, got {:?}",
5245                scan(&content)
5246            );
5247        }
5248    }
5249
5250    #[test]
5251    fn issue_2076_repository_revision_links_do_not_hide_labeled_secrets() {
5252        let revision = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
5253        let contents = [
5254            format!("api key: https://github.com/acme/widgets/blob/{revision}/src/runtime/mod.rs"),
5255            format!("secret key: href=\"{revision}\""),
5256        ];
5257
5258        for content in contents {
5259            assert!(
5260                check(&content).is_err(),
5261                "a direct credential label must still block: {content:?}"
5262            );
5263        }
5264    }
5265
5266    #[test]
5267    fn issue_2076_guidance_names_a_boundary_that_changes_the_predicate() {
5268        let guidance = block_guidance("high-entropy-token");
5269        assert!(guidance.contains("sentence") || guidance.contains("paragraph"));
5270        assert!(
5271            !guidance.contains("own line"),
5272            "a single newline remains inside the trigger window"
5273        );
5274    }
5275
5276    #[test]
5277    fn issue_1988_allows_dense_latex_fragments_near_math_vocabulary() {
5278        let latex = r"\operatorname{Spec}_{H_0^1(\Omega)}(K_N(\tau_{\omega}))^{1/2}";
5279        assert!(latex.len() >= MIN_ENTROPY_LEN);
5280        assert!(shannon_entropy(latex.as_bytes()) >= ENTROPY_THRESHOLD);
5281        let content = format!("the attention key estimate uses {latex}");
5282
5283        assert!(
5284            check(&content).is_ok(),
5285            "LaTeX structure must not be mistaken for a credential: {:?}",
5286            scan(&content)
5287        );
5288    }
5289
5290    #[test]
5291    fn issue_1988_latex_exemption_does_not_hide_credential_runs() {
5292        let credential = "a3f5c2e9d1b8047e63a1f4c2d5b6e8f1a9c3d2e4"; // gitleaks:allow
5293        let content = format!(r"api key: \texttt{{{credential}}}");
5294
5295        assert!(
5296            check(&content).is_err(),
5297            "credential-shaped runs inside LaTeX must remain blocked"
5298        );
5299    }
5300
5301    #[test]
5302    fn issue_1988_published_secret_vectors_remain_fail_closed() {
5303        // RFC 8032 Ed25519ph example secret. Published status cannot be
5304        // inferred from shape, so exact vectors remain blocked unless a
5305        // caller uses the separately audited exemption contract.
5306        let published = "833fe62409237b9d62ec77587520911e9a759cec1d19755b7da901b96dca3d42";
5307        let content = format!("RFC 8032 secret key test vector: {published}");
5308
5309        assert!(check(&content).is_err());
5310    }
5311
5312    #[test]
5313    fn hex_credential_lengths_blocked_near_trigger() {
5314        // Verify all four credential-shaped lengths are caught near a trigger.
5315        let hex40 = "a3f5c2e9d1b8047e63a1f4c2d5b6e8f1a9c3d2e4";
5316        let hex64 = "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b";
5317        let hex128 = format!("{hex64}{hex64}");
5318        assert_eq!(hex40.len(), 40);
5319        assert_eq!(hex64.len(), 64);
5320        assert_eq!(hex128.len(), 128);
5321
5322        for (label, hex) in &[
5323            ("hex40", hex40),
5324            ("hex64", hex64),
5325            ("hex128", hex128.as_str()),
5326        ] {
5327            let line = format!("secret key: {hex}");
5328            assert!(
5329                scan(&line).is_some(),
5330                "{label} near 'secret key' must be blocked; got None"
5331            );
5332        }
5333    }
5334
5335    #[test]
5336    fn hex_blocked_when_trigger_and_hash_word_coexist() {
5337        // Credential trigger dominates: adding "hash" or "sha" to the window does
5338        // not rescue a pure-hex token when a credential trigger is also present.
5339        // An attacker controlling the prose could otherwise bypass the gate with
5340        // one extra word, so the hash-word exception must NOT apply in trigger context.
5341        let hex32 = "4f9c2e8a1d3b5c7e9f0a2b4d6e8c0a2b";
5342        let key_hash_line = format!("api key hash {hex32}");
5343        let secret_sha_line = format!("secret sha {hex32}");
5344        assert!(
5345            scan(&key_hash_line).is_some(),
5346            "'api key hash <hex32>' must be blocked; got None"
5347        );
5348        assert!(
5349            scan(&secret_sha_line).is_some(),
5350            "'secret sha <hex32>' must be blocked; got None"
5351        );
5352    }
5353
5354    #[test]
5355    fn hex_near_sha_context_word_allowed() {
5356        // A 40-char hex with "sha" or "commit" in the window — but no credential
5357        // trigger — must be allowed (git SHA or content hash in normal prose).
5358        let hex40 = "da39a3ee5e6b4b0d3255bfef95601890afd80709";
5359        let sha_line = format!("sha1: {hex40}");
5360        let commit_line = format!("commit sha {hex40}");
5361        assert!(
5362            scan(&sha_line).is_none(),
5363            "hex40 near 'sha1' context must be allowed; fired: {:?}",
5364            scan(&sha_line)
5365        );
5366        assert!(
5367            scan(&commit_line).is_none(),
5368            "hex40 near 'commit sha' context must be allowed; fired: {:?}",
5369            scan(&commit_line)
5370        );
5371    }
5372
5373    const GIT_LENGTH_FIXTURE: &str = "da39a3ee5e6b4b0d3255bfef95601890afd80709";
5374
5375    #[test]
5376    fn bare_forty_hex_line_three_allows_trigger_on_line_one_within_window() {
5377        let content = format!("auth changes\nready\n{GIT_LENGTH_FIXTURE}");
5378        assert!(content.len() < TRIGGER_WINDOW);
5379        assert!(check(&content).is_ok());
5380        assert_eq!(mask_secrets(&content), content);
5381    }
5382
5383    #[test]
5384    fn bare_forty_hex_line_three_allows_trigger_beyond_window() {
5385        let content = format!("auth changes\n{}\n{GIT_LENGTH_FIXTURE}", "-".repeat(150));
5386        assert!(check(&content).is_ok());
5387    }
5388
5389    #[test]
5390    fn forty_hex_allows_direct_sha_or_commit_marker_in_prose() {
5391        for marker in ["sha:", "commit"] {
5392            let content = format!("auth changes\nready\n{marker} {GIT_LENGTH_FIXTURE}");
5393            assert!(check(&content).is_ok(), "{marker}");
5394        }
5395    }
5396
5397    #[test]
5398    fn forty_hex_refuses_same_line_token_assignment() {
5399        let content = format!("token: {GIT_LENGTH_FIXTURE}");
5400        let matched = scan(&content).expect("explicit credential assignment");
5401        assert_eq!(matched.detector, "hex-credential-token");
5402        assert_eq!(matched.trigger, Some("token"));
5403        assert!(check(&content).is_err());
5404        assert!(!mask_secrets(&content).contains(GIT_LENGTH_FIXTURE));
5405    }
5406
5407    #[test]
5408    fn forty_hex_refuses_previous_label_line_ending_colon_or_equals() {
5409        for newline in ["\n", "\r\n", "\r"] {
5410            for delimiter in [":", "="] {
5411                let content = format!("token{delimiter}{newline}  `{GIT_LENGTH_FIXTURE}`");
5412                let matched = scan(&content).expect("previous line labels the value");
5413                assert_eq!(matched.detector, "hex-credential-token");
5414                assert_eq!(matched.trigger, Some("token"));
5415                assert!(!mask_secrets(&content).contains(GIT_LENGTH_FIXTURE));
5416            }
5417        }
5418    }
5419
5420    #[test]
5421    fn forty_hex_context_stays_on_line_except_immediate_assignment_label() {
5422        for content in [
5423            format!("token\n{GIT_LENGTH_FIXTURE}"),
5424            format!("token:\n\n{GIT_LENGTH_FIXTURE}"),
5425            format!("{GIT_LENGTH_FIXTURE}\nauth changes"),
5426            format!("token_count:\n{GIT_LENGTH_FIXTURE}"),
5427            format!("authorized:\n{GIT_LENGTH_FIXTURE}"),
5428            format!("{}\n{GIT_LENGTH_FIXTURE}\n密钥", "文".repeat(80)),
5429        ] {
5430            assert!(check(&content).is_ok(), "{content}");
5431        }
5432        for content in [
5433            format!("{GIT_LENGTH_FIXTURE} auth"),
5434            format!("api_keyv2 =\n{GIT_LENGTH_FIXTURE}"),
5435            format!("secret for deploy: \n{GIT_LENGTH_FIXTURE}"),
5436        ] {
5437            assert!(check(&content).is_err(), "{content}");
5438        }
5439    }
5440
5441    #[test]
5442    fn other_hex_lengths_still_refuse_cross_line_trigger_context() {
5443        for length in [32, 64, 128] {
5444            let value = "a".repeat(length);
5445            let content = format!("auth changes\nready\n{value}");
5446            let matched = scan(&content).expect("unchanged cross-line context");
5447            assert_eq!(matched.detector, "hex-credential-token");
5448            assert_eq!(matched.trigger, Some("auth"));
5449        }
5450    }
5451
5452    #[test]
5453    fn prefixed_hex_of_forty_bytes_keeps_cross_line_trigger_context() {
5454        for prefix in ["0x", "0X"] {
5455            let value = format!("{prefix}{}", "a".repeat(38));
5456            let content = format!("auth changes\nready\n{value}");
5457            assert!(check(&content).is_err());
5458            assert!(!mask_secrets(&content).contains(&value));
5459        }
5460    }
5461
5462    #[test]
5463    fn forty_hex_beside_bridgeable_prose_keeps_conservative_trigger_context() {
5464        let content = format!("auth changes\ncompleted\n{GIT_LENGTH_FIXTURE}");
5465        assert!(check(&content).is_err());
5466        assert!(!mask_secrets(&content).contains(GIT_LENGTH_FIXTURE));
5467    }
5468
5469    #[test]
5470    fn forty_hex_bridge_fragments_keep_cross_line_detection_and_full_masking() {
5471        for lengths in [(40, 24), (24, 40), (40, 40)] {
5472            let first = "a".repeat(lengths.0);
5473            let second = "b".repeat(lengths.1);
5474            let content = format!("auth changes\nready\n{first}\u{200B}{second}");
5475            let matched = scan(&content).expect("fragment keeps original trigger context");
5476            assert_eq!(matched.detector, "hex-credential-token");
5477            assert_eq!(matched.trigger, Some("auth"));
5478            let masked = mask_secrets(&content);
5479            assert!(!masked.contains(&first), "first fragment remains");
5480            assert!(!masked.contains(&second), "second fragment remains");
5481            assert_eq!(
5482                masked,
5483                "auth changes\nready\n***MASKED***\u{200B}***MASKED***"
5484            );
5485        }
5486    }
5487
5488    #[test]
5489    fn refusal_names_rule_and_canonical_trigger_without_candidate_text() {
5490        for (label, trigger) in [
5491            ("token", "token"),
5492            ("AUTH", "auth"),
5493            ("api_keyv2", "api_key"),
5494        ] {
5495            let content = format!("{label}: {GIT_LENGTH_FIXTURE}");
5496            let error = check(&content).unwrap_err().to_string();
5497            assert!(error.contains("hex-credential-token"));
5498            assert!(error.contains(&format!("near '{trigger}'")), "{error}");
5499            assert!(!error.contains(&GIT_LENGTH_FIXTURE[..6]));
5500        }
5501        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
5502        let error = check(&format!("secret: {opaque}")).unwrap_err().to_string();
5503        assert!(error.contains("high-entropy-token near 'secret'"));
5504        assert!(!error.contains(&opaque[..6]));
5505        let provider = "AKIAFAKEKEY1234567890";
5506        let matched = scan(provider).unwrap();
5507        assert_eq!(matched.trigger, None);
5508        assert!(!matched.to_string().contains(&provider[..6]));
5509    }
5510
5511    #[test]
5512    fn allows_git_revision_reference_near_ordinary_key_and_token_prose() {
5513        let revision = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
5514        let contents = [
5515            format!("the primary key behavior is pinned to commit {revision}"),
5516            format!("revision: {revision} emits one extra token"),
5517            format!("primary key behavior at revision:{revision};"),
5518            format!("configuration key\n\nrev {revision}."),
5519            format!("one extra token was introduced by sha: {revision}"),
5520        ];
5521        for content in &contents {
5522            assert!(
5523                check(content).is_ok(),
5524                "git revision reference in technical prose must pass: {content:?}, got {:?}",
5525                scan(content)
5526            );
5527        }
5528    }
5529
5530    #[test]
5531    fn git_revision_reference_does_not_exempt_credential_assignments() {
5532        let revision = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
5533        for content in [
5534            format!("api_key={revision}"),
5535            format!("secret key: {revision}"),
5536            format!("token={revision}"),
5537            format!("api key hash {revision}"),
5538        ] {
5539            assert!(
5540                check(&content).is_err(),
5541                "credential assignment must remain blocked: {content:?}"
5542            );
5543        }
5544    }
5545
5546    #[test]
5547    fn blocks_forty_hex_after_credential_phrase_with_vcs_marker() {
5548        let revision = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
5549        for content in [
5550            format!("api key value is commit {revision}"),
5551            format!("api key value is revision {revision}"),
5552            format!("the secret is rev {revision}"),
5553            format!("token value sha {revision}"),
5554        ] {
5555            assert_eq!(
5556                scan(&content).map(|matched| matched.detector),
5557                Some("hex-credential-token"),
5558                "a credential phrase must not be hidden by connector words before \
5559                 a VCS marker: {content:?}"
5560            );
5561        }
5562    }
5563
5564    #[test]
5565    fn blocks_unicode_punctuation_between_vcs_marker_and_forty_hex() {
5566        let revision = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
5567        let content = format!("api_key value commit\u{200B}{revision}");
5568        assert!(
5569            check(&content).is_err(),
5570            "a zero-width space between marker and value must not rescue a \
5571             labeled credential: {content:?}, got {:?}",
5572            scan(&content)
5573        );
5574    }
5575
5576    #[test]
5577    fn blocks_slash_bearing_base64_credential_in_value_syntax() {
5578        // 40-char standard-base64-alphabet value whose `/` splits it into two
5579        // runs (19 and 20 bytes) each below MIN_ENTROPY_LEN — per-run checks
5580        // never see it, so the block must come from refusing the path
5581        // exemption for a credential-value clause and applying whole-token
5582        // entropy.
5583        let content = "api key value is Xk9mZ2vQpLrT8nJwYuA/HfBsDcGiONvMabcdefgh";
5584        assert_eq!(
5585            scan(content).map(|matched| matched.detector),
5586            Some("high-entropy-token"),
5587            "slash-bearing base64 credential in value syntax must be blocked"
5588        );
5589    }
5590
5591    #[test]
5592    fn blocks_angle_bracket_line_range_base64_credential_in_value_syntax() {
5593        let content = "api key value is <Xk9mZ2vQpLrT8nJwYuA/HfBsDcGiONvMabcdefgh>:~97-103";
5594        assert_eq!(
5595            scan(content).map(|matched| matched.detector),
5596            Some("high-entropy-token"),
5597            "angle-bracket/line-range dressing must not exempt a credential in \
5598             value syntax"
5599        );
5600    }
5601
5602    #[test]
5603    fn blocks_split_hex_credential_with_marker_adjacent_fragment() {
5604        // A 64-hex credential split 40+24 by a zero-width space, with the
5605        // first fragment hiding behind a VCS marker. The marker-adjacent
5606        // fragment is exempt as its own anchor, but the second fragment
5607        // anchors its own reconstruction chain, walks back across the gap,
5608        // and accumulates 40+24=64 — the symmetric-anchor property the
5609        // vcs-exempt bridge skip relies on.
5610        let content = concat!(
5611            "api token context: commit ",
5612            "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf",
5613            "\u{200B}",
5614            "0123456789abcdef01234567"
5615        );
5616        assert_eq!(
5617            scan(content).map(|matched| matched.detector),
5618            Some("hex-credential-token"),
5619            "split credential with a marker-adjacent fragment must be blocked"
5620        );
5621    }
5622
5623    #[test]
5624    fn blocks_forty_hex_behind_qualified_label_with_delimiter() {
5625        // A label with qualifier words the connector set cannot enumerate
5626        // ("for deploy") followed by a value delimiter is assignment syntax:
5627        // once the walk crosses the `:`/`=`, every label-side identifier is
5628        // stepped over until the trigger word.
5629        let revision = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
5630        for content in [
5631            format!("api key for deploy: commit {revision}"),
5632            format!("prod api key for deploy = commit {revision}"),
5633        ] {
5634            assert_eq!(
5635                scan(&content).map(|matched| matched.detector),
5636                Some("hex-credential-token"),
5637                "a qualified label before a value delimiter must not be \
5638                 hidden from the exemption guard: {content:?}"
5639            );
5640        }
5641    }
5642
5643    #[test]
5644    fn blocks_slash_base64_behind_qualified_label_with_delimiter() {
5645        let content = "api key for deploy: Xk9mZ2vQpLrT8nJwYuA/HfBsDcGiONvMabcdefgh";
5646        assert_eq!(
5647            scan(content).map(|matched| matched.detector),
5648            Some("high-entropy-token"),
5649            "a qualified label before a value delimiter must refuse the path \
5650             exemption for a slash-bearing base64 credential"
5651        );
5652    }
5653
5654    #[test]
5655    fn blocks_forty_hex_behind_versioned_label() {
5656        // `v1.2` splits into version fragments under identifier extraction;
5657        // the intra-token dot must not read as a sentence boundary and the
5658        // fragments must be stepped over like connector words.
5659        let revision = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
5660        let content = format!("api key v1.2 value is commit {revision}");
5661        assert_eq!(
5662            scan(&content).map(|matched| matched.detector),
5663            Some("hex-credential-token"),
5664            "a versioned credential label must stay reachable through its \
5665             version fragments: {content:?}"
5666        );
5667    }
5668
5669    #[test]
5670    fn blocks_labeled_inline_marker_split_credential() {
5671        // The r2 medium probes: `marker:value` inline forms carrying a split
5672        // credential, with a credential label ahead of a value delimiter.
5673        // The clause guard disables the VCS exemption, and whole-token
5674        // normalized-hex accumulation fires on the fused token.
5675        for content in [
5676            concat!(
5677                "api token context: rev:",
5678                "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf",
5679                "\u{200B}",
5680                "0123456789abcdef01234567"
5681            )
5682            .to_string(),
5683            concat!(
5684                "api token context: ",
5685                "0123456789abcdef01234567",
5686                "\u{200B}",
5687                "rev:d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf"
5688            )
5689            .to_string(),
5690        ] {
5691            assert!(
5692                check(&content).is_err(),
5693                "a labeled inline-marker split credential must be blocked: \
5694                 {content:?}, got {:?}",
5695                scan(&content)
5696            );
5697        }
5698    }
5699
5700    #[test]
5701    fn blocks_forty_hex_behind_multiword_label_and_marker_delimiter() {
5702        // Coverage: qualifier nouns beyond the two-word case are
5703        // reachable once prepositions/possessives read as glue, and a
5704        // delimiter attached to the VCS marker itself ("deploy sha: <hex>")
5705        // is assignment syntax like any other delimiter.
5706        let revision = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
5707        for content in [
5708            format!("api key for deploy sha: {revision}"),
5709            format!("prod api key for deploy sha: {revision}"),
5710            format!("api key for production deploy: commit {revision}"),
5711        ] {
5712            assert_eq!(
5713                scan(&content).map(|matched| matched.detector),
5714                Some("hex-credential-token"),
5715                "a natural multiword credential label must not be hidden by \
5716                 glue words or a marker-attached delimiter: {content:?}"
5717            );
5718        }
5719    }
5720
5721    #[test]
5722    fn blocks_slash_base64_behind_possessive_qualified_label() {
5723        let content = "api key for our production deploy: Xk9mZ2vQpLrT8nJwYuA/HfBsDcGiONvMabcdefgh";
5724        assert_eq!(
5725            scan(content).map(|matched| matched.detector),
5726            Some("high-entropy-token"),
5727            "possessive-qualified credential label must refuse the path \
5728             exemption"
5729        );
5730    }
5731
5732    #[test]
5733    fn blocks_forty_hex_behind_participial_adjective_qualifier() {
5734        // Coverage: a past-participle word in ADJECTIVE position
5735        // (followed by a content noun: "shared deploy", "encrypted backup")
5736        // is a label qualifier, not verb-phrase prose, and must not end the
5737        // walk.
5738        let revision = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
5739        let content = format!("api key for shared deploy: commit {revision}");
5740        assert_eq!(
5741            scan(&content).map(|matched| matched.detector),
5742            Some("hex-credential-token"),
5743            "a participial-adjective label qualifier must stay walkable: \
5744             {content:?}"
5745        );
5746    }
5747
5748    #[test]
5749    fn blocks_slash_base64_behind_participial_adjective_qualifier() {
5750        let content = "api key for encrypted backup: Xk9mZ2vQpLrT8nJwYuA/HfBsDcGiONvMabcdefgh";
5751        assert_eq!(
5752            scan(content).map(|matched| matched.detector),
5753            Some("high-entropy-token"),
5754            "a participial-adjective label qualifier must refuse the path \
5755             exemption"
5756        );
5757    }
5758
5759    #[test]
5760    fn blocks_forty_hex_behind_chained_qualifier_label() {
5761        // Coverage: a chain of qualifiers between the value and
5762        // the trigger ("shared encrypted deploy") must not exhaust the walk
5763        // before the label head is reached. Any per-clause content-word cap
5764        // re-admits this bypass one qualifier past the cap.
5765        let revision = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
5766        let content = format!("api key for shared encrypted deploy: commit {revision}");
5767        assert_eq!(
5768            scan(&content).map(|matched| matched.detector),
5769            Some("hex-credential-token"),
5770            "a chained-qualifier credential label must stay walkable: \
5771             {content:?}"
5772        );
5773    }
5774
5775    #[test]
5776    fn blocks_slash_base64_behind_chained_qualifier_label() {
5777        let content =
5778            "api key for shared encrypted deploy: Xk9mZ2vQpLrT8nJwYuA/HfBsDcGiONvMabcdefgh";
5779        assert_eq!(
5780            scan(content).map(|matched| matched.detector),
5781            Some("high-entropy-token"),
5782            "a chained-qualifier credential label must refuse the path \
5783             exemption"
5784        );
5785    }
5786
5787    #[test]
5788    fn blocks_over_limit_qualified_label_fails_closed() {
5789        // Coverage: labels whose clause exhausts the walk budget
5790        // (a marker, an extra qualifier, or an interleaved glue word pushes
5791        // the trigger past the limit). Exhaustion after a value delimiter
5792        // fails closed — clause length must not launder a labeled credential
5793        // into the exemptions.
5794        let revision = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
5795        let opaque = "Xk9mZ2vQpLrT8nJwYuA/HfBsDcGiONvMabcdefgh";
5796        for content in [
5797            format!("api key for the new shared encrypted staging deploy: commit {revision}"),
5798            format!("api key for the new shared encrypted regional staging deploy: {opaque}"),
5799            format!("api key for the new shared and encrypted staging deploy: {opaque}"),
5800            format!(
5801                "api key for the new shared encrypted regional staging deploy: commit {revision}"
5802            ),
5803            format!("api key for the new shared and encrypted staging deploy: commit {revision}"),
5804            format!("api key for the new shared encrypted staging deploy: {opaque}"),
5805        ] {
5806            assert!(
5807                check(&content).is_err(),
5808                "an over-limit qualified credential label must fail closed: \
5809                 {content:?}, got {:?}",
5810                scan(&content)
5811            );
5812        }
5813    }
5814
5815    #[test]
5816    fn accepted_false_positive_topical_trigger_before_delimited_path() {
5817        // The clause walk treats ANY reachable pre-delimiter trigger as a
5818        // credential label — it has no grammar to tell a label head ("api
5819        // key ...") from a topical object ("testing auth against parser").
5820        // Distinguishing them would reopen the labeled-value bypasses, so
5821        // this ordinary prose shape blocks. Accepted false positive,
5822        // conservative direction; documented in docs/api/secret_gate.md.
5823        let content = "results from testing auth against parser: \
5824             internal/workspaces/20260701/cloud-rebuild/R1-repo-audit.md";
5825        assert!(
5826            check(content).is_err(),
5827            "accepted-FP contract changed: topical trigger before a \
5828             delimited path no longer blocks — update the docs if deliberate"
5829        );
5830    }
5831
5832    #[test]
5833    fn blocks_participle_before_trigger_word() {
5834        // Ordering contract: a past-participle word BEFORE the trigger never
5835        // matters — the walk reaches the trigger first. Pinned so the
5836        // verb-position rule cannot regress into shielding these labels.
5837        let revision = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
5838        let opaque = "Xk9mZ2vQpLrT8nJwYuA/HfBsDcGiONvMabcdefgh";
5839        for content in [
5840            format!("shared key: {revision}"),
5841            format!("generated api key: {revision}"),
5842            format!("encrypted token = {opaque}"),
5843        ] {
5844            assert!(
5845                check(&content).is_err(),
5846                "a participle before the trigger word must not shield the \
5847                 label: {content:?}, got {:?}",
5848                scan(&content)
5849            );
5850        }
5851    }
5852
5853    #[test]
5854    fn accepted_false_positive_docs_path_behind_attributive_trigger_and_delimiter() {
5855        // "auth setup: <path>" carries a trigger word in clause range ahead
5856        // of a value delimiter; the walk cannot distinguish an attributive
5857        // trigger ("auth setup") from a label head ("api key ...") without
5858        // reopening the labeled-value bypasses, so this ordinary prose shape
5859        // blocks. Accepted false positive — conservative direction under the
5860        // threat model; documented in docs/api/secret_gate.md.
5861        let content = "see the docs for auth setup: \
5862             internal/workspaces/20260701/cloud-rebuild/R1-repo-audit.md";
5863        assert!(
5864            check(content).is_err(),
5865            "accepted-FP contract changed: attributive trigger before a \
5866             delimited path no longer blocks — update the docs if deliberate"
5867        );
5868    }
5869
5870    #[test]
5871    fn allows_verb_phrase_prose_with_delimiter_before_trigger_word() {
5872        // Past-participle content words are verb-phrase evidence: the clause
5873        // narrates an action on the value instead of labeling it. These are
5874        // the false-positive shapes the exemptions exist for, and they must
5875        // survive the marker-attached-delimiter and glue-word widenings.
5876        let revision = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
5877        for content in [
5878            format!("one extra token was introduced by sha: {revision}"),
5879            format!("the api key was rotated. deploy notes reference commit {revision}"),
5880            format!("api key updated: commit {revision}"),
5881        ] {
5882            assert!(
5883                check(&content).is_ok(),
5884                "verb-phrase prose must keep the VCS exemption: {content:?}, \
5885                 got {:?}",
5886                scan(&content)
5887            );
5888        }
5889    }
5890
5891    #[test]
5892    fn allows_unlabeled_unknown_connector_without_delimiter() {
5893        // VCS coordinates retain the strict no-delimiter tier: an identifier
5894        // outside the connector set ends the walk. The bounded bridge used by
5895        // file paths must not re-block this ordinary revision prose.
5896        let revision = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
5897        for content in [
5898            format!("the key changes are in commit {revision}"),
5899            format!("deployed at commit {revision}"),
5900        ] {
5901            assert!(
5902                check(&content).is_ok(),
5903                "prose without a value delimiter must keep the VCS exemption: \
5904                 {content:?}, got {:?}",
5905                scan(&content)
5906            );
5907        }
5908    }
5909
5910    #[test]
5911    fn blocks_direct_no_delimiter_labels_for_both_file_path_value_families() {
5912        let values = [
5913            "Xk9mZ2vQpLrT8nJwYuA/HfBsDcGiONvMabcdefgh",
5914            "internal/workspaces/20260701/cloud-rebuild/R1-repo-audit.md",
5915        ];
5916
5917        for value in values {
5918            for label in ["api key found", "auth scanner found", "secret note"] {
5919                let content = format!("{label} {value}");
5920                assert!(
5921                    check(&content).is_err(),
5922                    "a direct no-delimiter label must refuse the file-path exemption: \
5923                     {content:?}, got {:?}",
5924                    scan(&content)
5925                );
5926            }
5927        }
5928    }
5929
5930    #[test]
5931    fn blocks_direct_gerund_and_see_labels_for_both_file_path_value_families() {
5932        let values = [
5933            "Xk9mZ2vQpLrT8nJwYuA/HfBsDcGiONvMabcdefgh",
5934            "internal/workspaces/20260701/cloud-rebuild/R1-repo-audit.md",
5935        ];
5936
5937        for value in values {
5938            for label in [
5939                "api key handling",
5940                "auth scanner handling",
5941                "api key see",
5942                "key: see",
5943            ] {
5944                let content = format!("{label} {value}");
5945                assert!(
5946                    check(&content).is_err(),
5947                    "narrative-looking adjacency must not shield a direct credential label: \
5948                     {content:?}, got {:?}",
5949                    scan(&content)
5950                );
5951            }
5952        }
5953    }
5954
5955    #[test]
5956    fn blocks_direct_participle_labels_for_both_file_path_value_families() {
5957        let values = [
5958            "Xk9mZ2vQpLrT8nJwYuA/HfBsDcGiONvMabcdefgh",
5959            "internal/workspaces/20260701/cloud-rebuild/R1-repo-audit.md",
5960        ];
5961
5962        for value in values {
5963            for label in ["api key leaked", "auth scanner leaked"] {
5964                let content = format!("{label} {value}");
5965                assert!(
5966                    check(&content).is_err(),
5967                    "step-zero participle adjacency must not shield a direct credential label: \
5968                     {content:?}, got {:?}",
5969                    scan(&content)
5970                );
5971            }
5972        }
5973    }
5974
5975    #[test]
5976    fn allows_no_delimiter_narrative_file_path_prose() {
5977        // The value-side `file`/`this` identifiers provide real walk context
5978        // before `flagged`; the step-zero direct-label tightening must not
5979        // remove this required narrative exemption.
5980        let content = "the auth scanner flagged this file \
5981            internal/workspaces/20260701/cloud-rebuild/R1-repo-audit.md";
5982
5983        assert!(
5984            check(content).is_ok(),
5985            "a regular participle in narrative position must preserve the path: got {:?}",
5986            scan(content)
5987        );
5988    }
5989
5990    #[test]
5991    fn suffix_word_hundred_is_not_a_narrative_participle() {
5992        assert!(is_clause_narrative_participle("flagged"));
5993        assert!(is_clause_narrative_participle("INTRODUCED"));
5994        assert!(!is_clause_narrative_participle("found"));
5995        assert!(!is_clause_narrative_participle("hundred"));
5996        assert!(is_clause_narrative_gerund("HANDLING"));
5997        assert!(!is_clause_narrative_gerund("found"));
5998
5999        for value in [
6000            "Xk9mZ2vQpLrT8nJwYuA/HfBsDcGiONvMabcdefgh",
6001            "internal/workspaces/20260701/cloud-rebuild/R1-repo-audit.md",
6002        ] {
6003            let content = format!("api key hundred: {value}");
6004            assert!(
6005                check(&content).is_err(),
6006                "a lexical -ed suffix must not shield a credential label: \
6007                 {content:?}, got {:?}",
6008                scan(&content)
6009            );
6010        }
6011    }
6012
6013    #[test]
6014    fn allows_vcs_reference_with_prose_boundary_before_trigger_word() {
6015        // A sentence or paragraph boundary between a trigger word and the
6016        // marker/value clause means the trigger is prose context, not this
6017        // value's label — the clause walk must stop at the boundary.
6018        let revision = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
6019        for content in [
6020            format!("configuration key\n\nrev {revision}."),
6021            format!("rotate the api key. the fix is commit {revision}"),
6022        ] {
6023            assert!(
6024                check(&content).is_ok(),
6025                "boundary-separated trigger prose must not block a VCS \
6026                 reference: {content:?}, got {:?}",
6027                scan(&content)
6028            );
6029        }
6030    }
6031
6032    #[test]
6033    fn blocks_sha_revision_marker_immediately_labeled_as_api_key() {
6034        let revision = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
6035        let content = format!("api_key sha:{revision}");
6036
6037        assert!(
6038            check(&content).is_err(),
6039            "credential-labeled SHA value must remain blocked: {content:?}"
6040        );
6041    }
6042
6043    #[test]
6044    fn blocks_rev_revision_marker_immediately_labeled_as_token() {
6045        let revision = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
6046        let content = format!("token rev:{revision}");
6047
6048        assert!(
6049            check(&content).is_err(),
6050            "credential-labeled revision value must remain blocked: {content:?}"
6051        );
6052    }
6053
6054    #[test]
6055    fn hex64_near_hash_context_allowed() {
6056        // A 64-char hex near "sha256" or "hash" — with no credential trigger —
6057        // must be allowed (content digest in normal prose).
6058        let hex64 = "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b";
6059        let sha_line = format!("sha256: {hex64}");
6060        let hash_line = format!("hash value {hex64}");
6061        assert!(
6062            scan(&sha_line).is_none(),
6063            "hex64 near 'sha256' must be allowed; fired: {:?}",
6064            scan(&sha_line)
6065        );
6066        assert!(
6067            scan(&hash_line).is_none(),
6068            "hex64 near 'hash' must be allowed; fired: {:?}",
6069            scan(&hash_line)
6070        );
6071    }
6072
6073    #[test]
6074    fn blocks_high_entropy_hex_like_token_near_key() {
6075        // A token whose character set exceeds pure hex (contains mixed-case, digits,
6076        // and non-hex chars) that ALSO passes `is_pure_hex = false` AND has high
6077        // entropy AND appears near "key" MUST be caught.  This is the realistic
6078        // real-world case: hex-looking API tokens often mix case and non-hex chars.
6079        // Example: a 32-char mixed-charset token near "api key".
6080        let mixed = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM"; // gitleaks:allow — not pure hex
6081        assert!(!is_pure_hex(mixed), "test token must not be pure hex");
6082        let line = format!("api key {mixed}");
6083        assert!(
6084            scan(&line).is_some(),
6085            "mixed-charset high-entropy token near 'api key' must be caught; got: {:?}",
6086            scan(&line)
6087        );
6088    }
6089
6090    #[test]
6091    fn allows_hex40_without_trigger() {
6092        // 40-char hex string in a neutral context (no trigger word) must still pass —
6093        // it's likely a git commit SHA or content hash.
6094        let hex40 = "da39a3ee5e6b4b0d3255bfef95601890afd80709";
6095        let line = format!("commit: {hex40}");
6096        assert!(
6097            scan(&line).is_none(),
6098            "40-char hex without trigger word must pass; fired: {:?}",
6099            scan(&line)
6100        );
6101    }
6102
6103    // ── check_json scans object keys ─────────────────────────────────────────
6104
6105    #[test]
6106    fn check_json_blocks_secret_in_object_key() {
6107        // A credential used as a JSON object key (not a value) must be caught.
6108        let props = serde_json::json!({ "ghp_FakeGitHubToken0000000000000000000": "redacted" }); // gitleaks:allow
6109        assert!(
6110            check_json(&props).is_err(),
6111            "credential as JSON object key must be blocked"
6112        );
6113    }
6114
6115    #[test]
6116    fn check_json_blocks_nested_secret_key() {
6117        // Nested credential key must be caught.
6118        let props = serde_json::json!({
6119            "metadata": {
6120                "AKIAFAKEKEY000000000": "value" // gitleaks:allow
6121            }
6122        });
6123        assert!(
6124            check_json(&props).is_err(),
6125            "nested credential as JSON object key must be blocked"
6126        );
6127    }
6128
6129    // ── PEM masking format ───────────────────────────────────────────────────
6130
6131    #[test]
6132    fn pem_masked_excerpt_reflects_block_length_not_rest_of_string() {
6133        let header = ["-----BEGIN RSA", " PRIVATE KEY-----"].concat(); // gitleaks:allow
6134        let fake = format!(
6135            "{}\nMIIEo\u{2026}\n-----END RSA PRIVATE KEY-----\nsome trailing text that is very long",
6136            header
6137        );
6138        let m = scan(&fake).unwrap();
6139        assert_eq!(m.detector, "pem-private-key");
6140        // The masked length should reflect only the key block, not the whole string.
6141        // "some trailing text that is very long" is ~37 chars; total string is much longer.
6142        // The block ends after "-----END RSA PRIVATE KEY-----\n".
6143        // We just verify it is shorter than the full string length.
6144        let full_len = fake.chars().count();
6145        let reported_len: usize = m
6146            .masked
6147            .trim_end_matches("chars")
6148            .rsplit("...")
6149            .next()
6150            .and_then(|s| s.parse().ok())
6151            .unwrap_or(full_len + 1);
6152        assert!(
6153            reported_len < full_len,
6154            "masked length ({reported_len}) should be less than full string length ({full_len})"
6155        );
6156    }
6157
6158    // ── UTF-8 char-boundary reproduction tests ───────────────────────────────
6159    //
6160    // These tests verify that no code path in secret_gate panics when multibyte
6161    // UTF-8 characters (emoji, CJK, accented Latin) appear at positions where
6162    // byte-level slicing could land mid-codepoint.  Each test targets a specific
6163    // code path.  A panic means the bug is live; a pass means the path is safe.
6164
6165    /// `build_match` masked preview: if the detected candidate starts with
6166    /// multibyte chars the "first 6 chars" preview must not slice on a byte
6167    /// boundary that falls mid-codepoint.  build_match already uses
6168    /// `chars().take(6)`, but we exercise it with emoji-prefixed candidates.
6169    #[test]
6170    fn utf8_build_match_preview_multibyte_prefix_no_panic() {
6171        // "🔑" = 4 bytes; repeat 3 times = 12 bytes for only 3 chars.
6172        // A ghp_-prefixed token with an emoji: let's construct a scenario where
6173        // a known-prefix secret is immediately adjacent to multibyte content so
6174        // that build_match receives a slice starting at a multibyte char.
6175        // PEM block with multibyte chars in the body exercises build_match on a
6176        // candidate that may contain non-ASCII.
6177        let header = ["-----BEGIN RSA", " PRIVATE KEY-----"].concat(); // gitleaks:allow
6178        let fake = format!("{}\n🔑密钥\n-----END RSA PRIVATE KEY-----", header);
6179        // Must not panic; mask must not echo full body.
6180        let m = scan(&fake);
6181        assert!(m.is_some(), "PEM with emoji body must still be caught");
6182        let m = m.unwrap();
6183        assert!(
6184            !m.masked.contains("🔑密钥"),
6185            "mask must not echo the emoji body"
6186        );
6187    }
6188
6189    /// `extract_token` called with a string starting with multibyte chars:
6190    /// the FlyV1 handler calls `extract_token(&text[payload_start..])` where
6191    /// `payload_start` is just past "FlyV1 " (ASCII).  If the payload is ASCII
6192    /// this is trivially safe, but we verify it cannot panic when the rest of
6193    /// the text after the payload contains multibyte chars.
6194    #[test]
6195    fn utf8_extract_token_multibyte_suffix_no_panic() {
6196        // "FlyV1 ABCDEFGHIJ密钥" — the payload is "ABCDEFGHIJ密钥"; extract_token
6197        // must stop at the ideographic chars (which are NOT ASCII whitespace) and
6198        // return the whole glued run without panicking.
6199        let text = "FlyV1 ABCDEFGHIJ密钥";
6200        // scan() must not panic.
6201        let _ = scan(text);
6202    }
6203
6204    /// `find_prefix_token` with multibyte chars immediately before and after
6205    /// the known prefix: checks text[..abs] boundary slices and
6206    /// extract_token(&text[abs..]) do not panic.
6207    #[test]
6208    fn utf8_prefix_detector_multibyte_adjacent_no_panic() {
6209        // 🔑 (4 bytes) immediately before AKIA: boundary at abs = 4, which is a
6210        // valid char boundary (end of the emoji).  extract_token sees ASCII from abs.
6211        let text = "🔑AKIAFAKEKEY00000000000000";
6212        let _ = scan(text); // must not panic
6213
6214        // é (U+00E9 = 2 bytes) immediately before ghp_:
6215        let text2 = "éghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
6216        let _ = scan(text2); // must not panic
6217
6218        // Emoji immediately after the token — extract_token ends at the emoji
6219        // (non-whitespace, but non-ASCII acts as delimiter in entropy heuristic).
6220        // For prefix tokens extract_token stops at ASCII whitespace only, so the
6221        // emoji would be included in the token length measurement.
6222        let text3 = "AKIAFAKEKEY00000000000000🔑";
6223        let _ = scan(text3); // must not panic
6224    }
6225
6226    /// `find_jwt` with multibyte chars as "whitespace" adjacent to a JWT-like
6227    /// candidate: `i = end + 1` could skip into a multibyte char if `end`
6228    /// pointed at a non-ASCII byte.  The position() search only looks for ASCII
6229    /// whitespace bytes, so a multibyte space (U+3000) is NOT found — `end`
6230    /// equals bytes.len() and `i = bytes.len() + 1` exits the loop.  Still
6231    /// verify no panic on CJK-surrounded JWT-like content.
6232    #[test]
6233    fn utf8_jwt_multibyte_adjacent_no_panic() {
6234        // A (fake) JWT-like triple surrounded by CJK text.
6235        let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.FAKE_SIG_XXXXXXXXXXXX"; // gitleaks:allow
6236        let text = format!("数据{jwt}密钥");
6237        let _ = scan(&text); // must not panic
6238
6239        // JWT followed by ideographic space (U+3000 = 3 bytes 0xE3 0x80 0x80) —
6240        // not matched by the ASCII-whitespace position() search.
6241        let text2 = format!("{jwt}\u{3000}morecontent");
6242        let _ = scan(&text2); // must not panic
6243
6244        // JWT followed by emoji
6245        let text3 = format!("{jwt}🔑");
6246        let _ = scan(&text3); // must not panic
6247    }
6248
6249    /// `find_url_userinfo` with multibyte chars between "://" and "@":
6250    /// `at_pos` from `rest.find('@')` and `colon` from `userinfo.find(':')` are
6251    /// ASCII markers (char boundaries), but `scheme_start` calculation uses
6252    /// char_indices().rev() which must handle multibyte chars in the scheme
6253    /// prefix correctly.
6254    #[test]
6255    fn utf8_url_userinfo_multibyte_scheme_no_panic() {
6256        // CJK glued to a credential URL — the scheme_start walker must not place
6257        // the start inside a multibyte codepoint.
6258        let cases = [
6259            "🔑postgresql://dbuser:S3cr3tP4ss@db.example.com/db", // gitleaks:allow
6260            "密钥mysql://root:hunter2pw@10.0.0.1:3306/app",       // gitleaks:allow
6261            "éredis://svc:V3ryS3cretPw@cache.internal:6379",      // gitleaks:allow
6262        ];
6263        for text in &cases {
6264            // Must not panic and must detect the credential.
6265            let result = scan(text);
6266            assert!(
6267                result.is_some(),
6268                "URL credential after multibyte must be caught: {text:?}"
6269            );
6270        }
6271    }
6272
6273    /// `check_entropy_heuristic` window slicing with multibyte content at the
6274    /// ±TRIGGER_WINDOW boundary: `floor_char_boundary` must prevent slicing
6275    /// on a non-char boundary.
6276    #[test]
6277    fn utf8_entropy_window_multibyte_boundary_no_panic() {
6278        // Construct content where the TRIGGER_WINDOW (120 bytes) boundary falls
6279        // inside a 3-byte CJK character.  Repeat "数" (U+6570 = 3 bytes) to fill
6280        // exactly 119 bytes, then add an ASCII trigger word + high-entropy token.
6281        // Window start: token_offset - 120 = lands inside one of the CJK chars.
6282        let cjk_fill = "数".repeat(39); // 39 × 3 = 117 bytes
6283        assert_eq!(cjk_fill.len(), 117);
6284        // Pad with 2 more ASCII chars ("xy") so that the 120-byte window lands at
6285        // byte 119 which is the second byte of the 40th "数" — mid-multibyte.
6286        let secret = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM1"; // gitleaks:allow
6287        let content = format!("{cjk_fill}xy key {secret}");
6288        let _ = scan(&content); // must not panic
6289
6290        // Also test the right edge: token ends at byte offset, window_end =
6291        // token_offset + raw_token.len() + 120 may land mid-multibyte.
6292        let content2 = format!("key {secret}{cjk_fill}xy");
6293        let _ = scan(&content2); // must not panic
6294    }
6295
6296    /// `check()` top-level fuzz: a large batch of inputs with multibyte
6297    /// characters at various offsets to catch any remaining panic sites.
6298    /// All results must be either Ok or Err (not a panic).
6299    #[test]
6300    fn utf8_no_panic_property_test() {
6301        let multibyte_items = [
6302            "🔑",       // 4-byte emoji
6303            "密",       // 3-byte CJK
6304            "é",        // 2-byte accented Latin
6305            "\u{3000}", // 3-byte ideographic space
6306            "🇺🇸",       // 8-byte emoji flag (two surrogate-like scalars)
6307        ];
6308        let secrets = [
6309            "AKIAFAKEKEY00000000000000".to_owned(),
6310            "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_owned(),
6311            anthropic_api_key_fixture(),
6312            "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM1".to_owned(),
6313            "FlyV1 fm2_AAAABBBBCCCCDDDDEEEEFFFF".to_owned(),
6314        ];
6315        for mb in &multibyte_items {
6316            for secret in &secrets {
6317                for sep in &["", " ", "\n"] {
6318                    // multibyte before secret
6319                    let s = format!("{mb}{sep}{secret}");
6320                    let _ = check(&s);
6321                    // multibyte after secret
6322                    let s = format!("{secret}{sep}{mb}");
6323                    let _ = check(&s);
6324                    // multibyte both sides
6325                    let s = format!("{mb}{sep}{secret}{sep}{mb}");
6326                    let _ = check(&s);
6327                    // repeated multibyte filling TRIGGER_WINDOW boundary
6328                    let fill = mb.repeat(50);
6329                    let s = format!("{fill} api_key {secret} {fill}");
6330                    let _ = check(&s);
6331                }
6332            }
6333        }
6334    }
6335
6336    // ── mask_secrets: in-place redaction reusing the canonical detector ───────
6337
6338    #[test]
6339    fn bounded_masked_log_text_masks_connection_string_credentials() {
6340        let raw =
6341            "gate backend probe failed: postgres://svc:not-a-real-secret@internal-host refused"; // gitleaks:allow
6342        let rendered = bounded_masked_log_text(raw);
6343        assert!(
6344            !rendered.contains("not-a-real-secret"),
6345            "credential must be masked: {rendered:?}"
6346        );
6347        assert!(
6348            rendered.contains("***MASKED***"),
6349            "masked marker must record that detail was redacted: {rendered:?}"
6350        );
6351        assert!(
6352            rendered.contains("gate backend probe failed"),
6353            "non-secret diagnostic prose must survive: {rendered:?}"
6354        );
6355    }
6356
6357    #[test]
6358    fn bounded_masked_log_text_bounds_pathological_input() {
6359        let raw = "x".repeat(MAX_LOG_TEXT_OUTPUT_CHARS + 500);
6360        let rendered = bounded_masked_log_text(&raw);
6361        assert!(
6362            rendered.chars().count() <= MAX_LOG_TEXT_OUTPUT_CHARS + 1,
6363            "output must be bounded: {} chars",
6364            rendered.chars().count()
6365        );
6366        assert!(
6367            rendered.ends_with('…'),
6368            "truncated output must declare its own truncation"
6369        );
6370
6371        let short = "gate policy file unreadable";
6372        assert_eq!(
6373            bounded_masked_log_text(short),
6374            short,
6375            "short clean text passes through unchanged"
6376        );
6377    }
6378
6379    #[test]
6380    fn bounded_masked_log_text_masks_low_entropy_password_past_old_truncation_bound() {
6381        // Regression: masking used to run AFTER truncating the raw input to a
6382        // few KB, so a connection string whose password ran past that bound
6383        // lost its terminating `@` before the url-userinfo detector (a
6384        // shape match, not an entropy one) ever saw it — a low-entropy
6385        // password can't trip the entropy heuristic either, so it leaked
6386        // verbatim into the log. Masking now runs on the full input first,
6387        // so a password far longer than the old 4096-char bound is still
6388        // caught.
6389        let long_low_entropy_password = "a".repeat(5_000);
6390        let raw = format!(
6391            "gate backend probe failed: postgres://svc:{long_low_entropy_password}@internal-host refused"
6392        );
6393        let rendered = bounded_masked_log_text(&raw);
6394        assert!(
6395            !rendered.contains(&"a".repeat(50)),
6396            "long low-entropy password must not survive in the log: {rendered:?}"
6397        );
6398        assert!(
6399            rendered.contains("***MASKED***"),
6400            "masked marker must record that the credential was redacted: {rendered:?}"
6401        );
6402    }
6403
6404    /// Regression for the crossing-boundary leak: a password longer than
6405    /// [`MAX_LOG_TEXT_MASK_INPUT_CHARS`] never gets a chance to show its
6406    /// terminating `@` to `find_url_userinfo` inside the truncated scan
6407    /// input, so the shape detector alone can never catch it — and it's
6408    /// deliberately low-entropy so the entropy heuristic can't catch it
6409    /// either. Only `redact_crossing_boundary_url_userinfo` can close this.
6410    #[test]
6411    fn bounded_masked_log_text_redacts_password_crossing_mask_input_cap() {
6412        let huge_low_entropy_password = "a".repeat(MAX_LOG_TEXT_MASK_INPUT_CHARS + 1000);
6413        let raw =
6414            format!("gate backend probe failed: postgres://svc:{huge_low_entropy_password}@internal-host refused");
6415        let rendered = bounded_masked_log_text(&raw);
6416        assert!(
6417            !rendered.contains(&"a".repeat(50)),
6418            "no password fragment may survive when the password crosses the mask-input cap: {rendered:?}"
6419        );
6420        assert!(
6421            rendered.contains("***MASKED***"),
6422            "mask marker must record that the credential was redacted: {rendered:?}"
6423        );
6424        assert!(
6425            rendered.ends_with('…'),
6426            "truncated record must declare its own incompleteness: {rendered:?}"
6427        );
6428    }
6429
6430    /// Regression: the crossing fallback follows the same empty-username
6431    /// rule as the canonical detector — `redis://:<password>` with the
6432    /// terminating `@` beyond the mask-input cap must still be redacted.
6433    #[test]
6434    fn bounded_masked_log_text_redacts_crossing_empty_username_password() {
6435        let huge_low_entropy_password = "b".repeat(MAX_LOG_TEXT_MASK_INPUT_CHARS + 1000);
6436        let raw = format!(
6437            "gate backend probe failed: redis://:{huge_low_entropy_password}@internal-host refused"
6438        );
6439        let rendered = bounded_masked_log_text(&raw);
6440        assert!(
6441            !rendered.contains(&"b".repeat(50)),
6442            "no empty-username password fragment may survive the cap crossing: {rendered:?}"
6443        );
6444        assert!(
6445            rendered.contains("***MASKED***"),
6446            "mask marker must record that the credential was redacted: {rendered:?}"
6447        );
6448    }
6449
6450    /// Regression: the crossing fallback shares the canonical detector's
6451    /// authority boundary — a colon after `/`, `?`, or `#` is path/query
6452    /// text, so a capped input whose only colon-at pair sits in the path
6453    /// must NOT be masked even when the `@` lies beyond the cap.
6454    #[test]
6455    fn bounded_masked_log_text_keeps_path_colon_text_crossing_the_cap() {
6456        let filler = "z".repeat(MAX_LOG_TEXT_MASK_INPUT_CHARS + 1000);
6457        let raw = format!("see https://host/a:x{filler}@next for details");
6458        let rendered = bounded_masked_log_text(&raw);
6459        assert!(
6460            !rendered.contains("***MASKED***"),
6461            "path-colon text must not read as a crossing credential: {rendered:?}"
6462        );
6463        assert!(
6464            rendered.starts_with("see https://host/a:x"),
6465            "the non-credential prefix must survive verbatim: {rendered:?}"
6466        );
6467    }
6468
6469    /// Regression: a crossing password that itself contains `://` must not
6470    /// let the fallback anchor at that nested delimiter. Anchoring at the
6471    /// last `://` would redact only from the nested span onward, leaving
6472    /// the real `user:<password prefix>` before it in the emitted log. The
6473    /// fallback must anchor at the earliest unterminated credential
6474    /// opening, so zero password characters survive.
6475    #[test]
6476    fn bounded_masked_log_text_redacts_crossing_password_containing_nested_scheme() {
6477        let mut password = "a".repeat(1000);
6478        password.push_str("://h:");
6479        password.push_str(&"b".repeat(MAX_LOG_TEXT_MASK_INPUT_CHARS + 1000));
6480        let raw =
6481            format!("gate backend probe failed: postgres://svc:{password}@internal-host refused");
6482        let rendered = bounded_masked_log_text(&raw);
6483        assert!(
6484            !rendered.contains(&"a".repeat(50)),
6485            "password prefix before the nested delimiter must not survive: {rendered:?}"
6486        );
6487        assert!(
6488            !rendered.contains(&"b".repeat(50)),
6489            "password tail after the nested delimiter must not survive: {rendered:?}"
6490        );
6491        assert!(
6492            rendered.contains("***MASKED***"),
6493            "mask marker must record that the credential was redacted: {rendered:?}"
6494        );
6495    }
6496
6497    /// Regression: a complete URL earlier in the text must not stop the
6498    /// fallback from catching a later credential run that crosses the cap.
6499    /// The earlier URL's span terminates inside the text (whitespace after
6500    /// it), so it is skipped; the later unterminated `user:<password-run>`
6501    /// is the one redacted.
6502    #[test]
6503    fn bounded_masked_log_text_redacts_crossing_credential_after_complete_url() {
6504        let huge_low_entropy_password = "c".repeat(MAX_LOG_TEXT_MASK_INPUT_CHARS + 1000);
6505        let raw = format!(
6506            "probe of https://ok-host/health failed; retry hit postgres://svc:{huge_low_entropy_password}@internal-host refused"
6507        );
6508        let rendered = bounded_masked_log_text(&raw);
6509        assert!(
6510            rendered.contains("ok-host"),
6511            "the earlier complete URL must survive untouched: {rendered:?}"
6512        );
6513        assert!(
6514            !rendered.contains(&"c".repeat(50)),
6515            "no password fragment may survive: {rendered:?}"
6516        );
6517        assert!(
6518            rendered.contains("***MASKED***"),
6519            "mask marker must record that the credential was redacted: {rendered:?}"
6520        );
6521    }
6522
6523    /// Regression: the crossing-boundary fallback must not fire when the
6524    /// password's terminating `@` sits safely inside the (untruncated)
6525    /// bounded input — the existing `find_url_userinfo` arm alone must
6526    /// still catch and mask it, with exactly one mask marker.
6527    #[test]
6528    fn bounded_masked_log_text_masks_password_just_under_cap_with_terminating_at() {
6529        let password_under_cap = "a".repeat(MAX_LOG_TEXT_MASK_INPUT_CHARS - 1000);
6530        let raw = format!(
6531            "gate backend probe failed: postgres://svc:{password_under_cap}@internal-host refused"
6532        );
6533        assert!(
6534            raw.chars().count() < MAX_LOG_TEXT_MASK_INPUT_CHARS,
6535            "test precondition: whole input must stay under the mask-input cap so no truncation occurs"
6536        );
6537        let rendered = bounded_masked_log_text(&raw);
6538        assert!(
6539            !rendered.contains(&"a".repeat(50)),
6540            "terminated credential must still be masked normally: {rendered:?}"
6541        );
6542        assert_eq!(
6543            rendered.matches("***MASKED***").count(),
6544            1,
6545            "exactly one mask marker — the crossing fallback must not fire early: {rendered:?}"
6546        );
6547    }
6548
6549    /// Regression: giant truncated text with no `://user:` shape at all must
6550    /// pass through unmodified by the crossing-boundary fallback — it must
6551    /// not manufacture a false redaction out of ordinary non-credential
6552    /// prose that happens to be long enough to hit the mask-input cap.
6553    #[test]
6554    fn bounded_masked_log_text_giant_non_credential_text_unaffected_by_crossing_fallback() {
6555        let raw = "x".repeat(MAX_LOG_TEXT_MASK_INPUT_CHARS + 1000);
6556        let rendered = bounded_masked_log_text(&raw);
6557        assert!(
6558            !rendered.contains("***MASKED***"),
6559            "non-credential text must never be redacted: {rendered:?}"
6560        );
6561        assert!(
6562            rendered.ends_with('…'),
6563            "truncated record must still declare its own incompleteness: {rendered:?}"
6564        );
6565        assert!(
6566            rendered.starts_with("xxxx"),
6567            "non-credential content must survive verbatim up to the output bound: {rendered:?}"
6568        );
6569    }
6570
6571    /// Regression: an ordinary short connection string (well under both
6572    /// caps) must keep being masked exactly as before — the crossing
6573    /// fallback is gated on truncation and must never touch this path.
6574    #[test]
6575    fn bounded_masked_log_text_masks_ordinary_short_connection_string() {
6576        let raw = "gate backend probe failed: postgres://svc:hunter2pw@internal-host refused"; // gitleaks:allow
6577        let rendered = bounded_masked_log_text(raw);
6578        assert!(
6579            !rendered.contains("hunter2pw"),
6580            "credential must be masked: {rendered:?}"
6581        );
6582        assert_eq!(
6583            rendered.matches("***MASKED***").count(),
6584            1,
6585            "exactly one mask marker for the one credential: {rendered:?}"
6586        );
6587        assert!(
6588            !rendered.ends_with('…'),
6589            "short untruncated text must not declare truncation: {rendered:?}"
6590        );
6591    }
6592
6593    #[test]
6594    fn bounded_masked_log_text_neutralizes_ascii_control_chars() {
6595        let raw = "line one\r\ninjected: \u{1b}[31mFAKE ALERT\u{1b}[0m line two";
6596        let rendered = bounded_masked_log_text(raw);
6597        assert!(
6598            !rendered.contains('\r') && !rendered.contains('\n'),
6599            "CR/LF must be neutralized so a single log line cannot be split/forged: {rendered:?}"
6600        );
6601        assert!(
6602            !rendered.contains('\u{1b}'),
6603            "ESC control character must be neutralized: {rendered:?}"
6604        );
6605        assert!(
6606            rendered.contains("line one") && rendered.contains("line two"),
6607            "surrounding prose must survive neutralization: {rendered:?}"
6608        );
6609    }
6610
6611    #[test]
6612    fn bounded_masked_log_text_neutralizes_each_unsafe_unicode_category() {
6613        let cases = [
6614            ("Cc", '\u{1b}', "\\u{001b}"),
6615            ("Cf", '\u{202e}', "\\u{202e}"),
6616            ("Zl", '\u{2028}', "\\u{2028}"),
6617            ("Zp", '\u{2029}', "\\u{2029}"),
6618        ];
6619
6620        for (category, unsafe_char, escaped) in cases {
6621            let raw = format!("before{unsafe_char}after");
6622            assert_eq!(
6623                bounded_masked_log_text(&raw),
6624                format!("before{escaped}after"),
6625                "Unicode category {category} must be neutralized"
6626            );
6627        }
6628    }
6629
6630    #[test]
6631    fn bounded_masked_log_text_keeps_space_separators_tabs_accented_and_cjk_text() {
6632        let raw = "ordinary whitespace\tcafé résumé 日本語のテキスト\u{3000}数据库连接管理";
6633        assert_eq!(
6634            bounded_masked_log_text(raw),
6635            raw,
6636            "ordinary whitespace, Zs separators, accented text, and CJK prose must pass through unmodified"
6637        );
6638    }
6639
6640    #[test]
6641    fn mask_secrets_borrows_clean_text() {
6642        let clean = "The FlashAttention paper introduces IO-aware tiling.";
6643        let masked = mask_secrets(clean);
6644        assert!(
6645            matches!(masked, std::borrow::Cow::Borrowed(_)),
6646            "clean text must not allocate"
6647        );
6648        assert_eq!(masked, clean);
6649    }
6650
6651    #[test]
6652    fn named_redaction_surfaces_are_permanently_mask_only() {
6653        let contracts = [
6654            (RedactionSurface::GitIngest, Some(GIT_INGEST_STORED_TARGET)),
6655            (
6656                RedactionSurface::SessionMirror,
6657                Some(SESSION_MIRROR_STORED_TARGET),
6658            ),
6659            (RedactionSurface::McpDiagnostic, None),
6660            (RedactionSurface::GateProbe, None),
6661        ];
6662
6663        for (surface, expected_target) in contracts {
6664            let contract = redaction_surface_contract(surface);
6665            assert_eq!(contract.mode, RedactionSurfaceMode::PermanentMaskOnly);
6666            assert_eq!(contract.final_stored_target, expected_target);
6667            assert_eq!(contract.stamp_property, None);
6668            assert_eq!(contract.atomic_success_event, None);
6669        }
6670    }
6671
6672    #[test]
6673    fn named_redaction_surfaces_mask_without_exemption_admission() {
6674        let content = format!("credential: {}", openai_project_key_fixture());
6675
6676        for surface in [
6677            RedactionSurface::GitIngest,
6678            RedactionSurface::SessionMirror,
6679            RedactionSurface::McpDiagnostic,
6680            RedactionSurface::GateProbe,
6681        ] {
6682            let masked = mask_for_redaction_surface(surface, &content);
6683            assert!(masked.contains(REDACTION_MARKER));
6684            assert!(check(masked.as_ref()).is_ok());
6685        }
6686    }
6687
6688    #[test]
6689    fn mask_bounded_matches_unbounded_masking_when_input_fits_the_window() {
6690        let content = format!("credential: {}", openai_project_key_fixture());
6691        let expected = mask_for_redaction_surface(RedactionSurface::McpDiagnostic, &content);
6692        let result = mask_bounded(RedactionSurface::McpDiagnostic, &content, 4_096, 1_024);
6693        assert_eq!(result.text, expected.as_ref());
6694        assert!(!result.truncated);
6695        assert!(result.redacted);
6696    }
6697
6698    #[test]
6699    fn mask_bounded_masks_a_credential_whose_terminator_sits_inside_the_window() {
6700        // The window is far larger than the message, and the credential's
6701        // terminating `@` sits well inside it — the ordinary case where the
6702        // masker sees the whole token and can recognize its shape.
6703        let password = format!("PlainPassMarker{}", "q".repeat(24));
6704        let url = format!("postgres://svc:{password}@internal-host.example.test/db");
6705        let message = format!("backend probe failed: {url}");
6706        assert!(message.chars().count() < 200, "fixture must fit the window");
6707
6708        let result = mask_bounded(RedactionSurface::McpDiagnostic, &message, 200, 100);
6709        assert!(
6710            !result.text.contains("PlainPassMarker"),
6711            "credential must be masked: {}",
6712            result.text
6713        );
6714        assert!(result.text.contains("***MASKED***"));
6715        assert!(result.redacted);
6716    }
6717
6718    #[test]
6719    fn mask_bounded_drops_a_token_straddling_the_window_boundary_without_partial_leak() {
6720        // Constructed so the credential token starts before the window ends
6721        // but its terminating `@` lands well past it — a masker restricted
6722        // to the window can never observe that `@`, so a truncate-then-mask
6723        // policy (the pre-fix behavior at this call site) would recognize no
6724        // span and let the visible prefix, including the marker below,
6725        // survive untouched in the output. Bounding the input before
6726        // masking must not reopen that hole: since the token has no internal
6727        // whitespace, the fix drops it whole rather than emitting any
6728        // fragment of it.
6729        let window_chars = 200;
6730        let marker = "StraddleMarkerXYZ789";
6731        let padding = "q".repeat(window_chars + 100);
6732        let password = format!("{marker}{padding}");
6733        let url = format!("postgres://svc:{password}@internal-host.example.test/db");
6734        let message = format!("benign leading prose here {url}");
6735
6736        let at_offset = message.find('@').expect("fixture must contain '@'");
6737        assert!(
6738            at_offset > window_chars,
6739            "credential must straddle the window"
6740        );
6741
6742        let result = mask_bounded(RedactionSurface::McpDiagnostic, &message, window_chars, 100);
6743        assert!(
6744            !result.text.contains(marker),
6745            "no fragment of the credential may survive: {}",
6746            result.text
6747        );
6748        assert!(
6749            !result.text.contains("postgres://"),
6750            "the straddling token must be dropped whole, not partially echoed: {}",
6751            result.text
6752        );
6753        assert!(result.truncated);
6754    }
6755
6756    #[test]
6757    fn mask_bounded_replaces_a_single_oversized_token_with_the_truncation_marker_alone() {
6758        // One token (no whitespace anywhere) longer than the window: there is
6759        // no earlier whitespace to fall back to, so nothing from the window
6760        // can be shown safely.
6761        let window_chars = 50;
6762        let text = "q".repeat(window_chars * 4);
6763
6764        let result = mask_bounded(RedactionSurface::McpDiagnostic, &text, window_chars, 20);
6765        assert_eq!(result.text, "…");
6766        assert!(result.truncated);
6767        assert!(result.redacted);
6768    }
6769
6770    #[test]
6771    fn mask_bounded_bounds_output_to_the_window_regardless_of_input_size() {
6772        // Several megabytes of benign, whitespace-separated text. A
6773        // truncate-then-mask or mask-then-truncate policy alike would still
6774        // need to allocate and scan the full input before this function ever
6775        // gets to cap the *output*; the point of `mask_bounded` is that the
6776        // *masker* itself only ever sees `window_chars`. That is asserted
6777        // here as a length invariant (never by wall-clock): the returned
6778        // text can never exceed the window, no matter how large the input.
6779        let window_chars = 4_096;
6780        let huge = "benign word ".repeat(500_000);
6781        assert!(huge.len() > 5_000_000, "fixture must be several megabytes");
6782
6783        let result = mask_bounded(RedactionSurface::McpDiagnostic, &huge, window_chars, 500);
6784        assert!(result.truncated);
6785        assert!(result.text.chars().count() <= 501);
6786    }
6787
6788    /// A 40-hex-char credential (the SHA-1/git-SHA-doubled shape) built at
6789    /// test time by cycling a short literal, never committed whole.
6790    fn forty_char_hex_fixture() -> String {
6791        "a1b2c3d4e5f6".chars().cycle().take(40).collect()
6792    }
6793
6794    #[test]
6795    fn mask_bounded_masks_a_bridged_credential_whose_fragments_straddle_the_window_boundary() {
6796        // A 40-char hex credential split into two whitespace-separated
6797        // fragments, each individually too short (20 chars) to be
6798        // recognized as hex-credential-shaped or high-entropy on its own —
6799        // recoverable only by bridging the two fragments together. Expected
6800        // arm: before the fix, the window cut lands inside the second
6801        // fragment, the existing partial-token drop removes only that
6802        // fragment's remnant, and the first fragment (alone, unrecognizable)
6803        // survives visible in the bounded output.
6804        let hex = forty_char_hex_fixture();
6805        let (frag1, frag2) = hex.split_at(20);
6806        let message = format!("credential: {frag1} {frag2}");
6807
6808        // Control: the unbounded masker sees both fragments and masks the
6809        // reconstructed credential.
6810        let full = mask_for_redaction_surface(RedactionSurface::McpDiagnostic, &message);
6811        assert!(
6812            !full.contains(frag1) && !full.contains(frag2),
6813            "control: unbounded masking must reconstruct and mask the split credential: {full}"
6814        );
6815
6816        // Land the window a few characters into frag2, so the existing
6817        // partial-token drop removes only frag2's remnant.
6818        let window_chars = "credential: ".len() + frag1.len() + 1 + 5;
6819        assert!(
6820            window_chars < message.len(),
6821            "fixture must exceed the window"
6822        );
6823
6824        let result = mask_bounded(
6825            RedactionSurface::McpDiagnostic,
6826            &message,
6827            window_chars,
6828            window_chars,
6829        );
6830        assert!(result.truncated);
6831        assert!(
6832            !result.text.contains(frag1),
6833            "no fragment of a bridged credential may survive a window cut mid-chain: {:?}",
6834            result.text
6835        );
6836        assert!(result.redacted);
6837    }
6838
6839    #[test]
6840    fn mask_bounded_masks_a_bridged_credential_entirely_inside_the_window() {
6841        // Control: both fragments AND the rest of the message fit inside the
6842        // window — ordinary in-window bridge reconstruction, unaffected by
6843        // the boundary-drop logic.
6844        let hex = forty_char_hex_fixture();
6845        let (frag1, frag2) = hex.split_at(20);
6846        let message = format!("credential: {frag1} {frag2} trailing prose after the secret");
6847        let window_chars = message.chars().count() + 10;
6848
6849        let result = mask_bounded(
6850            RedactionSurface::McpDiagnostic,
6851            &message,
6852            window_chars,
6853            window_chars,
6854        );
6855        assert!(!result.truncated);
6856        assert!(!result.text.contains(frag1));
6857        assert!(!result.text.contains(frag2));
6858    }
6859
6860    #[test]
6861    fn mask_bounded_never_reveals_a_bridged_credential_entirely_outside_the_window() {
6862        // Control: the window cut lands well before the credential even
6863        // starts, so neither fragment is ever read into the window.
6864        let hex = forty_char_hex_fixture();
6865        let (frag1, frag2) = hex.split_at(20);
6866        let prefix = "benign leading prose that pads well past the cut point here ";
6867        let message = format!("{prefix}credential: {frag1} {frag2}");
6868        let window_chars = prefix.chars().count() - 10;
6869
6870        let result = mask_bounded(
6871            RedactionSurface::McpDiagnostic,
6872            &message,
6873            window_chars,
6874            window_chars,
6875        );
6876        assert!(!result.text.contains(frag1));
6877        assert!(!result.text.contains(frag2));
6878    }
6879
6880    #[test]
6881    fn mask_bounded_masks_a_bridged_credential_whose_trigger_follows_the_window() {
6882        // Three-way split (14/13/13 chars) of a 40-char hex credential, each
6883        // fragment individually too short to be recognized alone. The only
6884        // trigger word sits AFTER the last fragment ("... is the api key
6885        // for ..."), never inside the truncated window. Expected arm
6886        // (pre-fix): `trailing_bridge_fragment_cut` gated its backward walk
6887        // on a trigger word in the window, and the window here carries no
6888        // trigger at all — the walk never ran, so frag1 and frag2 (both
6889        // read whole into the window) survived the partial-token drop and
6890        // leaked.
6891        let hex = forty_char_hex_fixture();
6892        let (frag1, rest) = hex.split_at(14);
6893        let (frag2, frag3) = rest.split_at(13);
6894        let message = format!("values: {frag1} {frag2} {frag3} is the api key for the service");
6895
6896        // Control: the unbounded masker sees the trigger after the
6897        // fragments and still reconstructs and masks the whole credential.
6898        let full = mask_for_redaction_surface(RedactionSurface::McpDiagnostic, &message);
6899        assert!(
6900            !full.contains(frag1) && !full.contains(frag2) && !full.contains(frag3),
6901            "control: unbounded masking must reconstruct and mask the split \
6902             credential even though its trigger word comes after the \
6903             fragments: {full}"
6904        );
6905
6906        // Land the window a few characters into frag3, so the existing
6907        // partial-token drop removes only frag3's remnant and leaves frag1
6908        // and frag2 whole in the window; the trigger word stays entirely
6909        // outside the window.
6910        let window_chars = "values: ".len() + frag1.len() + 1 + frag2.len() + 1 + 5;
6911        assert!(
6912            window_chars < message.len(),
6913            "fixture must exceed the window"
6914        );
6915        assert!(
6916            find_trigger(&message[..window_chars], false).is_none(),
6917            "fixture must carry no trigger word inside the window"
6918        );
6919
6920        let result = mask_bounded(
6921            RedactionSurface::McpDiagnostic,
6922            &message,
6923            window_chars,
6924            window_chars,
6925        );
6926        assert!(result.truncated);
6927        assert!(
6928            !result.text.contains(frag1) && !result.text.contains(frag2),
6929            "no fragment of a bridged credential may survive a window cut \
6930             mid-chain, even when the credential's only trigger word lies \
6931             past the window boundary: {:?}",
6932            result.text
6933        );
6934    }
6935
6936    #[test]
6937    fn mask_bounded_keeps_a_non_fragment_tail_of_an_untriggered_truncated_window() {
6938        // No trigger word anywhere in the message, and the tokens at the
6939        // tail of the truncated window are ordinary short words (each under
6940        // `MIN_BRIDGE_FRAGMENT_LEN`) rather than fragment-shaped. The
6941        // unconditional backward walk must still leave them alone: nothing
6942        // at the tail looks like a bridged credential fragment.
6943        let prefix = "benign status update about the lazy owls and cats over ";
6944        let message = format!("{prefix}here while more prose keeps going past the window");
6945        assert!(
6946            find_trigger(&message, false).is_none(),
6947            "fixture must carry no trigger word"
6948        );
6949        let window_chars = prefix.chars().count() + 2;
6950
6951        let result = mask_bounded(
6952            RedactionSurface::McpDiagnostic,
6953            &message,
6954            window_chars,
6955            window_chars,
6956        );
6957        assert!(result.truncated);
6958        assert_eq!(result.text, format!("{prefix}{TRUNCATION_MARKER}"));
6959    }
6960
6961    #[test]
6962    fn mask_bounded_drops_a_fragment_shaped_tail_of_an_untriggered_truncated_window() {
6963        // No trigger word anywhere in the message. A single fragment-shaped
6964        // identifier (alphanumeric, >= MIN_BRIDGE_FRAGMENT_LEN) sits whole
6965        // in the window, followed by a short word that gets cut mid-token by
6966        // the boundary. Documents the trade the unconditional walk makes:
6967        // the walk cannot tell this lone identifier apart from a genuine
6968        // bridged fragment, so it drops it too even though nothing is
6969        // actually chained to it and no trigger word is anywhere nearby.
6970        let prefix = "an ordinary status line about the current build before ";
6971        let identifier = "deadbeefcafefeed01234567";
6972        let message = format!("{prefix}{identifier} zzzzzzzzzz");
6973        assert!(
6974            find_trigger(&message, false).is_none(),
6975            "fixture must carry no trigger word"
6976        );
6977        assert!(identifier.len() >= MIN_BRIDGE_FRAGMENT_LEN);
6978
6979        let window_chars = prefix.chars().count() + identifier.chars().count() + 1 + 3;
6980        assert!(
6981            window_chars < message.len(),
6982            "fixture must exceed the window"
6983        );
6984
6985        let result = mask_bounded(
6986            RedactionSurface::McpDiagnostic,
6987            &message,
6988            window_chars,
6989            window_chars,
6990        );
6991        assert!(result.truncated);
6992        assert!(
6993            !result.text.contains(identifier),
6994            "a lone fragment-shaped tail token is dropped even without a \
6995             chained neighbor or a trigger word: {:?}",
6996            result.text
6997        );
6998    }
6999
7000    #[test]
7001    fn mask_bounded_keeps_the_whole_token_prefix_of_an_untriggered_sentence_cut_mid_word() {
7002        // No trigger word anywhere in the message: the boundary-drop must
7003        // never fire, so the existing partial-token-drop behavior is
7004        // unchanged and nothing extra is dropped.
7005        let prefix = "the quick brown fox jumps over the lazy ";
7006        let message = format!("{prefix}dogs while writing documentation");
7007        assert!(
7008            find_trigger(&message, false).is_none(),
7009            "fixture must carry no trigger word"
7010        );
7011        let window_chars = prefix.chars().count() + 2;
7012
7013        let result = mask_bounded(
7014            RedactionSurface::McpDiagnostic,
7015            &message,
7016            window_chars,
7017            window_chars,
7018        );
7019        assert!(result.truncated);
7020        assert_eq!(result.text, format!("{prefix}{TRUNCATION_MARKER}"));
7021    }
7022
7023    #[test]
7024    #[cfg(debug_assertions)]
7025    #[should_panic(expected = "the input window must stay at least as large as the output cap")]
7026    fn mask_bounded_debug_asserts_when_window_is_smaller_than_output_cap() {
7027        let _ = mask_bounded(
7028            RedactionSurface::McpDiagnostic,
7029            "some diagnostic text",
7030            10,
7031            50,
7032        );
7033    }
7034
7035    #[test]
7036    #[cfg(not(debug_assertions))]
7037    fn mask_bounded_clamps_output_cap_to_window_chars_outside_debug_assertions() {
7038        // Inverted pair (window_chars < output_cap_chars): outside debug
7039        // assertions this must not panic, and the returned text must
7040        // respect window_chars as the effective cap, never the larger
7041        // output_cap_chars a misconfigured call site passed in. Uses a
7042        // degenerate `scheme://user:pass@host` short enough (9 chars) that
7043        // its `***MASKED***` replacement (12 chars) GROWS past window_chars
7044        // — the one case where an uncapped `output_cap_chars` would let the
7045        // returned text exceed the window it was supposed to be bounded by.
7046        let window_chars = 9;
7047        let output_cap_chars = 500;
7048        let text = "a://b:c@d"; // scheme=a, user=b, pass=c, host=d — 9 chars, fits the window whole
7049        assert_eq!(
7050            text.len(),
7051            window_chars,
7052            "fixture must exactly fill the window"
7053        );
7054
7055        let result = mask_bounded(
7056            RedactionSurface::McpDiagnostic,
7057            text,
7058            window_chars,
7059            output_cap_chars,
7060        );
7061        assert!(
7062            result.text.chars().count() <= window_chars + 1,
7063            "output must never exceed the window (plus one truncation-marker char) \
7064             even when output_cap_chars is misconfigured larger than window_chars: {:?}",
7065            result.text
7066        );
7067    }
7068
7069    #[test]
7070    fn mask_secrets_redacts_shapes_the_old_mirror_regex_missed() {
7071        // These are exactly the detectors the session mirror's previous local
7072        // regex did NOT cover, which is why it now shares this masker.
7073        let cases = [
7074            format!("key: {}", openai_project_key_fixture()),
7075            "cred ASIAFAKEKEY00000000000".to_owned(), // gitleaks:allow
7076            "stripe sk_live_FAKESTRIPE0000000000000".to_owned(), // gitleaks:allow
7077            "db postgresql://dbuser:S3cr3tP4ss@db.example.com/db".to_owned(), // gitleaks:allow
7078        ];
7079        for c in &cases {
7080            let masked = mask_secrets(c);
7081            assert!(
7082                masked.contains(REDACTION_MARKER),
7083                "must redact: {c:?} -> {masked:?}"
7084            );
7085        }
7086    }
7087
7088    #[test]
7089    fn mask_secrets_redacts_every_span_and_keeps_prose() {
7090        let line = format!(
7091            "first {} then ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA end",
7092            anthropic_api_key_fixture()
7093        );
7094        let masked = mask_secrets(&line);
7095        assert!(
7096            !masked.contains("sk-ant-api03") && !masked.contains("ghp_AAAA"),
7097            "no secret may survive: {masked}"
7098        );
7099        assert_eq!(
7100            masked.matches(REDACTION_MARKER).count(),
7101            2,
7102            "both secrets must be redacted: {masked}"
7103        );
7104        assert!(masked.starts_with("first "), "prose preserved: {masked}");
7105        assert!(masked.ends_with(" end"), "prose preserved: {masked}");
7106    }
7107
7108    #[test]
7109    fn mask_secrets_public_api_redacts_unscanned_dense_tail() {
7110        let token = "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
7111        let segment = format!("{token} keep ");
7112        // Keep this fixture independent of implementation-only constants: the
7113        // pre-bound implementation must compile with the test present.
7114        let line = segment.repeat(512);
7115        assert!(
7116            line.len() >= 20_000,
7117            "fixture must exceed the cumulative scan-work threshold"
7118        );
7119
7120        let masked = mask_secrets(&line);
7121        assert!(
7122            !masked.contains(token),
7123            "no credential may survive fail-closed tail redaction"
7124        );
7125        assert!(
7126            masked.matches("***MASKED***").count() < line.matches(token).count(),
7127            "the public masker must redact the unscanned tail wholesale"
7128        );
7129        assert!(
7130            masked.ends_with("***MASKED***"),
7131            "the fail-closed tail redaction must reach the end of the public result"
7132        );
7133    }
7134
7135    // White-box complement only: the public test above is the independent
7136    // guard for the fail-closed work bound.
7137    #[test]
7138    fn mask_secrets_tokenizes_concentrated_tail_once() {
7139        let token = "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
7140        let prefix = "clean ".repeat(1_000_000 / "clean ".len());
7141        let tail: String = (0..200).map(|_| format!("{token} ")).collect();
7142        let line = format!("{prefix}{tail}");
7143        assert_eq!(line.matches(token).count(), 200);
7144
7145        ENTROPY_TOKENIZATION_COUNT.with(|count| count.set(0));
7146        let masked = mask_secrets(&line);
7147        let tokenization_count = ENTROPY_TOKENIZATION_COUNT.with(|count| count.get());
7148
7149        assert!(
7150            !masked.contains(token),
7151            "the public masker must redact every concentrated-tail credential"
7152        );
7153        assert_eq!(
7154            tokenization_count, 1,
7155            "the full input token vector must be built once, not once per tail credential"
7156        );
7157    }
7158
7159    #[test]
7160    fn mask_secrets_output_passes_check() {
7161        // The masked output must itself be clean — no credential left for the
7162        // write-time gate to catch.
7163        let line = "token=ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA and AKIAFAKEKEY1234567890";
7164        let masked = mask_secrets(line).into_owned();
7165        assert!(
7166            check(&masked).is_ok(),
7167            "masked output must pass the gate: {masked}"
7168        );
7169    }
7170
7171    #[test]
7172    fn mask_secrets_redacts_entropy_secret_left_of_known_secret() {
7173        // Cross-layer leftmost regression: a Layer-2 entropy secret sits to the
7174        // LEFT of a Layer-1 known-prefix secret. A scan that short-circuits on
7175        // the first known match (or returns first-by-detector-priority) would
7176        // redact `ghp_…` and copy the entropy token before it verbatim — leaking
7177        // it. `scan_match` must fold both layers through leftmost selection.
7178        let line =
7179            "secret=Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM and ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; // gitleaks:allow
7180        let masked = mask_secrets(line).into_owned();
7181        assert!(
7182            !masked.contains("Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM") && !masked.contains("ghp_AAAA"),
7183            "neither the entropy secret nor the known secret may survive: {masked}"
7184        );
7185        assert_eq!(
7186            masked.matches(REDACTION_MARKER).count(),
7187            2,
7188            "both secrets must be redacted exactly once: {masked}"
7189        );
7190        assert!(
7191            check(&masked).is_ok(),
7192            "masked output must pass the gate: {masked}"
7193        );
7194    }
7195
7196    #[test]
7197    fn github_app_token_families_are_masked() {
7198        // ghu_ (user-to-server), ghs_ (server-to-server), and ghr_ (refresh)
7199        // GitHub App tokens are real credential families. They are
7200        // context-free: no trigger word needed.
7201        let cases = [
7202            "ghu_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", // gitleaks:allow
7203            "ghs_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",  // gitleaks:allow
7204            "ghr_CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC",  // gitleaks:allow
7205        ];
7206        for token in &cases {
7207            assert!(
7208                check(token).is_err(),
7209                "gate must hard-block GitHub App token {token}"
7210            );
7211            let line = format!("auth: {token} trailing");
7212            let masked = mask_secrets(&line).into_owned();
7213            assert!(
7214                !masked.contains(token),
7215                "GitHub App token must not survive masking: {masked}"
7216            );
7217            assert!(
7218                check(&masked).is_ok(),
7219                "masked output must pass the gate: {masked}"
7220            );
7221        }
7222    }
7223
7224    #[test]
7225    fn mask_secrets_redacts_entropy_token_whose_trigger_is_left_of_earlier_secret() {
7226        // The entropy detector only fires near a
7227        // trigger word. When the trigger (`api_key`) sits to the LEFT of an
7228        // earlier known-prefix secret (`ghp_…`), a masker that rescans only the
7229        // suffix after each redaction loses that context and leaks the later
7230        // high-entropy token. Spans must be discovered against the ORIGINAL text.
7231        let line =
7232            "api_key ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM1"; // gitleaks:allow
7233        let masked = mask_secrets(line).into_owned();
7234        assert!(
7235            !masked.contains("ghp_AAAA"),
7236            "the known secret must be redacted: {masked}"
7237        );
7238        assert!(
7239            !masked.contains("Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM1"),
7240            "the later entropy token must be redacted even though its trigger \
7241             word sits left of the earlier redaction: {masked}"
7242        );
7243        assert_eq!(
7244            masked.matches(REDACTION_MARKER).count(),
7245            2,
7246            "both secrets must be redacted exactly once: {masked}"
7247        );
7248        assert!(
7249            check(&masked).is_ok(),
7250            "masked output must pass the gate: {masked}"
7251        );
7252    }
7253
7254    // ── Structured identifiers: file paths / branch names ───────────────────
7255
7256    #[test]
7257    fn allows_high_entropy_file_path_near_secret_word() {
7258        let content =
7259            "workspace path fable-ops/ADR-DRAFT-adr079-slices234.md for the secret gate bug";
7260        assert!(
7261            check(content).is_ok(),
7262            "structured file path in technical prose must pass; got {:?}",
7263            scan(content)
7264        );
7265    }
7266
7267    #[test]
7268    fn allows_high_entropy_workspace_path_before_later_key_word() {
7269        let content =
7270            "see internal/workspaces/20260701/adr079-slices234/PACKET.md for the key behavior";
7271        assert!(
7272            check(content).is_ok(),
7273            "a path before a later topical key word must pass; got {:?}",
7274            scan(content)
7275        );
7276    }
7277
7278    #[test]
7279    fn allows_high_entropy_short_run_path_near_auth_word() {
7280        let content =
7281            "auth work saved at internal/workspaces/20260701/cloud-rebuild/R1-repo-audit.md";
7282        assert!(
7283            check(content).is_ok(),
7284            "path with a short run in technical prose must pass; got {:?}",
7285            scan(content)
7286        );
7287    }
7288
7289    #[test]
7290    fn allows_branch_and_review_filename_near_key_word() {
7291        let content =
7292            "branch feat-session-mirror pushed, see release_notes_v2.md for the key findings";
7293        assert!(
7294            check(content).is_ok(),
7295            "branch name and review filename near 'key' must not be blocked; fired: {:?}",
7296            scan(content)
7297        );
7298    }
7299
7300    #[test]
7301    fn allows_adr_doc_path_near_password_word() {
7302        let content = "password reset doc: docs/adr/ADR-055-epistemic-edge-relations.md";
7303        assert!(
7304            check(content).is_ok(),
7305            "ADR doc path near 'password' must not be blocked; fired: {:?}",
7306            scan(content)
7307        );
7308    }
7309
7310    #[test]
7311    fn allows_source_file_path_near_credential_word() {
7312        let content = "credential handling code crates/khive-pack-session/src/mirror/ingest.rs";
7313        assert!(
7314            check(content).is_ok(),
7315            "source file path near 'credential' must not be blocked; fired: {:?}",
7316            scan(content)
7317        );
7318    }
7319
7320    #[test]
7321    fn allows_long_snake_case_identifier_near_key_word() {
7322        let content = "api key handling lives in check_entropy_heuristic_impl";
7323        assert!(
7324            check(content).is_ok(),
7325            "snake_case identifier near 'key' must not be blocked; fired: {:?}",
7326            scan(content)
7327        );
7328    }
7329
7330    // ── Structured-identifier exemption: catch-suite regression ─────────────
7331
7332    #[test]
7333    fn hyphenated_random_secret_is_not_a_structured_identifier() {
7334        // Same token as `blocks_bare_base64url_43chars_near_key`: hyphenated
7335        // but not word-shaped. The second run exceeds the 24-char run cap,
7336        // and the first run's case-transition density (~0.42) exceeds the
7337        // 0.3 threshold on its own, so this must not be exempted and the
7338        // existing catch-suite test must keep blocking it.
7339        assert!(!is_structured_identifier(
7340            "wJalrXUtnFEMI-K7MDENGbPxRfiCYEXAMPLEKEYX123"
7341        ));
7342        let line = "api key wJalrXUtnFEMI-K7MDENGbPxRfiCYEXAMPLEKEYX123";
7343        assert!(
7344            scan(line).is_some(),
7345            "hyphenated random secret must still be blocked; got: {:?}",
7346            scan(line)
7347        );
7348    }
7349
7350    // ── Structured-identifier exemption: direct unit tests ───────────────────
7351
7352    #[test]
7353    fn structured_identifier_true_for_repro_paths() {
7354        let paths = [
7355            "fable-ops/ADR-DRAFT-adr079-slices234.md",
7356            "internal/workspaces/20260701/adr079-slices234/PACKET.md",
7357            "internal/workspaces/20260701/cloud-rebuild/R1-repo-audit.md",
7358            "release_notes_v2.md",
7359            "docs/adr/ADR-055-epistemic-edge-relations.md",
7360            "crates/khive-pack-session/src/mirror/ingest.rs",
7361            "check_entropy_heuristic_impl",
7362        ];
7363        for p in paths {
7364            assert!(
7365                is_structured_identifier(p),
7366                "expected structured identifier: {p}"
7367            );
7368        }
7369    }
7370
7371    #[test]
7372    fn structured_identifier_false_without_separator() {
7373        // No `/`, `-`, `_`, or `.` present — fails rule 1 outright.
7374        assert!(!is_structured_identifier(
7375            "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM"
7376        ));
7377    }
7378
7379    #[test]
7380    fn structured_identifier_false_for_leetspeak_digit_interleaving() {
7381        // Digits interleaved with letters within a run (not a trailing digit
7382        // suffix) fail the `[A-Za-z]+[0-9]*` / `[0-9]+` shape check.
7383        assert!(!is_structured_identifier("S3cr3t-P4ssw0rd-t0ken-here!"));
7384    }
7385
7386    #[test]
7387    fn structured_identifier_false_for_run_over_length_cap() {
7388        // A 26-char single alphabetic run between separators fails the
7389        // 24-char per-run length cap even though it is otherwise trivially
7390        // word-shaped (uniform lowercase, zero case transitions).
7391        let long_run = "a".repeat(26);
7392        let token = format!("prefix-{long_run}-suffix");
7393        assert!(!is_structured_identifier(&token));
7394    }
7395
7396    // ── Credential-labeled structured identifiers remain blocked ────────────
7397    // Shannon entropy over an attacker-chosen run boundary cannot distinguish an
7398    // English word from adversarially-chosen letters at the same length ceiling
7399    // (log2(run_len)), so no run-shape exemption is sound near a trigger. File paths
7400    // are exempt only after their runs pass the entropy/hex checks AND the path is
7401    // not immediately labeled as a credential value. The tests below guard that
7402    // credential-label boundary.
7403
7404    #[test]
7405    fn blocks_separator_secret_access_key_bypass() {
7406        // Adversarial bypass shape: an AWS-secret-key-like value split into
7407        // separator-delimited word-shaped runs to dodge the entropy check.
7408        let content = "secret_access_key abcdefghij/klmnopqrst/uvwxyzabcd/efghijk";
7409        assert!(
7410            check(content).is_err(),
7411            "AWS Secret Access Key shaped bypass must be blocked: {:?}",
7412            scan(content)
7413        );
7414    }
7415
7416    #[test]
7417    fn blocks_adversarial_lowercase_only_separator_token_near_access_key() {
7418        let content = "access_key qrstuvwxyz/abcdefghij/klmnopqrst/uvwxyzab";
7419        assert!(
7420            check(content).is_err(),
7421            "lowercase-only separator-delimited high-entropy token near \
7422             'access_key' must be blocked: {:?}",
7423            scan(content)
7424        );
7425    }
7426
7427    #[test]
7428    fn blocks_adversarial_digit_and_word_mixed_token_near_api_key() {
7429        // A mix of pure-digit runs and letters-then-digits runs (both
7430        // individually word-shaped) whose combined alphabet diversity crosses
7431        // the entropy threshold.
7432        let content = "api_key attaycofrsm827/festwqjhc493/8261947350/qwikjzx982";
7433        assert!(
7434            check(content).is_err(),
7435            "digit-and-word-mixed high-entropy token near 'api_key' must be blocked: {:?}",
7436            scan(content)
7437        );
7438    }
7439
7440    #[test]
7441    fn blocks_adversarial_token_assignment_separator_delimited_secret() {
7442        let content = "token=zxkqwmvbpl/trfhysjgnc/dweiaoutkz-mnbvcxzlk";
7443        assert!(
7444            check(content).is_err(),
7445            "token= with lowercase-only separator-delimited high-entropy value \
7446             must be blocked: {:?}",
7447            scan(content)
7448        );
7449    }
7450
7451    #[test]
7452    fn blocks_extension_suffix_bypass_secret_access_key() {
7453        // A file-extension check alone would exempt this: appending `.md`
7454        // to a random credential must not bypass detection.
7455        let content = "secret_access_key abcdefghij/klmnopqrst/uvwxyzabcd/efghijk.md";
7456        assert!(
7457            check(content).is_err(),
7458            "extension-suffixed AWS Secret Access Key shaped bypass must be blocked: {:?}",
7459            scan(content)
7460        );
7461    }
7462
7463    #[test]
7464    fn blocks_extension_suffix_bypass_token_assignment() {
7465        let content = "token=zxkqwmvbpl/trfhysjgnc/dweiaoutkz-mnbvcxzlk.rs";
7466        assert!(
7467            check(content).is_err(),
7468            "extension-suffixed token= bypass must be blocked: {:?}",
7469            scan(content)
7470        );
7471    }
7472
7473    #[test]
7474    fn blocks_unsuffixed_separator_split_credential_bypasses() {
7475        let cases = [
7476            "secret_access_key abcdefghij/klmnopqrst/uvwxyzabcd/efghijk",
7477            "token=zxkqwmvbpl/trfhysjgnc/dweiaoutkz-mnbvcxzlk",
7478        ];
7479        for content in cases {
7480            assert!(
7481                check(content).is_err(),
7482                "bypass string must still be blocked: {content:?}, got: {:?}",
7483                scan(content)
7484            );
7485        }
7486    }
7487
7488    #[test]
7489    fn blocks_digit_run_suffix_bypass_attempt() {
7490        let cases = [
7491            "secret_access_key abcdefghij/klmnopqrst/uvwxyzabcd/efghijk2024",
7492            "secret_access_key abcdefghij2024/klmnopqrst/uvwxyzabcd/efghijk.md",
7493        ];
7494        for content in cases {
7495            assert!(
7496                check(content).is_err(),
7497                "digit-run-suffixed bypass attempt must be blocked: {content:?}, got: {:?}",
7498                scan(content)
7499            );
7500        }
7501    }
7502
7503    #[test]
7504    fn blocks_low_entropy_padding_run_bypass_attempts() {
7505        // A low-entropy padding run (`aaaa`) inserted before short/digit-shaped
7506        // runs would drag any AVERAGE per-run entropy signal below its
7507        // threshold. With the exemption dropped entirely, these must be
7508        // blocked purely on full-token entropy, same as any other
7509        // near-trigger high-entropy token.
7510        let cases = [
7511            "secret_access_key abcdefghij/klmnopqrst/uvwxyzabcd/efghijk/aaaa/R1.md",
7512            "token=zxkqwmvbpl/trfhysjgnc/dweiaoutkz/mnbvcxzlk/aaaa/R1.rs",
7513            "secret_access_key abcdefghij/klmnopqrst/uvwxyzabcd/efghijk/aaaa/bbbb/R1.md",
7514            "token=zxkqwmvbpl/trfhysjgnc/dweiaoutkz/mnbvcxzlk/aaaa/bbbb/R1.rs",
7515        ];
7516        for content in cases {
7517            assert!(
7518                check(content).is_err(),
7519                "padding-run bypass attempt must be blocked: {content:?}, got: {:?}",
7520                scan(content)
7521            );
7522        }
7523    }
7524
7525    #[test]
7526    fn blocks_run_splitting_bypass_attempts() {
7527        // Splitting a credential into short (4-6 char) runs drives EVERY
7528        // run's own letters-only entropy toward log2(run_len), which ordinary
7529        // short English path words already sit at or near: this is exactly
7530        // why any per-run entropy ceiling is unsound as an exemption signal.
7531        // With the exemption dropped, these are blocked on full-token entropy
7532        // regardless of run shape.
7533        let cases = [
7534            "secret_access_key abcd/efgh/ijkl/mnop/qrst/uvwx/yzab/cdef.md",
7535            "secret_access_key abcde/fghij/klmno/pqrst/uvwxy/zabcd.md",
7536            "secret_access_key abcdef/ghijkl/mnopqr/stuvwx/yzabcd.md",
7537        ];
7538        for content in cases {
7539            assert!(
7540                check(content).is_err(),
7541                "run-splitting bypass attempt must be blocked: {content:?}, got: {:?}",
7542                scan(content)
7543            );
7544        }
7545    }
7546
7547    #[test]
7548    fn blocks_separator_split_generic_hex_credential_ascii() {
7549        // Two 20-char hex runs joined by `/` — individually
7550        // below MIN_ENTROPY_LEN (24) and neither is a HEX_CREDENTIAL_LENGTHS
7551        // length on its own; the whole token is not pure hex (the `/` breaks
7552        // it) and its entropy is capped below ENTROPY_THRESHOLD by the
7553        // 17-symbol hex-plus-separator alphabet, so none of the prior checks
7554        // caught it. Concatenating the two runs (dropping the separator)
7555        // normalizes to one 40-char hex sequence — a HEX_CREDENTIAL_LENGTHS
7556        // value.
7557        let content = "api key 0123456789abcdef0123/456789abcdef01234567";
7558        assert!(
7559            check(content).is_err(),
7560            "separator-split hex credential must be blocked: got {:?}",
7561            scan(content)
7562        );
7563    }
7564
7565    #[test]
7566    fn blocks_separator_split_generic_hex_credential_unicode_separator() {
7567        // Same shape as above, but the separator is a non-ASCII character
7568        // (U+200B zero-width space) instead of `/`: the tokenizer treats
7569        // every non-ASCII character as a delimiter (see the tokenizer
7570        // comment in `check_entropy_heuristic`), so this splits the payload
7571        // into TWO tokens rather than leaving it inside one — the
7572        // intra-token concatenation above never sees both halves together.
7573        // The adjacent-token bridge must catch it the same way.
7574        let content = "api key 0123456789abcdef0123\u{200B}456789abcdef01234567";
7575        assert!(
7576            check(content).is_err(),
7577            "Unicode-separator split hex credential must be blocked: got {:?}",
7578            scan(content)
7579        );
7580    }
7581
7582    #[test]
7583    fn blocks_unicode_split_hex_credential_with_path_shaped_anchor() {
7584        let content =
7585            "api key handling uses source/x/0123456789abcdef0123\u{200B}456789abcdef01234567";
7586        let tokens: Vec<(usize, &str)> = content
7587            .split(|c: char| c.is_ascii_whitespace() || !c.is_ascii())
7588            .filter(|token| !token.is_empty())
7589            .map(|token| (token.as_ptr() as usize - content.as_ptr() as usize, token))
7590            .collect();
7591        let anchor = tokens
7592            .iter()
7593            .position(|(_, token)| token.starts_with("source/"))
7594            .expect("path anchor must be tokenized");
7595        assert!(is_plausible_file_path(tokens[anchor].1));
7596        let fragments = bridge_fragment_chain(&tokens, content, anchor);
7597        assert_eq!(fragments.len(), 2);
7598        assert!(normalized_hex_credential_span(&fragments.join(" ")).is_some());
7599        assert!(
7600            check(content).is_err(),
7601            "path-shaped anchor must not bypass fragment reconstruction: got {:?}",
7602            scan(content)
7603        );
7604    }
7605
7606    #[test]
7607    fn blocks_separator_split_hex_credential_repeated_unicode_gap() {
7608        // Three U+200B zero-width spaces in a row
7609        // (9 bytes) would exceed a fixed byte-length gap bound (e.g. 8 bytes),
7610        // leaving the two 20-char hex halves unbridged. The fragment-chain
7611        // bridge is bounded by fragment COUNT (MAX_BRIDGE_FRAGMENTS), not
7612        // gap byte length, so repeating the delimiter buys an attacker
7613        // nothing: the gap between the two fragments still contains zero
7614        // ASCII alphanumeric characters, so it is still one bridgeable gap
7615        // regardless of how many times the delimiter repeats inside it.
7616        let content = "api key 0123456789abcdef0123\u{200B}\u{200B}\u{200B}456789abcdef01234567";
7617        assert!(
7618            check(content).is_err(),
7619            "repeated-Unicode-gap split hex credential must be blocked: got {:?}",
7620            scan(content)
7621        );
7622    }
7623
7624    #[test]
7625    fn blocks_separator_split_three_way_hex_credential_mixed_case() {
7626        // A 40-char mixed-case hex credential split
7627        // into THREE tokens by two single-U+200B gaps. Bridging only one
7628        // adjacent pair (`idx` with `idx + 1`, or `idx` with
7629        // `idx - 1`) would never reach the full
7630        // 40 chars. `bridge_fragment_chain` walks a bounded chain in both
7631        // directions, so starting from the first fragment reconstructs all
7632        // three. `is_ascii_hexdigit` accepts both cases, so the mixed-case
7633        // split (`AAAA...` alongside lowercase `cccc...`) must still
7634        // normalize to one 40-char hex sequence.
7635        let content = "api key AAAA1111bbbb22\u{200B}22cccc3333ddd\u{200B}d4444eeee5555";
7636        assert!(
7637            check(content).is_err(),
7638            "three-way mixed-case Unicode-split hex credential must be blocked: got {:?}",
7639            scan(content)
7640        );
7641    }
7642
7643    #[test]
7644    fn blocks_separator_split_base64_like_unicode_credential() {
7645        // A base64-like credential (mixed-case
7646        // alphanumeric, not hex) split by one U+200B into two 20-char
7647        // halves. A hex-only bridge candidacy gate would only admit pure-hex
7648        // short tokens, so neither half here (mixed-case, non-hex letters
7649        // like `X`, `k`, `Z`) ever reached the near-trigger bridge checks at
7650        // all. `is_bridge_candidate` now admits any short alphanumeric
7651        // token, and the reconstructed chain is checked against the SAME
7652        // whole-token entropy decision a genuine single-token high-entropy
7653        // candidate must clear — closing the hex-only gap without widening
7654        // detection to non-alphanumeric noise.
7655        let content = "api key Xk9mZ2vQpLrT8nJwYuAe\u{200B}HfBsDcGiONvMabcdefgh";
7656        assert!(
7657            check(content).is_err(),
7658            "base64-like Unicode-split credential must be blocked: got {:?}",
7659            scan(content)
7660        );
7661    }
7662
7663    #[test]
7664    fn blocks_punctuation_glue_between_two_unicode_gaps() {
7665        // Two 20-char hex fragments separated by a punctuation-only token (`---`)
7666        // sandwiched between two U+200B gaps: `is_delimiter_only_token` must let the
7667        // walk absorb `---` as glue (without counting it against MAX_BRIDGE_FRAGMENTS)
7668        // rather than stopping at it as a non-fragment token.
7669        let content = "api key 0123456789abcdef0123\u{200B}---\u{200B}456789abcdef01234567";
7670        assert!(
7671            check(content).is_err(),
7672            "punctuation-glue split hex credential between two Unicode gaps must be \
7673             blocked: got {:?}",
7674            scan(content)
7675        );
7676    }
7677
7678    #[test]
7679    fn allows_seven_way_hex_split_beyond_fragment_cap_documented_limitation() {
7680        // A 64-hex credential split into SEVEN
7681        // Unicode-separated fragments (each meeting MIN_BRIDGE_FRAGMENT_LEN)
7682        // exceeds MAX_BRIDGE_FRAGMENTS (6), so no chain the walk can build
7683        // ever reconstructs the full 64 chars. This is an ACCEPTED RESIDUAL
7684        // of the local-neighborhood bound, not a defect to fix here: per
7685        // ADR-096 / ADR-115 the secret gate is accidental-persistence
7686        // hygiene on a single-principal same-uid host, not defense against a
7687        // same-uid adversary hand-splitting a credential to evade it — that
7688        // adversary could write the DB directly instead. This test pins the
7689        // boundary so a future reader does not mistake it for an
7690        // unaddressed bypass.
7691        let content = "api key 012345678\u{200B}9abcdef01\u{200B}23456789a\u{200B}bcdef0123\u{200B}456789abc\u{200B}def012345\u{200B}6789abcdef";
7692        assert!(
7693            check(content).is_ok(),
7694            "seven-way hex split beyond MAX_BRIDGE_FRAGMENTS is a documented residual \
7695             limitation and must stay allowed: got {:?}",
7696            scan(content)
7697        );
7698    }
7699
7700    #[test]
7701    fn allows_six_way_sub_floor_hex_split_documented_limitation() {
7702        // A 40-hex credential split into six
7703        // Unicode-separated fragments each individually below
7704        // MIN_BRIDGE_FRAGMENT_LEN (8) — 7/7/7/7/6/6 characters. Every
7705        // fragment fails `is_bridge_candidate`, so none ever reaches
7706        // `bridge_fragment_chain` in the first place. Same accepted-residual
7707        // rationale as the seven-way split above: a same-uid adversary
7708        // splitting fragments this small to evade the gate can equally
7709        // write the DB directly. This test pins the boundary.
7710        let content = "api key 0123456\u{200B}789abcd\u{200B}ef01234\u{200B}56789ab\u{200B}cdef01\u{200B}234567";
7711        assert!(
7712            check(content).is_ok(),
7713            "six-way sub-MIN_BRIDGE_FRAGMENT_LEN hex split is a documented residual \
7714             limitation and must stay allowed: got {:?}",
7715            scan(content)
7716        );
7717    }
7718
7719    #[test]
7720    fn allows_unrelated_short_fragments_cited_near_a_trigger_word() {
7721        // False-positive guard: ordinary prose
7722        // citing two SEPARATE short hex/base64-ish identifiers (e.g. two
7723        // unrelated git SHA prefixes) near a trigger word must NOT combine
7724        // into a block just because a delimiter-only gap between them makes
7725        // them bridge-eligible. Each fragment is well under
7726        // MIN_BRIDGE_FRAGMENT_LEN's credential-length neighborhood, and the
7727        // reconstructed concatenation (16 chars) is neither a
7728        // HEX_CREDENTIAL_LENGTHS value nor at MIN_ENTROPY_LEN (24), so it
7729        // must stay allowed exactly like a real single fragment that short
7730        // would.
7731        let content = "api key: see commits abc12345, def67890 for the fix";
7732        assert!(
7733            check(content).is_ok(),
7734            "unrelated short fragments cited near a trigger word must stay allowed: \
7735             fired {:?}",
7736            scan(content)
7737        );
7738    }
7739
7740    #[test]
7741    fn allows_unrelated_short_base64_like_fragments_cited_near_a_trigger_word() {
7742        // Same guard as above, for the newly-widened non-hex/base64-like
7743        // bridge path specifically: two short mixed-case alphanumeric build
7744        // identifiers separated by a plain space near a trigger word.
7745        // Reconstructed length (12 chars) is far under MIN_ENTROPY_LEN (24),
7746        // so the generic entropy reconstruction must not fire.
7747        let content = "api key: build ids Ab3Kf9 and Xy7Lm2 do not match";
7748        assert!(
7749            check(content).is_ok(),
7750            "unrelated short base64-like fragments cited near a trigger word must \
7751             stay allowed: fired {:?}",
7752            scan(content)
7753        );
7754    }
7755
7756    #[test]
7757    fn allows_scattered_short_hex_runs_that_do_not_sum_to_a_credential_length() {
7758        // False-positive guard: short hex-looking runs
7759        // that happen to sit near a trigger word must NOT be flagged just
7760        // because they exist — only when their normalized concatenation
7761        // actually lands on a HEX_CREDENTIAL_LENGTHS value. Three
7762        // independent 8-char runs (running total 8, 16, 24) never hit
7763        // 32/40/64/128, and the whole-token entropy check that follows stays
7764        // below ENTROPY_THRESHOLD for this path-shaped content.
7765        let content = "auth config lives in abc12345/de678901/fa234567.md";
7766        assert!(
7767            check(content).is_ok(),
7768            "scattered short hex runs that never sum to a credential length \
7769             must stay allowed: fired {:?}",
7770            scan(content)
7771        );
7772    }
7773
7774    #[test]
7775    fn allows_fp_paths_whose_full_token_entropy_is_already_below_threshold() {
7776        // 4 of the 7 original FP-repro paths stay OK near a trigger word even
7777        // with NO structured-identifier exemption at all, because their own
7778        // full-token Shannon entropy already reads below ENTROPY_THRESHOLD
7779        // (4.5) — the exemption was never load-bearing for these regardless
7780        // of which version of it existed.
7781        let paths = [
7782            "release_notes_v2.md",
7783            "docs/adr/ADR-055-epistemic-edge-relations.md",
7784            "crates/khive-pack-session/src/mirror/ingest.rs",
7785            "check_entropy_heuristic_impl",
7786        ];
7787        for p in paths {
7788            let content = format!("api_key handling in {p}");
7789            assert!(
7790                check(&content).is_ok(),
7791                "{p} must stay allowed near 'api_key' (full-token entropy already \
7792                 below threshold): fired {:?}",
7793                scan(&content)
7794            );
7795        }
7796    }
7797
7798    #[test]
7799    fn allows_adr_draft_path_near_trigger() {
7800        let content = "api_key handling in fable-ops/ADR-DRAFT-adr079-slices234.md";
7801        assert!(
7802            check(content).is_ok(),
7803            "ADR-DRAFT path in technical prose must pass; got {:?}",
7804            scan(content)
7805        );
7806    }
7807
7808    #[test]
7809    fn allows_workspace_packet_path_near_trigger() {
7810        let content = "api_key handling in internal/workspaces/20260701/adr079-slices234/PACKET.md";
7811        assert!(
7812            check(content).is_ok(),
7813            "workspace path in technical prose must pass; got {:?}",
7814            scan(content)
7815        );
7816    }
7817
7818    #[test]
7819    fn allows_high_entropy_repo_audit_path_near_api_key() {
7820        let content =
7821            "api_key handling in internal/workspaces/20260701/cloud-rebuild/R1-repo-audit.md";
7822        assert!(
7823            check(content).is_ok(),
7824            "repository path in technical prose must pass; got {:?}",
7825            scan(content)
7826        );
7827    }
7828
7829    #[test]
7830    fn allows_source_paths_near_ordinary_key_and_token_prose() {
7831        let contents = [
7832            "see <a/path/to/file.py>:~97-103 lists it as a real checkpoint-supplied key",
7833            "see <a/path/to/file.py>:~97-103 emits one extra token",
7834            "see /workspace/src/checkpoint_loader.rs:97-103\n\nconfiguration key behavior",
7835            "the token behavior is implemented in src/runtime/secret_gate.rs:618-914.",
7836        ];
7837        for content in contents {
7838            assert!(
7839                check(content).is_ok(),
7840                "source path in technical prose must pass: {content:?}, got {:?}",
7841                scan(content)
7842            );
7843        }
7844    }
7845
7846    #[test]
7847    fn allows_adr_authorization_path_in_markdown_ingest() {
7848        let content =
7849            "- Evidence: `docs/adr/C-ADR-007-authorization-server.md:70` defines token handling.";
7850        assert!(
7851            check(content).is_ok(),
7852            "ADR path citation in technical markdown must pass: got {:?}",
7853            scan(content)
7854        );
7855    }
7856
7857    #[test]
7858    fn blocks_markdown_ingest_bare_opaque_value_near_token() {
7859        let content = concat!(
7860            "- Review evidence for token handling: ",
7861            "Xk9mZ2vQpLrT8nJwYuAeHfBs",
7862            "DcGiONvM1qPrStUvWxYz23456789"
7863        );
7864        assert_eq!(
7865            scan(content).map(|matched| matched.detector),
7866            Some("high-entropy-token"),
7867            "bare opaque value near token must remain blocked"
7868        );
7869    }
7870
7871    // ── UUID / content-hash allowlists are prose-context only ───────────────
7872
7873    #[test]
7874    fn blocks_uuid_directly_labeled_as_api_key() {
7875        let content = "api_key 550e8400-e29b-41d4-a716-446655440000";
7876        assert!(
7877            check(content).is_err(),
7878            "UUID-shaped token labeled api_key must be blocked; got {:?}",
7879            scan(content)
7880        );
7881    }
7882
7883    #[test]
7884    fn blocks_sha256_content_hash_labeled_as_secret() {
7885        let content = "secret sha256-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq";
7886        assert!(
7887            check(content).is_err(),
7888            "sha256-prefixed hash labeled secret must be blocked; got {:?}",
7889            scan(content)
7890        );
7891    }
7892
7893    #[test]
7894    fn blocks_sha384_content_hash_labeled_as_api_key() {
7895        let content =
7896            "api_key sha384-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
7897        assert!(
7898            check(content).is_err(),
7899            "sha384-prefixed hash labeled api_key must be blocked; got {:?}",
7900            scan(content)
7901        );
7902    }
7903
7904    #[test]
7905    fn blocks_sha512_content_hash_labeled_as_auth() {
7906        let content = "auth sha512-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/ABCDEFGHIJKLMNOPQRSTUV";
7907        assert!(
7908            check(content).is_err(),
7909            "sha512-prefixed hash labeled auth must be blocked; got {:?}",
7910            scan(content)
7911        );
7912    }
7913
7914    #[test]
7915    fn allows_uuid_with_no_trigger_within_window() {
7916        // Common benign shape: a UUID (e.g. an internal record id) with no
7917        // credential trigger word anywhere in the surrounding window stays
7918        // allowed — the allowlist still applies outside trigger context.
7919        let content =
7920            "task 550e8400-e29b-41d4-a716-446655440000 was created and assigned to the team";
7921        assert!(
7922            check(content).is_ok(),
7923            "UUID with no nearby trigger word must stay allowed; got {:?}",
7924            scan(content)
7925        );
7926    }
7927
7928    #[test]
7929    fn allows_area_id_uuid_near_authorized_substring() {
7930        // An internal task `area_id` UUID field sitting within the trigger
7931        // window of the SUBSTRING "auth" inside
7932        // `authorized_write_requires_dominance` is not a genuine mention of
7933        // the word "auth": it is a pure substring collision with
7934        // "authorized". Bare trigger words match at a word boundary (see
7935        // `contains_bounded_word`), so `auth` does not match inside
7936        // `authorized`; this UUID has no trigger in its window and passes via
7937        // the ordinary out-of-context UUID allowlist.
7938        let content = "area_id: cfcea31d-6f50-4fd1-ad6d-5f160de1694c\n\n## Problem\nReduce Lion microkernel axioms. Converted authorized_write_requires_dominance from axiom to theorem.";
7939        assert!(
7940            check(content).is_ok(),
7941            "internal area_id UUID near the 'authorized' substring \
7942             (not a genuine 'auth' mention) must now pass; got {:?}",
7943            scan(content)
7944        );
7945    }
7946
7947    #[test]
7948    fn allows_uuid_on_line_after_benign_token_contract_title() {
7949        let content = "Design language and token contract\n550e8400-e29b-41d4-a716-446655440000";
7950        assert!(
7951            check(content).is_ok(),
7952            "a generic token-contract title must not make a next-line UUID look like a secret; \
7953             got {:?}",
7954            scan(content)
7955        );
7956    }
7957
7958    #[test]
7959    fn generic_token_uuid_exemption_keeps_strong_credential_controls() {
7960        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
7961        let uuid = "550e8400-e29b-41d4-a716-446655440000";
7962        let cases = [
7963            (format!("service token {opaque}"), "high-entropy-token"),
7964            (format!("token={opaque}"), "high-entropy-token"),
7965            (format!("token={uuid}"), "uuid-near-trigger"),
7966            (format!("api_key {uuid}"), "uuid-near-trigger"),
7967        ];
7968
7969        for (content, detector) in cases {
7970            assert_eq!(
7971                scan(&content).map(|matched| matched.detector),
7972                Some(detector),
7973                "credential-shaped control must remain blocked: {content:?}"
7974            );
7975        }
7976    }
7977
7978    // ── UUID/hash value extraction from assignment and wrapper syntax ───────
7979
7980    #[test]
7981    fn blocks_uuid_glued_to_assignment_equals() {
7982        let content = "api_key=550e8400-e29b-41d4-a716-446655440000";
7983        assert!(
7984            check(content).is_err(),
7985            "UUID glued via '=' to a trigger word must be blocked; got {:?}",
7986            scan(content)
7987        );
7988    }
7989
7990    #[test]
7991    fn blocks_uuid_with_trailing_sentence_period() {
7992        let content = "api_key 550e8400-e29b-41d4-a716-446655440000.";
7993        assert!(
7994            check(content).is_err(),
7995            "UUID with a trailing sentence period near a trigger must be blocked; got {:?}",
7996            scan(content)
7997        );
7998    }
7999
8000    #[test]
8001    fn blocks_uuid_wrapped_in_parens() {
8002        let content = "api_key (550e8400-e29b-41d4-a716-446655440000)";
8003        assert!(
8004            check(content).is_err(),
8005            "UUID wrapped in parens near a trigger must be blocked; got {:?}",
8006            scan(content)
8007        );
8008    }
8009
8010    #[test]
8011    fn blocks_uuid_in_json_object() {
8012        let content = "{\"api_key\":\"550e8400-e29b-41d4-a716-446655440000\"}";
8013        assert!(
8014            check(content).is_err(),
8015            "UUID in a JSON-ish object near a trigger key must be blocked; got {:?}",
8016            scan(content)
8017        );
8018    }
8019
8020    #[test]
8021    fn blocks_content_hash_glued_to_assignment_equals() {
8022        let content = "secret=sha256-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq";
8023        assert!(
8024            check(content).is_err(),
8025            "sha256-prefixed hash glued via '=' to a trigger word must be blocked; \
8026             got {:?}",
8027            scan(content)
8028        );
8029    }
8030
8031    #[test]
8032    fn blocks_content_hash_with_trailing_sentence_period() {
8033        let content = "secret sha256-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq.";
8034        assert!(
8035            check(content).is_err(),
8036            "sha256-prefixed hash with a trailing period near a trigger must be \
8037             blocked; got {:?}",
8038            scan(content)
8039        );
8040    }
8041
8042    #[test]
8043    fn blocks_content_hash_wrapped_in_parens() {
8044        let content = "secret (sha256-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq)";
8045        assert!(
8046            check(content).is_err(),
8047            "sha256-prefixed hash wrapped in parens near a trigger must be blocked; \
8048             got {:?}",
8049            scan(content)
8050        );
8051    }
8052
8053    #[test]
8054    fn blocks_content_hash_in_json_object() {
8055        let content = "{\"secret\":\"sha256-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq\"}";
8056        assert!(
8057            check(content).is_err(),
8058            "sha256-prefixed hash in a JSON-ish object near a trigger key must be \
8059             blocked; got {:?}",
8060            scan(content)
8061        );
8062    }
8063
8064    #[test]
8065    fn allows_uuid_wrapped_in_parens_with_no_trigger_nearby() {
8066        // Control: the prose allowlist must survive for wrapper syntax when
8067        // there is no credential trigger word anywhere in the window — only
8068        // the trigger-context extraction changed, not the outside-context
8069        // allowlist itself.
8070        let content = "wrapper (550e8400-e29b-41d4-a716-446655440000) present";
8071        assert!(
8072            check(content).is_ok(),
8073            "UUID wrapped in parens with no trigger word nearby must stay allowed; \
8074             got {:?}",
8075            scan(content)
8076        );
8077    }
8078
8079    #[test]
8080    fn blocks_padded_content_hash_glued_to_assignment_with_trailing_period() {
8081        // A padded base64 value ends in its own `=`, which is also a valid
8082        // separator character — `value_candidates` must enumerate the
8083        // suffix after every `=`/`:`, not assume any single separator
8084        // position, so the true value is recovered regardless of which
8085        // separator happens to sit where.
8086        let content = "secret=sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=.";
8087        assert!(
8088            check(content).is_err(),
8089            "padded sha256 hash glued via '=' with a trailing period must be \
8090             blocked; got {:?}",
8091            scan(content)
8092        );
8093    }
8094
8095    #[test]
8096    fn blocks_padded_content_hash_in_json_object() {
8097        let content = "{\"secret\":\"sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"}";
8098        assert!(
8099            check(content).is_err(),
8100            "padded sha256 hash in a JSON-ish object near a trigger key must be \
8101             blocked; got {:?}",
8102            scan(content)
8103        );
8104    }
8105
8106    #[test]
8107    fn blocks_uuid_when_json_label_itself_contains_colon() {
8108        // The label can itself contain the separator character
8109        // (`"api:key"` rather than `"api_key"`); the first `:` after
8110        // wrapper-stripping then lands inside the label, not at the
8111        // label/value boundary. value_candidates must still surface the
8112        // bare UUID as a later suffix candidate.
8113        let content = "{\"api:key\":\"550e8400-e29b-41d4-a716-446655440000\"}";
8114        assert!(
8115            check(content).is_err(),
8116            "UUID must be blocked even when the JSON label contains ':'; got {:?}",
8117            scan(content)
8118        );
8119    }
8120
8121    #[test]
8122    fn blocks_uuid_when_json_label_itself_contains_equals() {
8123        let content = "{\"api=key\":\"550e8400-e29b-41d4-a716-446655440000\"}";
8124        assert!(
8125            check(content).is_err(),
8126            "UUID must be blocked even when the JSON label contains '='; got {:?}",
8127            scan(content)
8128        );
8129    }
8130
8131    #[test]
8132    fn blocks_uuid_behind_doubled_assignment() {
8133        // key=label=value: the first `=` lands between two labels, not at
8134        // the true value boundary.
8135        let content = "api_key=label=550e8400-e29b-41d4-a716-446655440000"; // gitleaks:allow
8136        assert!(
8137            check(content).is_err(),
8138            "UUID must be blocked behind a doubled assignment; got {:?}",
8139            scan(content)
8140        );
8141    }
8142
8143    #[test]
8144    fn blocks_padded_content_hash_behind_doubled_assignment_equals() {
8145        let content = "secret=label=sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=.";
8146        assert!(
8147            check(content).is_err(),
8148            "padded content hash must be blocked behind a doubled '=' assignment; \
8149             got {:?}",
8150            scan(content)
8151        );
8152    }
8153
8154    #[test]
8155    fn blocks_padded_content_hash_behind_doubled_assignment_colon() {
8156        let content = "secret:label=sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=.";
8157        assert!(
8158            check(content).is_err(),
8159            "padded content hash must be blocked behind a doubled ':'+'=' \
8160             assignment; got {:?}",
8161            scan(content)
8162        );
8163    }
8164
8165    #[test]
8166    fn allows_benign_url_with_scheme_and_path_separators() {
8167        // `value_candidates`'s any-suffix semantics must not block ordinary
8168        // URLs, whose `://` and `/` characters produce several suffix
8169        // candidates but none of them are UUID- or content-hash-shaped.
8170        // Placed near a real trigger word ("key") so the check actually
8171        // exercises the trigger-context path rather than being skipped
8172        // outright.
8173        let content = "api_key endpoint=https://example.test/resource/for/testing";
8174        assert!(
8175            check(content).is_ok(),
8176            "a benign URL near a trigger word must stay allowed; got {:?}",
8177            scan(content)
8178        );
8179    }
8180
8181    // ── Trigger word-boundary matching ──────────────────────────────────────
8182
8183    #[test]
8184    fn allows_trigger_substrings_inside_benign_path_slugs() {
8185        let paths = [
8186            "docs/_archive/adr_v0/ADR-051-cli-auth-and-kg-git-workflow.md",
8187            "docs/platform/oauth-callback-docs-and-redirect-handling-v2.md",
8188            "docs/research/author-attribution-and-collaboration-notes.md",
8189            "docs/security/passwordless-authentication-overview-v3.md",
8190            "docs/platform/private_keynote-authoring-guide-v2.md",
8191        ];
8192        for path in paths {
8193            assert!(
8194                check(path).is_ok(),
8195                "trigger substring inside a benign path slug must not make the path \
8196                 its own credential context: {path:?}, got {:?}",
8197                scan(path)
8198            );
8199        }
8200    }
8201
8202    #[test]
8203    fn blocks_inline_auth_assignment_with_high_entropy_value() {
8204        let content = "auth=Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
8205        assert!(
8206            check(content).is_err(),
8207            "auth=<high-entropy-value> must still be blocked; got {:?}",
8208            scan(content)
8209        );
8210    }
8211
8212    #[test]
8213    fn blocks_suffix_bearing_compound_credential_assignments() {
8214        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
8215        let cases = [
8216            format!("api_keyv2={opaque}"),
8217            format!("access_keyv2={opaque}"),
8218            format!("private_keyv2={opaque}"),
8219            format!("API_KEYV2={opaque}"),
8220            format!(r#"{{"private_keyv2":"{opaque}"}}"#),
8221        ];
8222        for content in &cases {
8223            assert!(
8224                check(content).is_err(),
8225                "suffix-bearing compound credential assignment must be blocked: \
8226                 {content:?}, got {:?}",
8227                scan(content)
8228            );
8229        }
8230    }
8231
8232    #[test]
8233    fn blocks_spaced_suffix_bearing_compound_credential_assignments() {
8234        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
8235        for label in ["api_keyv2", "access_keyv2", "private_keyv2"] {
8236            let cases = [
8237                format!("{label} = {opaque}"),
8238                format!("{label} : {opaque}"),
8239                format!(r#"{{"{label}": "{opaque}"}}"#),
8240            ];
8241            for content in &cases {
8242                assert!(
8243                    check(content).is_err(),
8244                    "spaced suffix-bearing compound credential assignment must be \
8245                     blocked: {content:?}, got {:?}",
8246                    scan(content)
8247                );
8248            }
8249        }
8250    }
8251
8252    #[test]
8253    fn blocks_suffix_bearing_compound_credentials_without_assignment_separator() {
8254        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
8255        for label in ["api_keyv2", "access_keyv2", "private_keyv2"] {
8256            let cases = [format!("{label} {opaque}"), format!("{label}{opaque}")];
8257            for content in &cases {
8258                assert!(
8259                    check(content).is_err(),
8260                    "suffix-bearing compound credential without an assignment separator must be \
8261                     blocked: {content:?}, got {:?}",
8262                    scan(content)
8263                );
8264                assert!(
8265                    mask_secrets(content).contains(REDACTION_MARKER),
8266                    "shared secret masker must redact separator-free compound credential: \
8267                     {content:?}"
8268                );
8269            }
8270        }
8271
8272        let prefixed = "xapi_keyv2=Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
8273        assert!(
8274            check(prefixed).is_err(),
8275            "prefix-bearing compound credential assignment must be blocked: \
8276             {prefixed:?}, got {:?}",
8277            scan(prefixed)
8278        );
8279    }
8280
8281    #[test]
8282    fn allows_authorized_and_authentication_prose_near_uuid() {
8283        // The word-boundary fix directly: "auth" no longer matches the
8284        // substring inside "authorized"/"authentication", so ordinary prose
8285        // using those words does not poison the trigger window for a nearby
8286        // UUID or other allowlisted shape.
8287        let cases = [
8288            "authorized_write_requires_dominance was converted from axiom to theorem, id 550e8400-e29b-41d4-a716-446655440000",
8289            "authentication flow diagram lives at 550e8400-e29b-41d4-a716-446655440000",
8290        ];
8291        for content in cases {
8292            assert!(
8293                check(content).is_ok(),
8294                "'authorized'/'authentication' substring must not trigger the \
8295                 entropy heuristic: {content:?}, got {:?}",
8296                scan(content)
8297            );
8298        }
8299    }
8300
8301    #[test]
8302    fn allows_turkey_monkey_keyword_prose_near_uuid() {
8303        // Other bare-word substring collisions in TRIGGER_WORDS ("key") must
8304        // likewise not fire on ordinary English words that merely contain it.
8305        let cases = [
8306            "the turkey and monkey story references id 550e8400-e29b-41d4-a716-446655440000",
8307            "keyword research doc: 550e8400-e29b-41d4-a716-446655440000",
8308        ];
8309        for content in cases {
8310            assert!(
8311                check(content).is_ok(),
8312                "'turkey'/'monkey'/'keyword' substring must not trigger the \
8313                 entropy heuristic: {content:?}, got {:?}",
8314                scan(content)
8315            );
8316        }
8317    }
8318
8319    #[test]
8320    fn blocks_opaque_tokens_near_standalone_trigger_words() {
8321        // Word-boundary matching only removes SUBSTRING collisions; a genuine
8322        // standalone trigger word must still dominate exactly as before.
8323        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
8324        let cases = [
8325            format!("auth header {opaque}"),
8326            format!("the key is {opaque}"),
8327            format!("secret value: {opaque}"),
8328        ];
8329        for content in &cases {
8330            assert!(
8331                check(content).is_err(),
8332                "a genuine standalone trigger word must still block: {content:?}, \
8333                 got {:?}",
8334                scan(content)
8335            );
8336        }
8337    }
8338
8339    #[test]
8340    fn issue_2654_lookup_members_accept_record_references() {
8341        let id = "550e8400-e29b-41d4-a716-446655440000";
8342        for label in [
8343            "association_key",
8344            "partition_key",
8345            "sort_key",
8346            "cache_key",
8347            "idempotency_key",
8348            "primary_key",
8349        ] {
8350            for value in [id, "", "runtime/current", "record-slug"] {
8351                for separator in [",", ", "] {
8352                    let content = format!(r#"{{"{label}":"{value}"{separator}"neighbor":"{id}"}}"#);
8353                    assert!(check(&content).is_ok(), "{content}: {:?}", check(&content));
8354                    assert_eq!(mask_secrets(&content), content);
8355                }
8356            }
8357        }
8358        let renamed = format!(r#"{{"association_ref":"{id}","neighbor":"{id}"}}"#);
8359        assert!(check(&renamed).is_ok());
8360    }
8361
8362    #[test]
8363    fn issue_2654_credential_compounds_and_natural_assignments_stay_refused() {
8364        let id = "550e8400-e29b-41d4-a716-446655440000";
8365        for label in [
8366            "key",
8367            "api_key",
8368            "secret_key",
8369            "private_key",
8370            "access_key",
8371            "signing_key",
8372            "encryption_key",
8373            "auth_key",
8374            "service_signing_key",
8375        ] {
8376            for content in [
8377                format!("{label}={id}"),
8378                format!("{label}: {id}"),
8379                format!(r#"{{"{label}":"{id}"}}"#),
8380            ] {
8381                assert!(check(&content).is_err(), "{content}");
8382                assert!(!mask_secrets(&content).contains(id), "{content}");
8383            }
8384        }
8385        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
8386        for content in [
8387            format!("the key is {opaque}"),
8388            format!("api key {opaque}"),
8389            format!("association_key {id}"),
8390            format!("_key_ = {id}"),
8391            format!("association_key=x key={id}"),
8392        ] {
8393            assert!(check(&content).is_err(), "{content}");
8394        }
8395        assert!(check("secret docs/guide.md").is_ok());
8396        assert!(check("auth release-slug").is_ok());
8397    }
8398
8399    #[test]
8400    fn issue_2654_lookup_exception_requires_an_assignment_gap() {
8401        let id = "550e8400-e29b-41d4-a716-446655440000";
8402        for gap in ["***", ")", "}", "\\", "!", " ", "\" "] {
8403            let content = format!("association_key{gap}:{id}");
8404            assert!(check(&content).is_err(), "{content}");
8405            assert!(!mask_secrets(&content).contains(id), "{content}");
8406        }
8407        for gap in ["", "\"", "'", "`"] {
8408            let content = format!("association_key{gap}:{id}");
8409            assert!(check(&content).is_ok(), "{content}");
8410        }
8411    }
8412
8413    #[test]
8414    fn issue_2654_repeated_key_identifier_is_scanned_once() {
8415        let content = format!("{}key=x", "key_".repeat(262_144));
8416        assert!(check(&content).is_ok());
8417    }
8418
8419    #[test]
8420    fn issue_2654_unicode_before_lookup_member_keeps_boundaries() {
8421        let content = "記録association_key: 550e8400-e29b-41d4-a716-446655440000";
8422        assert!(check(content).is_ok(), "{:?}", check(content));
8423    }
8424
8425    #[test]
8426    fn issue_2654_unlisted_key_compounds_stay_credential_labels() {
8427        // The lookup exception is a closed allowlist: a `*_key` label it does
8428        // not name keeps `key` as a credential trigger for every value shape.
8429        let hex = "0123456789abcdef".repeat(4);
8430        let id = "550e8400-e29b-41d4-a716-446655440000";
8431        let neighbor = "6ba7b810-9dad-11d1-80b4-00c04fd430c8";
8432        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
8433        for label in [
8434            "hmac_key",
8435            "master_key",
8436            "ssh_key",
8437            "jwt_key",
8438            "webhook_key",
8439            "license_key",
8440            "gpg_key",
8441            "service_key",
8442            "client_key",
8443            "service_hmac_key",
8444        ] {
8445            for value in [hex.as_str(), id, opaque] {
8446                for content in [
8447                    format!("{label}={value}"),
8448                    format!("{label}: {value}"),
8449                    format!(r#"{{"{label}":"{value}"}}"#),
8450                    format!(r#"{{"neighbor":"{neighbor}","{label}":"{value}"}}"#),
8451                ] {
8452                    assert!(check(&content).is_err(), "{content}");
8453                    assert!(!mask_secrets(&content).contains(value), "{content}");
8454                }
8455            }
8456        }
8457    }
8458
8459    #[test]
8460    fn issue_2654_listed_lookup_labels_accept_identifier_shapes() {
8461        let hex = "0123456789abcdef".repeat(4);
8462        let id = "550e8400-e29b-41d4-a716-446655440000";
8463        for label in LOOKUP_KEY_LABELS {
8464            for value in [id, hex.as_str(), "runtime/current", "record-slug"] {
8465                for content in [
8466                    format!("{label}={value}"),
8467                    format!(r#"{{"{label}":"{value}","neighbor":"{id}"}}"#),
8468                ] {
8469                    assert!(check(&content).is_ok(), "{content}: {:?}", scan(&content));
8470                    assert_eq!(mask_secrets(&content), content);
8471                }
8472            }
8473        }
8474        // A credential word in the prefix is its own trigger.
8475        assert!(check(&format!("secret_partition_key={id}")).is_err());
8476        assert!(check(&format!("api_key_cache_key={id}")).is_err());
8477    }
8478
8479    #[test]
8480    fn issue_2654_qualified_lookup_labels_stay_refused() {
8481        // The vocabulary matches whole labels. A prefix rule ("anything ending
8482        // in `_` before a listed suffix") re-opens exactly the compounds the
8483        // list closes, because stems such as `hmac` or `jwt` are not trigger
8484        // words of their own. The cost is that a qualified lookup spelling
8485        // (`left_association_key`) is refused too; that is the fail-closed side.
8486        let hex = "0123456789abcdef".repeat(4);
8487        let id = "550e8400-e29b-41d4-a716-446655440000";
8488        for label in [
8489            "hmac_cache_key",
8490            "master_routing_key",
8491            "jwt_lookup_key",
8492            "ssh_row_key",
8493            "webhook_search_key",
8494            "license_index_key",
8495            "left_association_key",
8496            "record_partition_key",
8497        ] {
8498            for content in [
8499                format!("{label}={hex}"),
8500                format!(r#"{{"{label}":"{hex}","neighbor":"{id}"}}"#),
8501            ] {
8502                assert!(check(&content).is_err(), "{content}");
8503                assert!(!mask_secrets(&content).contains(hex.as_str()), "{content}");
8504            }
8505        }
8506    }
8507
8508    #[test]
8509    fn accepted_false_positive_workspace_artifact_path_behind_attributive_trigger() {
8510        // "secret gate false positive repro: <path>" carries a trigger word
8511        // in clause range ahead of a value delimiter. The clause walk no
8512        // longer caps content words after a delimiter (any cap re-admits the
8513        // chained-qualifier labeled-value bypass), so this meta-prose shape
8514        // blocks. Accepted false positive — same attributive-trigger class
8515        // as the auth-setup docs path; documented in docs/api/secret_gate.md.
8516        let content = "writing up the secret gate false positive repro: \
8517             .workspace/20260101/fix-secret-gate-trigger-false-positive/MEASUREMENT_REPORT.md";
8518        assert!(
8519            check(content).is_err(),
8520            "accepted-FP contract changed: attributive trigger before a \
8521             delimited path no longer blocks — update the docs if deliberate"
8522        );
8523    }
8524
8525    #[test]
8526    fn accepted_false_positive_archive_doc_path_behind_attributive_trigger() {
8527        // "secret scanner archive notes: <path>" — attributive trigger two
8528        // qualifiers ahead of the delimiter. Same accepted-FP class as
8529        // above; the walk cannot tell an attributive trigger from a label
8530        // head without reopening the chained-qualifier bypass.
8531        let content =
8532            "secret scanner archive notes: docs/_archive/ADR051-TenantEncryption-v2Notes.md";
8533        assert!(
8534            check(content).is_err(),
8535            "accepted-FP contract changed: attributive trigger before a \
8536             delimited path no longer blocks — update the docs if deliberate"
8537        );
8538    }
8539
8540    #[test]
8541    fn allows_absolute_path_near_standalone_auth() {
8542        let content = "the auth scanner flagged this file: /home/user/projects/workspace/SessionNotes20260107/AuthGateFollowup2.md";
8543        assert!(
8544            check(content).is_ok(),
8545            "absolute path in technical prose must pass; got {:?}",
8546            scan(content)
8547        );
8548    }
8549
8550    #[test]
8551    fn blocks_assignment_shaped_credential_disguised_as_path_near_api_key() {
8552        // Adversarial negative: a credential-shaped value glued via '='
8553        // directly to a trigger word must not be exempted just because it is
8554        // path-shaped (separator-delimited, word-shaped runs) and looks
8555        // superficially like the technical paths above. The compound label
8556        // `api_key` makes this a credential value, so it must block.
8557        let content = "api_key=/home/user/workspaces/2026/topic-name-example/SECRET_VALUE_HERE.md";
8558        assert!(
8559            check(content).is_err(),
8560            "assignment-shaped credential disguised as a path must still be \
8561             blocked: {content:?}, got {:?}",
8562            scan(content)
8563        );
8564    }
8565
8566    #[test]
8567    fn blocks_separator_split_secret_access_key_compound() {
8568        // Adversarial negative: a separator-split bypass shape must still be
8569        // blocked. The `secret` and `key` entries both match because underscore
8570        // is a boundary for bare `TRIGGER_WORDS`. This asserts the end-to-end
8571        // outcome.
8572        let content = "secret_access_key abcdefghij/klmnopqrst/uvwxyzabcd/efghijk.md";
8573        assert!(
8574            check(content).is_err(),
8575            "secret_access_key bypass shape must still be blocked: {content:?}, \
8576             got {:?}",
8577            scan(content)
8578        );
8579    }
8580
8581    // ── Underscore is a BOUNDARY for bare TRIGGER_WORDS, not a continuation ─
8582    // Opposite of `has_standalone_token`'s rule for `token`: treating underscore as a
8583    // boundary here is what keeps `SECRET_KEY=`/`auth_token=`/`signing_key=` detected
8584    // (`contains_word`'s `underscore_is_word_char` parameter controls this per caller).
8585
8586    #[test]
8587    fn blocks_secret_key_assignment_when_underscore_bounds_trigger() {
8588        // `SECRET_KEY=<value>` must block via the plain-substring `secret`
8589        // trigger even though `secret` is followed by `_` rather than a
8590        // non-word-char boundary.
8591        let content = "SECRET_KEY=dGhpc2lzYXNlY3JldGtleXZhbHVlMTIzNDU2Nzg5MA=="; // gitleaks:allow
8592        assert!(
8593            check(content).is_err(),
8594            "SECRET_KEY=<value> (Django/Flask-style config) must still be \
8595             blocked: {content:?}, got {:?}",
8596            scan(content)
8597        );
8598    }
8599
8600    #[test]
8601    fn blocks_auth_token_assignment_when_underscore_bounds_trigger() {
8602        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
8603        let content = format!("auth_token={opaque}");
8604        assert!(
8605            check(&content).is_err(),
8606            "auth_token=<value> must still be blocked: {content:?}, got {:?}",
8607            scan(&content)
8608        );
8609    }
8610
8611    #[test]
8612    fn blocks_underscore_joined_session_secret_and_signing_key_compounds() {
8613        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
8614        let cases = [
8615            format!("session_secret_{opaque}"),
8616            format!("signing_key={opaque}"),
8617        ];
8618        for content in &cases {
8619            assert!(
8620                check(content).is_err(),
8621                "underscore-joined credential compound must still be blocked: \
8622                 {content:?}, got {:?}",
8623                scan(content)
8624            );
8625        }
8626    }
8627
8628    #[test]
8629    fn allows_letter_joined_trigger_substrings_in_benign_prose() {
8630        // The letter-joined substring-collision exemption must survive the
8631        // underscore-as-boundary change, since that change only affects the
8632        // underscore character, not letter-joined words.
8633        let cases = [
8634            "authorized_write_requires_dominance was converted from axiom to theorem, id 550e8400-e29b-41d4-a716-446655440000",
8635            "authentication flow diagram lives at 550e8400-e29b-41d4-a716-446655440000",
8636            "the turkey and monkey story references id 550e8400-e29b-41d4-a716-446655440000",
8637            "keyword research doc: 550e8400-e29b-41d4-a716-446655440000",
8638        ];
8639        for content in cases {
8640            assert!(
8641                check(content).is_ok(),
8642                "letter-joined substring collision must stay exempt: \
8643                 {content:?}, got {:?}",
8644                scan(content)
8645            );
8646        }
8647    }
8648
8649    #[test]
8650    fn block_message_carries_actionable_guidance() {
8651        let fake = "AKIAFAKEKEY1234567890";
8652        let m = scan(fake).unwrap();
8653        let rendered = m.to_string();
8654        assert!(
8655            rendered.contains("real credential"),
8656            "block message must carry actionable guidance: {rendered}"
8657        );
8658    }
8659
8660    #[test]
8661    fn block_message_shape_guidance_names_effective_boundary() {
8662        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
8663        let content = format!("auth header {opaque}");
8664        let m = scan(&content).unwrap();
8665        let rendered = m.to_string();
8666        assert!(
8667            rendered.contains("sentence") || rendered.contains("paragraph"),
8668            "shape-based detector guidance must name an effective context boundary: {rendered}"
8669        );
8670    }
8671}
8672
8673// ─── Corpus replay harness (manual, opt-in) ─────────────────────────────────
8674//
8675// Measures how many real note/entity strings the gate blocks, so a detector
8676// change can be evaluated against production content rather than intuition
8677// (see the module doc). Opens the target database
8678// STRICTLY read-only (`SQLITE_OPEN_READ_ONLY`) and never mutates it. Point
8679// `KHIVE_REPLAY_DB` at a copy or a live KG database file path; the harness
8680// never writes, locks aggressively, or deletes anything.
8681//
8682// Run with: `KHIVE_REPLAY_DB=/path/to/khive.db cargo test -p khive-runtime \
8683//   --release -- --ignored --nocapture corpus_replay`
8684#[cfg(test)]
8685mod corpus_replay {
8686    use super::*;
8687    use rusqlite::{Connection, OpenFlags};
8688
8689    // ── Whole-token-average entropy dilution (issue #1044, false-negative) ──
8690
8691    #[test]
8692    fn blocks_hex_credential_diluted_by_filler_path_segments() {
8693        // A real 40-char hex credential as one path segment among low-entropy
8694        // filler segments. Whole-token-average entropy is diluted below
8695        // ENTROPY_THRESHOLD, and the whole token is not pure hex (it has `/`
8696        // and `.` in it), so neither the whole-token entropy check nor the
8697        // whole-token hex-credential-token check catches it — only a per-run
8698        // check does.
8699        let line = "api key vault/9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c/rotate.md";
8700        let m = scan(line);
8701        assert!(
8702            m.is_some(),
8703            "hex credential diluted by filler path segments must be blocked; got None"
8704        );
8705        assert_eq!(m.unwrap().detector, "hex-credential-token");
8706    }
8707
8708    #[test]
8709    fn blocks_chopped_secret_padded_by_filler_runs() {
8710        // A random high-entropy run planted among short filler runs. Whole-
8711        // token-average entropy is diluted below threshold by the filler.
8712        let line = "secret path a/b/c/d/Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM/e/f.rs"; // gitleaks:allow
8713        let m = scan(line);
8714        assert!(
8715            m.is_some(),
8716            "chopped/padded secret among filler runs must be blocked; got None"
8717        );
8718        assert_eq!(m.unwrap().detector, "high-entropy-token");
8719    }
8720
8721    #[test]
8722    fn allows_real_paths_with_no_run_reaching_min_entropy_len() {
8723        // Regression guard: the #1040 measurement corpus's real path false
8724        // positives never contain a single run >= MIN_ENTROPY_LEN, so the new
8725        // per-run check introduced for #1044 must not newly block them.
8726        let contents = [
8727            "branch feat-session-mirror pushed, see release_notes_v2.md for the key findings",
8728            "password reset doc: docs/adr/ADR-055-epistemic-edge-relations.md",
8729            "credential handling code crates/khive-pack-session/src/mirror/ingest.rs",
8730            "api key handling lives in check_entropy_heuristic_impl",
8731        ];
8732        for content in contents {
8733            assert!(
8734                check(content).is_ok(),
8735                "real path with no long run must still pass; fired: {:?}",
8736                scan(content)
8737            );
8738        }
8739    }
8740
8741    #[test]
8742    #[ignore]
8743    fn replay_against_corpus() {
8744        let db_path = std::env::var("KHIVE_REPLAY_DB")
8745            .expect("set KHIVE_REPLAY_DB=/path/to/khive.db to run the corpus replay (read-only)");
8746        let conn = Connection::open_with_flags(
8747            &db_path,
8748            OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
8749        )
8750        .expect("open corpus DB read-only");
8751
8752        let mut total = 0usize;
8753        let mut blocked = 0usize;
8754        let mut samples: Vec<String> = Vec::new();
8755
8756        let mut collect = |sql: &str| {
8757            let mut stmt = conn.prepare(sql).expect("prepare replay query");
8758            let mut rows = stmt.query([]).expect("query replay rows");
8759            while let Some(row) = rows.next().expect("read replay row") {
8760                let content: Option<String> = row.get(0).unwrap_or(None);
8761                let Some(content) = content else { continue };
8762                if content.is_empty() {
8763                    continue;
8764                }
8765                total += 1;
8766                if let Some(m) = scan(&content) {
8767                    blocked += 1;
8768                    if samples.len() < 30 {
8769                        samples.push(format!(
8770                            "{} :: {}",
8771                            m,
8772                            content.chars().take(160).collect::<String>()
8773                        ));
8774                    }
8775                }
8776            }
8777        };
8778
8779        collect("SELECT content FROM notes WHERE deleted_at IS NULL");
8780        collect("SELECT description FROM entities WHERE deleted_at IS NULL");
8781
8782        eprintln!("corpus replay: {blocked}/{total} strings blocked");
8783        for s in &samples {
8784            eprintln!("  BLOCKED: {s}");
8785        }
8786    }
8787
8788    /// Generates the sanitized corpus manifest at
8789    /// `tests/data/secret_gate_corpus_manifest.md`: per-detector block counts plus a
8790    /// sha256 of each blocked candidate, never the candidate text itself. Point-in-time
8791    /// generator, not a CI check — re-run manually (`KHIVE_REPLAY_DB=... cargo test -p
8792    /// khive-runtime --release -- --ignored --nocapture generate_corpus_manifest`) and
8793    /// hand-update the checked-in file when the detector set changes.
8794    #[test]
8795    #[ignore]
8796    fn generate_corpus_manifest() {
8797        use sha2::{Digest, Sha256};
8798        use std::collections::BTreeMap;
8799
8800        let db_path = std::env::var("KHIVE_REPLAY_DB")
8801            .expect("set KHIVE_REPLAY_DB=/path/to/khive.db to run the corpus replay (read-only)");
8802        let conn = Connection::open_with_flags(
8803            &db_path,
8804            OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
8805        )
8806        .expect("open corpus DB read-only");
8807
8808        let mut total = 0usize;
8809        let mut counts_by_detector: BTreeMap<&'static str, usize> = BTreeMap::new();
8810        let mut hashes_by_detector: BTreeMap<&'static str, Vec<String>> = BTreeMap::new();
8811        let mut max_run_reaching_min_entropy_len = 0usize;
8812
8813        let mut collect = |sql: &str| {
8814            let mut stmt = conn.prepare(sql).expect("prepare replay query");
8815            let mut rows = stmt.query([]).expect("query replay rows");
8816            while let Some(row) = rows.next().expect("read replay row") {
8817                let content: Option<String> = row.get(0).unwrap_or(None);
8818                let Some(content) = content else { continue };
8819                if content.is_empty() {
8820                    continue;
8821                }
8822                total += 1;
8823                // Track the #1040 soundness claim directly against the same
8824                // corpus this replay scans: the longest run any path-shaped
8825                // token contributes, so the "no real path false positive
8826                // reaches MIN_ENTROPY_LEN" claim is checked against actual
8827                // data rather than asserted.
8828                for token in content.split_whitespace() {
8829                    for run in token.split(|c: char| !c.is_ascii_alphanumeric()) {
8830                        max_run_reaching_min_entropy_len =
8831                            max_run_reaching_min_entropy_len.max(run.len());
8832                    }
8833                }
8834                if let Some(m) = scan(&content) {
8835                    *counts_by_detector.entry(m.detector).or_insert(0) += 1;
8836                    let hash = format!("{:x}", Sha256::digest(content.as_bytes()));
8837                    hashes_by_detector.entry(m.detector).or_default().push(hash);
8838                }
8839            }
8840        };
8841
8842        collect("SELECT content FROM notes WHERE deleted_at IS NULL");
8843        collect("SELECT description FROM entities WHERE deleted_at IS NULL");
8844
8845        let blocked: usize = counts_by_detector.values().sum();
8846        println!("total_scanned: {total}");
8847        println!("total_blocked: {blocked}");
8848        println!("longest_alphanumeric_run_in_corpus: {max_run_reaching_min_entropy_len}");
8849        println!("counts_by_detector:");
8850        for (detector, count) in &counts_by_detector {
8851            println!("  {detector}: {count}");
8852        }
8853        println!("blocked_content_sha256_by_detector:");
8854        for (detector, hashes) in &hashes_by_detector {
8855            println!("  {detector}:");
8856            for hash in hashes {
8857                println!("    {hash}");
8858            }
8859        }
8860    }
8861}