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 — it never echoes the full candidate back.
6//!
7//! Scope: **credentials only** — API keys, tokens, private keys, passwords,
8//! and connection strings with embedded credentials. General PII (emails,
9//! phone numbers, company names) is intentionally NOT blocked.
10//!
11//! Detection is layered, cheap-first:
12//! 1. **Known-prefix / known-shape patterns** — AWS AKIA/ASIA, GitHub tokens,
13//!    OpenAI `sk-proj-`, Anthropic `sk-ant-`, Stripe live keys, Fly.io tokens,
14//!    Vercel secrets, Slack `xox*`, JWT triples, PEM private-key headers, Age
15//!    secret keys, URL userinfo (`scheme://user:pass@`).
16//! 2. **High-entropy token heuristic** — base64/hex/base64url runs ≥ 24 chars
17//!    near a trigger word (key, secret, password, credential, bearer, auth,
18//!    apikey, api_key, access_key, private_key). The word `token` alone is
19//!    NOT a trigger, to avoid blocking `tokenizer_*`, `token_count`, etc.
20//!
21//! A credential trigger word in the surrounding window always dominates the
22//! allowlist below — no exemption is unconditional near a trigger.
23//!
24//! Full exemption rules (hex/UUID/SRI-hash passes, non-ASCII token
25//! delimiting, structured-identifier decomposition, trigger word-boundary
26//! matching, the underscore-boundary asymmetry between bare trigger words and
27//! the word `token`, and the adversarial-corpus rationale for why some
28//! false positives are accepted) are documented in full in
29//! `docs/api/secret_gate.md#module-level-detection-algorithm` — read that before
30//! changing any detection or exemption logic in this file.
31//!
32//! The caller-visible block message (`SecretMatch`'s `Display` impl) also
33//! carries actionable guidance (`block_guidance`) to split or reword the
34//! flagged token.
35//!
36//! A production-corpus replay harness (`corpus_replay`, `#[ignore]`d, run via
37//! `KHIVE_REPLAY_DB=<path> cargo test ... -- --ignored --nocapture`) measures
38//! the detector's block rate against real note/entity content; see the
39//! harness's own output for current numbers rather than a point-in-time count
40//! here, which would drift as the corpus changes.
41
42use crate::error::{RuntimeError, RuntimeResult};
43
44// ─── Public API ──────────────────────────────────────────────────────────────
45
46/// Returned when a write would store credential-looking content.
47///
48/// Carries the detector name and a masked excerpt (`first6...Nchars`).  The
49/// full candidate is never stored in the error.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct SecretMatch {
52    /// Human-readable name of the detector that fired.
53    pub detector: &'static str,
54    /// `first6...N` — the first 6 chars of the match followed by the total length.
55    pub masked: String,
56}
57
58impl std::fmt::Display for SecretMatch {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        write!(
61            f,
62            "content matches secret pattern {} at masked excerpt {}. {}",
63            self.detector,
64            self.masked,
65            block_guidance(self.detector)
66        )
67    }
68}
69
70/// Actionable, caller-visible guidance for a hard block, keyed by detector
71/// name. If the content genuinely is a credential, remove it. If it
72/// is not — the common case for the detectors below, which key off SHAPE
73/// near a trigger word rather than a known credential prefix — the fix is to
74/// break up the flagged token so it no longer reads as one contiguous
75/// high-entropy value: put it on its own line away from words like
76/// key/secret/auth/token, or insert a space/punctuation inside a long path or
77/// identifier (e.g. `workspaces / 20260705 / topic` instead of one glued
78/// token).
79fn block_guidance(detector: &'static str) -> &'static str {
80    match detector {
81        "high-entropy-token"
82        | "uuid-near-trigger"
83        | "content-hash-near-trigger"
84        | "hex-credential-token" => {
85            "If this is a real credential, remove it before writing. If it is not \
86             (e.g. a file path, UUID, or hash that happens to sit near a word like \
87             key/secret/auth/token), reword so the token is not glued directly next \
88             to that word — e.g. move it to its own line, or insert a space or word \
89             between them."
90        }
91        _ => {
92            "If this is a real credential, remove it before writing; store secrets \
93              in an env var or secrets manager instead."
94        }
95    }
96}
97
98/// Hard-block content from being written.
99///
100/// Returns `Err(RuntimeError::SecretDetected)` on the first match found, or
101/// `Ok(())` if no secret pattern fires.
102pub fn check(content: &str) -> RuntimeResult<()> {
103    if let Some(m) = scan(content) {
104        return Err(RuntimeError::SecretDetected(m));
105    }
106    Ok(())
107}
108
109/// Recursively scan a JSON value for credential-shaped strings.
110///
111/// Walks every string leaf (object values, array elements, nested objects).
112/// Returns `Err(RuntimeError::SecretDetected)` on the first match found.
113/// `None` / null / numeric / boolean JSON values are skipped.
114pub fn check_json(value: &serde_json::Value) -> RuntimeResult<()> {
115    scan_json_value(value)
116}
117
118/// Scan a string-tagged slice (entity/note tags).
119///
120/// Each tag string is scanned individually.
121pub fn check_tags(tags: &[String]) -> RuntimeResult<()> {
122    for tag in tags {
123        check(tag)?;
124    }
125    Ok(())
126}
127
128fn scan_json_value(value: &serde_json::Value) -> RuntimeResult<()> {
129    match value {
130        serde_json::Value::String(s) => check(s),
131        serde_json::Value::Array(arr) => {
132            for v in arr {
133                scan_json_value(v)?;
134            }
135            Ok(())
136        }
137        serde_json::Value::Object(map) => {
138            for (k, v) in map {
139                // Scan both the key (a credential can appear as a JSON key name)
140                // and the value recursively.
141                check(k)?;
142                scan_json_value(v)?;
143            }
144            Ok(())
145        }
146        _ => Ok(()),
147    }
148}
149
150// ─── Scanner ─────────────────────────────────────────────────────────────────
151
152/// Marker substituted for a detected secret span by [`mask_secrets`].
153const REDACTION_MARKER: &str = "***MASKED***";
154
155/// Return the LEFTMOST secret in `text` as `(matched_slice, detector)`.
156///
157/// The matched slice borrows from `text`, so the caller can recover its byte
158/// span via pointer arithmetic — this is what lets [`mask_secrets`] redact in
159/// place while [`scan`] only needs the masked excerpt.
160///
161/// "Leftmost" (smallest start offset), NOT first-by-detector-priority, is the
162/// load-bearing contract: [`mask_secrets`] copies the text *before* each match
163/// verbatim, so a non-leftmost match would leak an earlier secret detected by a
164/// lower-priority detector (e.g. an `sk-ant-` key sitting to the left of a
165/// `ghp_` token). Both detector layers are folded through [`keep_leftmost`].
166fn scan_match(text: &str) -> Option<(&str, &'static str)> {
167    scan_from(text, 0)
168}
169
170/// Like [`scan_match`], but only returns secrets whose span starts at or after
171/// `from`, while still evaluating Layer-2 trigger context against the FULL
172/// `text`. [`mask_secrets`] calls this with an advancing `from` so that an
173/// entropy token is detected even when its only trigger word sits to the left of
174/// an already-redacted earlier secret. Layer-1 known patterns are context-free,
175/// so scanning the `&text[from..]` suffix is equivalent; offsets recovered via
176/// pointer arithmetic against the original `text` base stay absolute.
177fn scan_from(text: &str, from: usize) -> Option<(&str, &'static str)> {
178    let base = text.as_ptr() as usize;
179    // Layer 1: known prefix / shape patterns. Context-free → suffix scan; the
180    // returned slice still borrows from the same allocation, so its absolute
181    // offset is `slice.as_ptr() - base`.
182    let mut best = check_known_patterns(&text[from..]);
183    // Layer 2: entropy heuristic on long tokens near trigger words. Evaluated
184    // over the full text (so left-of-`from` trigger words count) but only tokens
185    // at offset >= from are returned; kept only if left of the best known match.
186    keep_leftmost(&mut best, check_entropy_heuristic(text, from), base);
187    best
188}
189
190/// Replace `best` with `cand` when `cand` starts earlier in the original text
191/// (`base` is the start address of that text). On a tie the incumbent wins, so
192/// callers offer more-specific detectors first. This is what makes
193/// [`check_known_patterns`] and [`scan_match`] return the leftmost secret span
194/// rather than the first detector that happens to match anywhere.
195fn keep_leftmost<'a>(
196    best: &mut Option<(&'a str, &'static str)>,
197    cand: Option<(&'a str, &'static str)>,
198    base: usize,
199) {
200    if let Some((slice, name)) = cand {
201        let start = slice.as_ptr() as usize - base;
202        let replace = match *best {
203            Some((incumbent, _)) => start < (incumbent.as_ptr() as usize - base),
204            None => true,
205        };
206        if replace {
207            *best = Some((slice, name));
208        }
209    }
210}
211
212/// Return the first `SecretMatch` found in `text`, or `None`.
213fn scan(text: &str) -> Option<SecretMatch> {
214    scan_match(text).map(|(slice, detector)| build_match(detector, slice))
215}
216
217/// Redact every detected secret span in `text`, replacing each with
218/// `***MASKED***`.
219///
220/// This is the masking counterpart to [`check`]: where `check` hard-blocks a
221/// write on the first match, `mask_secrets` is for content that must be STORED
222/// with credentials stripped (the session mirror). It reuses the SAME
223/// canonical detector set as `check`/`scan`, so callers must never maintain a
224/// second, weaker masker.
225///
226/// Returns `Cow::Borrowed` when no secret is present (the common case), avoiding
227/// an allocation. Spans are discovered left to right against the ORIGINAL text,
228/// always evaluating trigger context over the full input — a high-entropy value
229/// whose only trigger word sits to the left of an earlier-redacted secret is
230/// still detected. See `docs/api/secret_gate.md#mask_secrets` for the scan-cursor
231/// mechanics.
232pub fn mask_secrets(text: &str) -> std::borrow::Cow<'_, str> {
233    let base = text.as_ptr() as usize;
234    // Collect every secret span (absolute byte offsets into `text`) before
235    // writing any output, so trigger-context detection always sees the original
236    // string rather than the suffix after the previous redaction.
237    let mut spans: Vec<(usize, usize)> = Vec::new();
238    let mut from = 0;
239    while from < text.len() {
240        match scan_from(text, from) {
241            Some((sub, _detector)) => {
242                let start = sub.as_ptr() as usize - base;
243                // The prefix detectors return whitespace-delimited tokens, so a
244                // credential glued to structural punctuation (JSON quotes/braces,
245                // sentence commas) carries that trailing punctuation into the
246                // match. Trim a conservative trailing set that can never be part
247                // of a credential, so redacting does not consume surrounding JSON
248                // or prose structure. `=` `/` `+` `.` `-` `_` are intentionally
249                // NOT trimmed — they are valid base64/JWT/key characters.
250                let core_len = sub
251                    .trim_end_matches(['"', '\'', '`', '}', ']', ')', ',', ';'])
252                    .len();
253                let end = start + core_len.max(1);
254                spans.push((start, end));
255                // `scan_from` only returns matches with start >= from, and `end`
256                // is strictly greater than `start`, so `from` strictly advances.
257                from = end;
258            }
259            None => break,
260        }
261    }
262    if spans.is_empty() {
263        return std::borrow::Cow::Borrowed(text);
264    }
265    let mut out = String::with_capacity(text.len());
266    let mut cursor = 0;
267    for (start, end) in spans {
268        // Spans are non-overlapping and ascending (each starts at/after the prior
269        // `end`); `max(cursor)` is a defensive guard, never load-bearing.
270        let start = start.max(cursor);
271        out.push_str(&text[cursor..start]);
272        out.push_str(REDACTION_MARKER);
273        cursor = end.max(cursor);
274    }
275    out.push_str(&text[cursor..]);
276    std::borrow::Cow::Owned(out)
277}
278
279// ─── Layer 1: known patterns ─────────────────────────────────────────────────
280
281/// Each entry: (detector_name, needle, min_total_token_len).
282///
283/// The needle must appear as a word-boundary-adjacent prefix in the token.
284/// `min_total_token_len` is the minimum length the token (needle + remainder)
285/// must have — prevents the prefix alone triggering without a payload.
286const PREFIX_DETECTORS: &[(&str, &str, usize)] = &[
287    // AWS
288    ("aws-access-key-id", "AKIA", 20),
289    ("aws-access-key-id", "ASIA", 20),
290    // GitHub tokens: personal-access (ghp_), OAuth (gho_), GitHub App
291    // user-to-server (ghu_), server-to-server (ghs_), refresh (ghr_), and the
292    // fine-grained PAT (github_pat_). All but github_pat_ share the gh*_ + 36+
293    // base62 shape.
294    ("github-token", "ghp_", 36),
295    ("github-token", "gho_", 36),
296    ("github-token", "ghu_", 36),
297    ("github-token", "ghs_", 36),
298    ("github-token", "ghr_", 36),
299    ("github-token", "github_pat_", 20),
300    // OpenAI
301    ("openai-api-key", "sk-proj-", 40),
302    // NOTE: bare "sk-" also matches Anthropic/Stripe below; put it last so
303    // the more-specific detectors fire first when both would match.
304    // Anthropic
305    ("anthropic-api-key", "sk-ant-", 20),
306    // Stripe live keys
307    ("stripe-secret-key", "sk_live_", 30),
308    ("stripe-restricted-key", "rk_live_", 30),
309    // Fly.io (fm2_ prefix only — FlyV1 handled separately because it embeds a space)
310    ("fly-token", "fm2_", 20),
311    // Vercel
312    ("vercel-token", "vercel_", 20),
313    // Slack
314    ("slack-token", "xoxb-", 40),
315    ("slack-token", "xoxa-", 40),
316    ("slack-token", "xoxp-", 40),
317    ("slack-token", "xoxr-", 40),
318    ("slack-token", "xoxs-", 40),
319    // Age secret key
320    ("age-secret-key", "AGE-SECRET-KEY-", 60),
321];
322
323/// Known safe compound words that start with `sk-` but are not credentials.
324/// E.g. scikit-learn slugs such as `sk-learn`, `sk-image`, `sk-lego`.
325const SK_SAFE_PREFIXES: &[&str] = &["sk-learn", "sk-image", "sk-lego", "sk-base", "sk-misc"];
326
327/// Shape-based patterns checked with custom logic.
328///
329/// Returns the LEFTMOST match across every detector (see [`keep_leftmost`]). The
330/// detectors are still offered in priority order, so two detectors that match at
331/// the SAME offset (e.g. bare `sk-` and the more-specific `sk-ant-`) resolve to
332/// the first-offered one.
333fn check_known_patterns(text: &str) -> Option<(&str, &'static str)> {
334    let base = text.as_ptr() as usize;
335    let mut best: Option<(&str, &'static str)> = None;
336
337    // --- Prefix patterns ---
338    for &(name, needle, min_len) in PREFIX_DETECTORS {
339        keep_leftmost(
340            &mut best,
341            find_prefix_token(text, needle, min_len).map(|m| (m, name)),
342            base,
343        );
344    }
345
346    // --- Bare `sk-` (after all more-specific sk- detectors above) ---
347    // Require length ≥ 30 AND exclude known safe scikit/library compound words.
348    if let Some(token) = find_prefix_token(text, "sk-", 30) {
349        if !SK_SAFE_PREFIXES.iter().any(|safe| token.starts_with(safe)) {
350            keep_leftmost(&mut best, Some((token, "openai-api-key")), base);
351        }
352    }
353
354    // --- Fly.io FlyV1 token: "FlyV1 <base64-payload>" ---
355    // The format embeds a space, so the generic prefix extractor (which stops at
356    // whitespace) cannot measure the combined length.  Check for `FlyV1 ` followed
357    // by ≥ 4 non-whitespace characters as the payload.
358    if let Some(pos) = text.find("FlyV1 ") {
359        let at_boundary = pos == 0 || {
360            text[..pos]
361                .chars()
362                .next_back()
363                .is_none_or(|c| !c.is_ascii_alphanumeric())
364        };
365        if at_boundary {
366            let payload_start = pos + 6; // skip "FlyV1 "
367            let payload = extract_token(&text[payload_start..]);
368            if payload.len() >= 4 {
369                let candidate = &text[pos..payload_start + payload.len()];
370                keep_leftmost(&mut best, Some((candidate, "fly-token")), base);
371            }
372        }
373    }
374
375    // --- PEM private key block ---
376    // "-----BEGIN <TYPE> PRIVATE KEY-----"
377    if text.contains("-----BEGIN") && text.contains("PRIVATE KEY-----") {
378        if let Some(pos) = text.find("-----BEGIN") {
379            // Measure only the key block itself (up to END marker or end-of-string),
380            // not the rest of the surrounding text, so build_match reports the
381            // block length rather than the remaining string length.
382            let block_end = text[pos..]
383                .find("-----END")
384                .map(|rel| {
385                    text[pos + rel..]
386                        .find('\n')
387                        .map(|l| pos + rel + l + 1)
388                        .unwrap_or(text.len())
389                })
390                .unwrap_or(text.len());
391            let excerpt = &text[pos..block_end];
392            keep_leftmost(&mut best, Some((excerpt, "pem-private-key")), base);
393        }
394    }
395
396    // --- JWT triple: eyJ...eyJ...eyJ (header.payload.signature) ---
397    // A JWT starts with "eyJ" (base64url of `{"`) and has exactly two dots.
398    keep_leftmost(&mut best, find_jwt(text).map(|m| (m, "jwt")), base);
399
400    // --- URL userinfo: scheme://user:pass@host ---
401    keep_leftmost(
402        &mut best,
403        find_url_userinfo(text).map(|m| (m, "url-userinfo")),
404        base,
405    );
406
407    best
408}
409
410/// Locate the first token in `text` that starts with `needle` and has a
411/// total length >= `min_len`.  Returns a slice of the full token on match.
412fn find_prefix_token<'a>(text: &'a str, needle: &str, min_len: usize) -> Option<&'a str> {
413    let mut start = 0;
414    while let Some(rel) = text[start..].find(needle) {
415        let abs = start + rel;
416        // Require that the needle starts at a token boundary (start-of-string
417        // or preceded by a non-ASCII-alphanumeric char).  The needles are ASCII,
418        // so only an ASCII alphanumeric can be a real continuation of the same
419        // token; CJK/accented text (which Rust counts as `is_alphanumeric`) must
420        // act as a delimiter, else a secret glued to non-Latin prose (`数据AKIA…`)
421        // is missed.
422        let at_boundary = abs == 0 || {
423            let prev = text[..abs].chars().next_back().unwrap_or(' ');
424            !prev.is_ascii_alphanumeric()
425        };
426        if at_boundary {
427            let token = extract_token(&text[abs..]);
428            if token.len() >= min_len {
429                return Some(token);
430            }
431        }
432        start = abs + needle.len().max(1);
433    }
434    None
435}
436
437/// Scan for a JWT pattern: at least two "eyJ" segments separated by a `.`
438/// character, with each segment at least 10 chars.
439fn find_jwt(text: &str) -> Option<&str> {
440    let bytes = text.as_bytes();
441    let mut i = 0;
442    while i + 4 < bytes.len() {
443        if bytes[i..].starts_with(b"eyJ") {
444            // Find the end of this JWT (whitespace or string end).
445            let end = bytes[i..]
446                .iter()
447                .position(|&b| b == b' ' || b == b'\n' || b == b'\r' || b == b'\t')
448                .map(|p| i + p)
449                .unwrap_or(bytes.len());
450            let candidate = &text[i..end];
451            // Must have at least 2 dots and 3 eyJ-prefixed segments.
452            let dots = candidate.as_bytes().iter().filter(|&&b| b == b'.').count();
453            if dots >= 2 {
454                let parts: Vec<&str> = candidate.splitn(3, '.').collect();
455                if parts.len() == 3
456                    && parts[0].starts_with("eyJ")
457                    && parts[1].starts_with("eyJ")
458                    && parts[0].len() >= 10
459                    && parts[1].len() >= 10
460                {
461                    return Some(candidate);
462                }
463            }
464            i = end + 1;
465        } else {
466            i += 1;
467        }
468    }
469    None
470}
471
472/// Detect `scheme://user:pass@host` patterns where the `user:pass` portion
473/// contains actual credentials (both user and pass non-empty).
474fn find_url_userinfo(text: &str) -> Option<&str> {
475    let mut search = text;
476    let mut base = 0usize;
477    while let Some(at_rel) = search.find("://") {
478        let at_abs = base + at_rel;
479        // After `://`, look for `@` before the next `/`, `?`, ` `, or newline.
480        let rest_start = at_abs + 3;
481        let rest = &text[rest_start..];
482        if let Some(at_pos) = rest.find('@') {
483            let userinfo = &rest[..at_pos];
484            // Must contain a colon and both sides non-empty.
485            if let Some(colon) = userinfo.find(':') {
486                let user = &userinfo[..colon];
487                let pass = &userinfo[colon + 1..];
488                if !user.is_empty() && !pass.is_empty() && pass.len() >= 4 {
489                    // Return a slice starting from the scheme.  Walk back from
490                    // `at_abs` to the first non-scheme char and resume just past
491                    // it.  Use `char_indices` and skip by the separator's full
492                    // UTF-8 width: a multibyte separator (e.g. CJK prose before a
493                    // credential URL) would otherwise leave `scheme_start` inside
494                    // the codepoint and panic the slice below.
495                    let scheme_start = text[..at_abs]
496                        .char_indices()
497                        .rev()
498                        .find(|(_, c)| {
499                            !c.is_ascii_alphanumeric() && *c != '+' && *c != '-' && *c != '.'
500                        })
501                        .map(|(idx, c)| idx + c.len_utf8())
502                        .unwrap_or(0);
503                    // Ensure there are no spaces in userinfo (not a code snippet).
504                    if !userinfo.contains(' ') && !userinfo.contains('\n') {
505                        let end = rest_start
506                            + at_pos
507                            + 1
508                            + rest[at_pos + 1..]
509                                .find([' ', '\n', '\r'])
510                                .unwrap_or(rest[at_pos + 1..].len());
511                        return Some(&text[scheme_start..end.min(text.len())]);
512                    }
513                }
514            }
515        }
516        base = at_abs + 3;
517        search = &text[base..];
518    }
519    None
520}
521
522// ─── Layer 2: entropy heuristic ─────────────────────────────────────────────
523
524/// Trigger words checked as a bounded standalone word (see
525/// [`contains_bounded_word`]). `token` is deliberately excluded — see
526/// `has_standalone_token`/`has_token_assignment` instead.
527/// See `docs/api/secret_gate.md#trigger_words` for the substring-collision
528/// rationale (issues #577 / #632).
529const TRIGGER_WORDS: &[&str] = &[
530    "key",
531    "secret",
532    "password",
533    "passwd",
534    "credential",
535    "bearer",
536    "auth",
537    "apikey",
538];
539
540/// Compound triggers that retain suffix matching inside credential labels.
541/// Their underscore separator disambiguates them from ordinary prose, and
542/// suffixes are common in versioned credential names such as `api_keyv2`.
543const COMPOUND_TRIGGER_WORDS: &[&str] = &["api_key", "access_key", "private_key"];
544
545/// Minimum token length to apply the entropy check.
546const MIN_ENTROPY_LEN: usize = 24;
547
548/// Shannon entropy threshold (bits per character) above which a token is
549/// considered high-entropy.  7.0 corresponds to ~99% utilisation of a
550/// 128-symbol alphabet — typical for random base64/hex.
551const ENTROPY_THRESHOLD: f64 = 4.5;
552
553/// Window around a trigger word in which a high-entropy token must appear.
554const TRIGGER_WINDOW: usize = 120;
555
556/// Largest index `<= i` that lies on a UTF-8 char boundary of `s`. Stable
557/// replacement for the unstable `str::floor_char_boundary`; used to snap
558/// byte-offset windows that may land inside a multibyte char before slicing.
559fn floor_char_boundary(s: &str, i: usize) -> usize {
560    let mut i = i.min(s.len());
561    while i > 0 && !s.is_char_boundary(i) {
562        i -= 1;
563    }
564    i
565}
566
567/// `from` restricts which tokens may be RETURNED (only those starting at or
568/// after `from`), but the trigger-context window is still computed over the full
569/// `text`. This lets [`mask_secrets`] advance past an earlier redaction without
570/// losing a trigger word that sat to the left of it.
571fn check_entropy_heuristic(text: &str, from: usize) -> Option<(&str, &'static str)> {
572    // Tokenize into maximal ASCII non-whitespace runs, recording each run's byte
573    // offset.  Non-ASCII characters are delimiters (alongside ASCII whitespace):
574    // real base64/hex/base64url credentials are ASCII, so splitting on non-ASCII
575    // isolates an ASCII credential glued to CJK text/punctuation/fullwidth
576    // whitespace, while a run of natural-language CJK yields no ASCII run long
577    // enough to trip the length floor below.  On pure-ASCII input this is
578    // identical to `split_ascii_whitespace`.
579    let tokens: Vec<(usize, &str)> = text
580        .split(|c: char| c.is_ascii_whitespace() || !c.is_ascii())
581        .filter(|t| !t.is_empty())
582        .map(|t| {
583            let offset = t.as_ptr() as usize - text.as_ptr() as usize;
584            (offset, t)
585        })
586        .collect();
587
588    for &(tok_offset, raw_token) in &tokens {
589        // Strip common delimiters that wrap the actual value.
590        let token = strip_delimiters(raw_token);
591        // Only RETURN tokens at or after `from` (already-redacted spans lie
592        // before it); the trigger window below still spans the full text.
593        let token_offset = token.as_ptr() as usize - text.as_ptr() as usize;
594        if token_offset < from {
595            continue;
596        }
597        if token.len() < MIN_ENTROPY_LEN {
598            continue;
599        }
600
601        // `token` is ASCII here (non-ASCII was split out at tokenization), so
602        // `shannon_entropy` over its bytes is a true per-character entropy.
603
604        // Compute the trigger window BEFORE any shape-based allowlist decision.
605        // Every allowlist below (UUID, base64 content-hash, pure-hex) is a
606        // prose-context exemption, not an unconditional one: a credential
607        // trigger word dominates shape allowlists, because attacker-suppliable
608        // shapes (a UUID, a sha-prefixed hash) are exactly as ambiguous near a
609        // trigger word as any other high-entropy candidate.
610        let window_start = floor_char_boundary(text, tok_offset.saturating_sub(TRIGGER_WINDOW));
611        let window_end = floor_char_boundary(text, tok_offset + raw_token.len() + TRIGGER_WINDOW);
612        let window = &text[window_start..window_end];
613        let raw_start = tok_offset - window_start;
614        let raw_end = raw_start + raw_token.len();
615
616        // A high-entropy candidate must not provide its own surrounding
617        // trigger context. This matters for paths whose slugs contain a real
618        // trigger token, such as `ADR-051-cli-auth-and-kg-git-workflow.md`.
619        // Search the prose on either side independently, then preserve inline
620        // credential shapes through a focused assignment/config check.
621        let near_trigger = contains_trigger(&window[..raw_start])
622            || contains_trigger(&window[raw_end..])
623            || has_inline_credential_trigger(raw_token);
624
625        // UUID canonical form and sha-prefixed base64 content hashes (SRI /
626        // npm lockfile integrity) are allowlisted only outside trigger
627        // context. Near a trigger, both shapes fall through to detection
628        // below instead of being silently passed.
629        //
630        // A UUID's own character entropy cannot be relied on to catch it once
631        // it falls through: hex digits cap at log2(16) = 4.0 bits/char, which
632        // never reaches ENTROPY_THRESHOLD (4.5) regardless of token length.
633        // The explicit checks immediately below are what actually block a
634        // UUID-shaped or hash-shaped token in trigger context; letting it run
635        // into the generic entropy computation at the bottom of this loop
636        // would silently readmit it. A corpus replay of ~19k real notes/docs
637        // measured exactly one benign token (an internal task `area_id` UUID
638        // co-occurring with the word "auth" inside `authorized_write`) newly
639        // blocked by this rule — an accepted false positive, not a systemic
640        // regression.
641        //
642        // Both exact-shape checkers require the WHOLE token to match, so a
643        // credential glued to ordinary storage syntax (`api_key=<uuid>`,
644        // `(<uuid>)`, `{"api_key":"<uuid>"}`, a trailing sentence period,
645        // a doubled assignment, or a label itself containing `:`/`=`)
646        // would otherwise never reach them: `strip_delimiters` above only
647        // trims `"'`:=,;` at the token's OUTER ends, not braces/parens, and
648        // not an internal `=`/`:` from an assignment form. `value_candidates`
649        // enumerates every plausible value extraction from those glued forms
650        // specifically for this pair of checks — it does not replace `token`
651        // for any other check in this loop (entropy, hex, structured-
652        // identifier), none of which require an exact shape match. This is a
653        // small bounded iteration over separator positions in one token, not
654        // an allocation-heavy scan.
655        if near_trigger && value_candidates(token).any(is_uuid_canonical) {
656            return Some((token, "uuid-near-trigger"));
657        }
658        if near_trigger && value_candidates(token).any(is_base64_content_hash) {
659            return Some((token, "content-hash-near-trigger"));
660        }
661        if !near_trigger && (is_uuid_canonical(token) || is_base64_content_hash(token)) {
662            continue;
663        }
664
665        // Pure hex tokens (git SHA, checksum digests) are allowlisted only when
666        // they are NOT near a credential trigger.
667        if !near_trigger && is_pure_hex(token) {
668            continue;
669        }
670
671        // Hex API keys (AWS secret access key, Stripe test keys, random hex
672        // tokens) are pure hex yet are real credentials.  The entropy heuristic
673        // cannot catch them — hex alphabet maxes at log2(16) = 4.0 bits/char,
674        // which is always below ENTROPY_THRESHOLD (4.5).  A credential-shaped
675        // hex token (32 / 40 / 64 / 128 chars) near a trigger word is always
676        // flagged.  Credential triggers dominate: adding "sha" or "hash" to
677        // the window does not rescue the token — a caller controlling the prose
678        // could trivially bypass the gate with one extra word.  Safe git SHAs
679        // and content-hash digests do not appear near credential trigger words
680        // and are already allowed via the `!near_trigger && is_pure_hex` path.
681        const HEX_CREDENTIAL_LENGTHS: &[usize] = &[32, 40, 64, 128];
682        if near_trigger && is_pure_hex(token) && HEX_CREDENTIAL_LENGTHS.contains(&token.len()) {
683            return Some((token, "hex-credential-token"));
684        }
685
686        // Structured identifiers (file paths, branch names, ADR/doc slugs,
687        // snake_case identifiers) are exempted from the entropy check — see
688        // the module doc and `is_structured_identifier`. Must come after the
689        // UUID/content-hash and hex-credential-token checks above (neither of
690        // which it weakens) and before the entropy computation, since a
691        // legitimate path can exceed ENTROPY_THRESHOLD on Shannon entropy
692        // alone. The exemption applies ONLY outside trigger context: see the
693        // module doc for why no shape-based signal can be made sound near a
694        // trigger word; in trigger context this falls through to the entropy
695        // heuristic below unconditionally, an accepted false-positive
696        // tradeoff for genuine paths that happen to score high entropy.
697        if !near_trigger && is_structured_identifier(token) {
698            continue;
699        }
700
701        let entropy = shannon_entropy(token.as_bytes());
702        if entropy < ENTROPY_THRESHOLD {
703            continue;
704        }
705
706        // High-entropy token in trigger context — flag it.
707        if near_trigger {
708            return Some((token, "high-entropy-token"));
709        }
710    }
711    None
712}
713
714/// Returns `true` when `low_window` contains `needle` as a standalone word —
715/// bounded on both sides by a character outside the word-char set (or
716/// start/end of string) — rather than merely as a substring.
717/// `underscore_is_word_char` selects the boundary rule the caller needs; see
718/// `docs/api/secret_gate.md#contains_word` for the two deliberately different
719/// rules and why each caller needs its own.
720fn contains_word(low_window: &str, needle: &str, underscore_is_word_char: bool) -> bool {
721    let is_word_char = |c: char| c.is_ascii_alphanumeric() || (underscore_is_word_char && c == '_');
722    let mut start = 0;
723    while let Some(rel) = low_window[start..].find(needle) {
724        let abs = start + rel;
725        let before_ok = abs == 0
726            || low_window[..abs]
727                .chars()
728                .next_back()
729                .is_none_or(|c| !is_word_char(c));
730        let after_end = abs + needle.len();
731        let after_ok = after_end >= low_window.len()
732            || low_window[after_end..]
733                .chars()
734                .next()
735                .is_none_or(|c| !is_word_char(c));
736        if before_ok && after_ok {
737            return true;
738        }
739        start = abs + needle.len().max(1);
740    }
741    false
742}
743
744/// Returns `true` when `low_window` contains the bare trigger word `needle`
745/// as a standalone word, with underscore treated as a BOUNDARY (see
746/// [`contains_word`]) — so `secret_key=…`/`auth_token=…`/`signing_key=…`
747/// still match (on the `secret`/`auth`/`key` half), while pure letter-joined
748/// collisions like `authorized`/`authentication`/`monkey`/`keyword` do not.
749fn contains_bounded_word(low_window: &str, needle: &str) -> bool {
750    contains_word(low_window, needle, false)
751}
752
753/// Returns `true` when a compound credential label begins at an identifier
754/// boundary. The trailing edge is deliberately unbounded so version suffixes
755/// and larger underscore-composed labels remain protected.
756fn contains_compound_trigger(low_text: &str) -> bool {
757    COMPOUND_TRIGGER_WORDS.iter().any(|needle| {
758        let mut start = 0;
759        while let Some(rel) = low_text[start..].find(needle) {
760            let abs = start + rel;
761            let before_ok = abs == 0
762                || low_text[..abs]
763                    .chars()
764                    .next_back()
765                    .is_none_or(|c| !c.is_ascii_alphanumeric());
766            if before_ok {
767                return true;
768            }
769            start = abs + needle.len();
770        }
771        false
772    })
773}
774
775/// Returns `true` when `text` contains a boundary-delimited credential trigger.
776fn contains_trigger(text: &str) -> bool {
777    let low = text.to_ascii_lowercase();
778    TRIGGER_WORDS
779        .iter()
780        .any(|tw| contains_bounded_word(&low, tw))
781        || contains_compound_trigger(&low)
782        || has_standalone_token(&low)
783        || has_token_assignment(&low)
784        || has_assignment_credential_trigger(&low)
785}
786
787/// Detect a credential-bearing assignment label before an `=` or `:`.
788///
789/// The separator may be preceded by whitespace or a JSON quote. Compound
790/// triggers deliberately retain substring matching inside the label so common
791/// version suffixes such as `api_keyv2` remain protected.
792fn has_assignment_credential_trigger(low_text: &str) -> bool {
793    low_text.char_indices().any(|(index, ch)| {
794        if !matches!(ch, '=' | ':') {
795            return false;
796        }
797        let before =
798            low_text[..index].trim_end_matches(|c: char| !c.is_ascii_alphanumeric() && c != '_');
799        let label = before
800            .rsplit(|c: char| !c.is_ascii_alphanumeric() && c != '_')
801            .next()
802            .unwrap_or_default();
803        COMPOUND_TRIGGER_WORDS
804            .iter()
805            .any(|needle| label.contains(needle))
806            || TRIGGER_WORDS
807                .iter()
808                .any(|tw| contains_bounded_word(label, tw))
809            || label == "token"
810    })
811}
812
813/// Detect credential labels embedded in the same whitespace token as a value.
814///
815/// The surrounding-context scan deliberately excludes the candidate token so
816/// a trigger word inside a path cannot make that path self-trigger. Credential
817/// assignments still need to fire when no whitespace separates label and
818/// value, including JSON-like forms. Underscore-delimited config identifiers
819/// without an assignment are retained for compatibility with shapes such as
820/// `session_secret_<value>`.
821fn has_inline_credential_trigger(raw_token: &str) -> bool {
822    let low = raw_token.to_ascii_lowercase();
823
824    if has_assignment_credential_trigger(&low) {
825        return true;
826    }
827
828    !low.contains(['/', '-', '.'])
829        && low.contains('_')
830        && (COMPOUND_TRIGGER_WORDS
831            .iter()
832            .any(|needle| low.contains(needle))
833            || TRIGGER_WORDS
834                .iter()
835                .any(|tw| contains_bounded_word(&low, tw)))
836}
837
838/// Returns `true` when `low_window` contains the word `token` as a standalone
839/// word, with underscore treated as a WORD CHARACTER / continuation (see
840/// [`contains_word`]) — but NOT as part of compound identifiers such as
841/// `tokenizer`, `token_count`, or `next_token`. This underscore-as-
842/// continuation rule is deliberately different from
843/// [`contains_bounded_word`]: `token` alone is not a
844/// credential trigger (it fires on too many benign technical terms), so it
845/// needs the narrower, underscore-inclusive standalone-word definition,
846/// whereas the bare `TRIGGER_WORDS` need underscore-joined compounds like
847/// `secret_key` to still register.
848fn has_standalone_token(low_window: &str) -> bool {
849    contains_word(low_window, "token", true)
850}
851
852/// Returns `true` when `low_window` contains the assignment form `token=` or
853/// `token:` where the `token` identifier has a word boundary BEFORE it.
854///
855/// This is boundary-aware so that compound identifiers like `next_token:` or
856/// `pagination_token=` do NOT trigger — only a standalone `token=`/`token:`
857/// at the start of a field name does.
858///
859/// Examples that return `true`:  `token=<value>`, `token: <value>`,
860///   `"token": "<value>"` (JSON key-value pairs).
861/// Examples that return `false`: `next_token: <value>`,
862///   `pagination_token=<value>`, `token_count: <value>`.
863fn has_token_assignment(low_window: &str) -> bool {
864    let needle = "token";
865    let mut start = 0;
866    while let Some(rel) = low_window[start..].find(needle) {
867        let abs = start + rel;
868        // Require a word boundary BEFORE `token`.
869        let before_ok = abs == 0
870            || low_window[..abs]
871                .chars()
872                .next_back()
873                .is_none_or(|c| !c.is_ascii_alphanumeric() && c != '_');
874        let after_end = abs + needle.len();
875        // Require `=` or `:` immediately after `token` (possibly with surrounding
876        // whitespace or quotes stripped by the time we see the lowercased window).
877        let after_char = low_window[after_end..].chars().next();
878        let after_is_assign = matches!(after_char, Some('=') | Some(':'));
879        if before_ok && after_is_assign {
880            return true;
881        }
882        start = abs + needle.len().max(1);
883    }
884    false
885}
886
887// ─── Allowlist helpers ───────────────────────────────────────────────────────
888
889/// Returns `true` for pure-hex tokens (case-insensitive, optional `0x`/`0X` prefix,
890/// 8–128 chars) — git SHAs, checksum digests, uuid-hex without hyphens.
891///
892/// This helper is used with context: pure-hex tokens near credential trigger words
893/// are NOT allowlisted (see `check_entropy_heuristic`).  Only call this function
894/// when you have already confirmed no trigger context is nearby.
895fn is_pure_hex(token: &str) -> bool {
896    let hex_part = token
897        .strip_prefix("0x")
898        .or(token.strip_prefix("0X"))
899        .unwrap_or(token);
900    hex_part.len() >= 8 && hex_part.len() <= 128 && hex_part.bytes().all(|b| b.is_ascii_hexdigit())
901}
902
903/// Returns `true` for tokens that are unambiguous base64/base64url content
904/// hashes with an explicit `sha<N>-` prefix (SRI hash, npm lockfile integrity).
905/// Bare base64 of the same length WITHOUT the prefix is NOT allowlisted — see
906/// `docs/api/secret_gate.md#is_base64_content_hash` for the full criteria list and
907/// why the explicit prefix is required.
908fn is_base64_content_hash(token: &str) -> bool {
909    // Known vendor prefixes — never allowlist even if they look like base64.
910    // Includes bare `sk-` to prevent OpenAI-shaped tokens from being allowlisted.
911    const VENDOR_PREFIXES: &[&str] = &[
912        "sk-",
913        "rk_live_",
914        "fm2_",
915        "vercel_",
916        "xoxb-",
917        "xoxa-",
918        "xoxp-",
919        "xoxr-",
920        "xoxs-",
921        "ghp_",
922        "gho_",
923        "ghu_",
924        "ghs_",
925        "ghr_",
926        "github_pat_",
927        "AKIA",
928        "ASIA",
929        "AGE-SECRET-KEY-",
930        "FlyV1",
931    ];
932    if VENDOR_PREFIXES.iter().any(|p| token.starts_with(p)) {
933        return false;
934    }
935    // Require an explicit SRI `sha[0-9]+-` prefix.  Bare base64 at sha-length
936    // is NOT allowlisted — it is indistinguishable from a real API token.
937    let body = if let Some(rest) = token.strip_prefix("sha") {
938        // rest starts with digits followed by '-'
939        let dash = rest.find('-').unwrap_or(rest.len());
940        let digits = &rest[..dash];
941        if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) && dash < rest.len() {
942            &rest[dash + 1..] // everything after "sha<digits>-"
943        } else {
944            return false; // no valid sha<N>- prefix → not a known content hash
945        }
946    } else {
947        return false; // no sha prefix → not allowlisted
948    };
949    // Strip optional padding (at most 2 `=`).
950    let stripped = body.trim_end_matches('=');
951    let pad_removed = body.len() - stripped.len();
952    if pad_removed > 2 {
953        return false;
954    }
955    // Accept only SHA-family content-hash lengths (43, 64, 86–88 chars unpadded).
956    let n = stripped.len();
957    if n != 43 && n != 64 && !(86..=88).contains(&n) {
958        return false;
959    }
960    // Accept both standard-base64 and URL-safe-base64 alphabets.
961    stripped
962        .bytes()
963        .all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/' || b == b'-' || b == b'_')
964}
965
966/// Structural separators that gate entry into [`is_structured_identifier`]
967/// (rule 1: the token must contain at least one of these). The actual run
968/// decomposition (rule 2) splits on every non-alphanumeric character, not
969/// just these four — see the doc comment on `is_structured_identifier`.
970const STRUCTURAL_SEPARATORS: [char; 4] = ['/', '-', '_', '.'];
971
972/// Largest length a single path/branch/identifier segment (a "run" between
973/// separators) may have and still be considered word-shaped.
974const MAX_RUN_LEN: usize = 24;
975
976/// Runs whose letter portion is at or below this length skip the
977/// case-transition-density check: density is not a meaningful signal on very
978/// short runs (e.g. `R1`, `v2`, `ADR`).
979const DENSITY_EXEMPT_LETTER_LEN: usize = 4;
980
981/// Maximum case-transition density (transitions divided by letter_count - 1)
982/// a run's letter portion may have and still be considered word-shaped.
983const MAX_CASE_TRANSITION_DENSITY: f64 = 0.3;
984
985/// Returns `true` when `token` is shaped like a file path, branch name, or
986/// other structured identifier rather than a high-entropy secret (word-shaped
987/// runs separated by `/`, `-`, `_`, `.`). Exempts from the entropy heuristic
988/// ONLY outside trigger context — see the module doc and
989/// `docs/api/secret_gate.md#is_structured_identifier` for the run-shape criteria.
990fn is_structured_identifier(token: &str) -> bool {
991    if !token.contains(|c: char| STRUCTURAL_SEPARATORS.contains(&c)) {
992        return false;
993    }
994    let runs: Vec<&str> = token
995        .split(|c: char| !c.is_ascii_alphanumeric())
996        .filter(|r| !r.is_empty())
997        .collect();
998    runs.len() >= 2 && runs.iter().all(|run| is_word_shaped_run(run))
999}
1000
1001/// A single run (segment between structural separators) is word-shaped when
1002/// it matches `[A-Za-z]+[0-9]*` or `[0-9]+`, is at most [`MAX_RUN_LEN`] chars,
1003/// and (for the letters-then-digits form) its letter portion has a low
1004/// case-transition density.
1005fn is_word_shaped_run(run: &str) -> bool {
1006    if run.is_empty() || run.len() > MAX_RUN_LEN {
1007        return false;
1008    }
1009    let bytes = run.as_bytes();
1010    if bytes.iter().all(|b| b.is_ascii_digit()) {
1011        return true;
1012    }
1013    let letter_end = bytes
1014        .iter()
1015        .position(|b| !b.is_ascii_alphabetic())
1016        .unwrap_or(bytes.len());
1017    // A run that does not start with a letter, and is not pure digits (ruled
1018    // out above), mixes digits and letters in a shape other than
1019    // letters-then-digits — not word-shaped.
1020    if letter_end == 0 {
1021        return false;
1022    }
1023    // Everything after the leading letters must be digits only (no further
1024    // letters), else the run is not the `[A-Za-z]+[0-9]*` shape.
1025    if !bytes[letter_end..].iter().all(|b| b.is_ascii_digit()) {
1026        return false;
1027    }
1028    case_transition_density_ok(&run[..letter_end])
1029}
1030
1031/// `true` when the case-transition density of `letters` (an all-ASCII-letter
1032/// string) is at or below [`MAX_CASE_TRANSITION_DENSITY`]. A transition is an
1033/// adjacent letter pair where one side is uppercase and the other is not.
1034/// Runs with few enough letters pass automatically (see
1035/// [`DENSITY_EXEMPT_LETTER_LEN`]) since density is noisy on short strings.
1036fn case_transition_density_ok(letters: &str) -> bool {
1037    let chars: Vec<char> = letters.chars().collect();
1038    if chars.len() <= DENSITY_EXEMPT_LETTER_LEN {
1039        return true;
1040    }
1041    let transitions = chars
1042        .windows(2)
1043        .filter(|w| w[0].is_ascii_uppercase() != w[1].is_ascii_uppercase())
1044        .count();
1045    let density = transitions as f64 / (chars.len() - 1) as f64;
1046    density <= MAX_CASE_TRANSITION_DENSITY
1047}
1048
1049/// `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`
1050fn is_uuid_canonical(s: &str) -> bool {
1051    let b = s.as_bytes();
1052    if b.len() != 36 {
1053        return false;
1054    }
1055    b[8] == b'-'
1056        && b[13] == b'-'
1057        && b[18] == b'-'
1058        && b[23] == b'-'
1059        && b[..8].iter().all(|c| c.is_ascii_hexdigit())
1060        && b[9..13].iter().all(|c| c.is_ascii_hexdigit())
1061        && b[14..18].iter().all(|c| c.is_ascii_hexdigit())
1062        && b[19..23].iter().all(|c| c.is_ascii_hexdigit())
1063        && b[24..].iter().all(|c| c.is_ascii_hexdigit())
1064}
1065
1066/// Strip common wrapping characters (`"`, `'`, `` ` ``, `:`, `=`) from both ends.
1067fn strip_delimiters(s: &str) -> &str {
1068    s.trim_matches(|c| matches!(c, '"' | '\'' | '`' | ':' | '=' | ',' | ';'))
1069}
1070
1071/// Strip `{}()[]"'.,;` from both ends of `s`, repeatedly (JSON nests one
1072/// wrapper inside another).
1073fn strip_wrappers(s: &str) -> &str {
1074    s.trim_matches(|c: char| {
1075        matches!(
1076            c,
1077            '{' | '}' | '(' | ')' | '[' | ']' | '"' | '\'' | '`' | '.' | ',' | ';'
1078        )
1079    })
1080}
1081
1082fn wrapper_strip_repeated(token: &str) -> &str {
1083    let mut cur = token;
1084    loop {
1085        let next = strip_wrappers(cur);
1086        if next == cur {
1087            return cur;
1088        }
1089        cur = next;
1090    }
1091}
1092
1093/// Yields every candidate value an assignment/wrapper-glued token could
1094/// contain, for the near-trigger UUID/content-hash exact-shape checks only.
1095/// See `docs/api/secret_gate.md#value_candidates` for why every `=`/`:` suffix
1096/// must be tried rather than just the first or last.
1097fn value_candidates(token: &str) -> impl Iterator<Item = &str> {
1098    let cur = wrapper_strip_repeated(token);
1099    std::iter::once(cur).chain(cur.char_indices().filter_map(move |(i, c)| {
1100        if c == '=' || c == ':' {
1101            let after = strip_wrappers(&cur[i + c.len_utf8()..]);
1102            if !after.is_empty() {
1103                return Some(after);
1104            }
1105        }
1106        None
1107    }))
1108}
1109
1110// ─── Utilities ───────────────────────────────────────────────────────────────
1111
1112/// Extract a contiguous token (non-whitespace chars) starting at the beginning of `s`.
1113fn extract_token(s: &str) -> &str {
1114    let end = s
1115        .find(|c: char| c.is_whitespace() || c == '\n' || c == '\r')
1116        .unwrap_or(s.len());
1117    &s[..end]
1118}
1119
1120/// Shannon entropy in bits per character.
1121///
1122/// H = -∑ p_i log2(p_i)
1123fn shannon_entropy(bytes: &[u8]) -> f64 {
1124    if bytes.is_empty() {
1125        return 0.0;
1126    }
1127    let mut counts = [0u32; 256];
1128    for &b in bytes {
1129        counts[b as usize] += 1;
1130    }
1131    let len = bytes.len() as f64;
1132    counts
1133        .iter()
1134        .filter(|&&c| c > 0)
1135        .map(|&c| {
1136            let p = c as f64 / len;
1137            -p * p.log2()
1138        })
1139        .sum()
1140}
1141
1142/// Build a `SecretMatch` from a detector name and the candidate string.
1143///
1144/// The masked excerpt is: first 6 chars + "..." + total length.
1145/// Never includes more than 6 chars of the actual value.
1146fn build_match(detector: &'static str, candidate: &str) -> SecretMatch {
1147    let chars: Vec<char> = candidate.chars().collect();
1148    let preview: String = chars.iter().take(6).collect();
1149    let masked = format!("{}...{}chars", preview, chars.len());
1150    SecretMatch { detector, masked }
1151}
1152
1153// ─── Tests ───────────────────────────────────────────────────────────────────
1154
1155#[cfg(test)]
1156mod tests {
1157    use super::*;
1158
1159    #[test]
1160    fn blocks_aws_akia() {
1161        // FAKE key: prefix is real shape, 16-char suffix invented.
1162        let fake = "AKIAFAKEKEY1234567890";
1163        assert!(scan(fake).is_some(), "AKIA must be caught");
1164        let m = scan(fake).unwrap();
1165        assert_eq!(m.detector, "aws-access-key-id");
1166        // Masked excerpt must not echo the full key.
1167        assert!(
1168            !m.masked.contains("FAKEKEY1234567890"),
1169            "must not echo the secret: {}",
1170            m.masked
1171        );
1172    }
1173
1174    #[test]
1175    fn blocks_aws_asia() {
1176        let fake = "ASIAFAKEKEY00000000000";
1177        let m = scan(fake);
1178        assert!(m.is_some(), "ASIA must be caught");
1179        assert_eq!(m.unwrap().detector, "aws-access-key-id");
1180    }
1181
1182    #[test]
1183    fn blocks_github_ghp() {
1184        // 36 chars total to pass min_len.
1185        let fake = "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
1186        assert!(scan(fake).is_some(), "ghp_ must be caught");
1187    }
1188
1189    #[test]
1190    fn blocks_github_gho() {
1191        let fake = "gho_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
1192        assert!(scan(fake).is_some(), "gho_ must be caught");
1193    }
1194
1195    #[test]
1196    fn blocks_github_pat() {
1197        let fake = "github_pat_AAAAAABBBBBBCCCCCC";
1198        assert!(scan(fake).is_some(), "github_pat_ must be caught");
1199    }
1200
1201    #[test]
1202    fn blocks_openai_sk() {
1203        let fake = "sk-aaaaaabbbbbbccccccddddddeeeeeeffffgg";
1204        assert!(scan(fake).is_some(), "sk- must be caught");
1205    }
1206
1207    #[test]
1208    fn blocks_anthropic_sk_ant() {
1209        let fake = "sk-ant-api03-AAAAAAAAAAAAAAA";
1210        assert!(scan(fake).is_some(), "sk-ant- must be caught");
1211        assert_eq!(scan(fake).unwrap().detector, "anthropic-api-key");
1212    }
1213
1214    #[test]
1215    fn blocks_stripe_live() {
1216        let fake = "sk_live_FAKESTRIPE0000000000000"; // gitleaks:allow
1217        assert!(scan(fake).is_some(), "sk_live_ must be caught");
1218        assert_eq!(scan(fake).unwrap().detector, "stripe-secret-key");
1219    }
1220
1221    #[test]
1222    fn blocks_stripe_restricted() {
1223        let fake = "rk_live_FAKESTRIPE0000000000000"; // gitleaks:allow
1224        assert!(scan(fake).is_some(), "rk_live_ must be caught");
1225        assert_eq!(scan(fake).unwrap().detector, "stripe-restricted-key");
1226    }
1227
1228    #[test]
1229    fn blocks_fly_flyv1() {
1230        let fake = "FlyV1 FAKEFLYTOKEN000000000000000000";
1231        assert!(scan(fake).is_some(), "FlyV1 must be caught");
1232        assert_eq!(scan(fake).unwrap().detector, "fly-token");
1233    }
1234
1235    #[test]
1236    fn blocks_fly_fm2() {
1237        let fake = "fm2_FAKEFLYTOKEN00000000000000000";
1238        assert!(scan(fake).is_some(), "fm2_ must be caught");
1239        assert_eq!(scan(fake).unwrap().detector, "fly-token");
1240    }
1241
1242    #[test]
1243    fn blocks_vercel_token() {
1244        let fake = "vercel_FAKETOKEN00000000000000000";
1245        assert!(scan(fake).is_some(), "vercel_ must be caught");
1246        assert_eq!(scan(fake).unwrap().detector, "vercel-token");
1247    }
1248
1249    #[test]
1250    fn blocks_slack_xoxb() {
1251        let fake = "xoxb-FAKE-SLACKTOKEN-000000000000000000000000";
1252        assert!(scan(fake).is_some(), "xoxb- must be caught");
1253        assert_eq!(scan(fake).unwrap().detector, "slack-token");
1254    }
1255
1256    #[test]
1257    fn blocks_pem_private_key() {
1258        // Split the header so the literal detector-trigger string is not present
1259        // verbatim in source — pre-commit's detect-private-key hook would fire.
1260        // The gate detects it at runtime because scan() sees the assembled string.
1261        let header = ["-----BEGIN RSA", " PRIVATE KEY-----"].concat(); // gitleaks:allow
1262        let fake = format!("{}\nMIIEo\u{2026}\n-----END RSA PRIVATE KEY-----", header);
1263        assert!(scan(&fake).is_some(), "PEM private key must be caught");
1264        assert_eq!(scan(&fake).unwrap().detector, "pem-private-key");
1265    }
1266
1267    #[test]
1268    fn blocks_pem_ec_private_key() {
1269        let header = ["-----BEGIN EC", " PRIVATE KEY-----"].concat(); // gitleaks:allow
1270        let fake = format!("{}\nMHQCAQEE\u{2026}\n-----END EC PRIVATE KEY-----", header);
1271        assert!(scan(&fake).is_some(), "EC PEM must be caught");
1272    }
1273
1274    #[test]
1275    fn blocks_age_secret_key() {
1276        // AGE-SECRET-KEY- followed by 59 base32 chars (Bech32m body).
1277        let fake = "AGE-SECRET-KEY-1QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ";
1278        assert!(scan(fake).is_some(), "AGE-SECRET-KEY- must be caught");
1279        assert_eq!(scan(fake).unwrap().detector, "age-secret-key");
1280    }
1281
1282    #[test]
1283    fn blocks_jwt_triple() {
1284        // Synthetic JWT structure: header.payload.signature (no real key).
1285        let fake = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.FAKE_SIG_XXXXXXXXXXXX"; // gitleaks:allow
1286        assert!(scan(fake).is_some(), "JWT triple must be caught");
1287        assert_eq!(scan(fake).unwrap().detector, "jwt");
1288    }
1289
1290    #[test]
1291    fn blocks_url_userinfo() {
1292        let fake = "postgresql://dbuser:S3cr3tP4ss@db.example.com:5432/mydb";
1293        assert!(scan(fake).is_some(), "URL userinfo must be caught");
1294        assert_eq!(scan(fake).unwrap().detector, "url-userinfo");
1295    }
1296
1297    #[test]
1298    fn blocks_high_entropy_near_bearer_word() {
1299        // 32 random-looking base64 chars adjacent to the word "bearer".
1300        let fake = "Bearer token: Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM"; // gitleaks:allow
1301        assert!(
1302            scan(fake).is_some(),
1303            "high-entropy value near 'bearer' must be caught"
1304        );
1305        assert_eq!(scan(fake).unwrap().detector, "high-entropy-token");
1306    }
1307
1308    #[test]
1309    fn blocks_high_entropy_near_secret_word() {
1310        let fake = "secret=Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM"; // gitleaks:allow
1311        assert!(
1312            scan(fake).is_some(),
1313            "high-entropy value near 'secret' must be caught"
1314        );
1315    }
1316
1317    #[test]
1318    fn error_message_masks_secret() {
1319        let fake = "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
1320        let m = scan(fake).unwrap();
1321        // Masked form: first 6 chars + "...N chars".
1322        // Must NOT contain the full suffix.
1323        let masked = &m.masked;
1324        assert!(
1325            !masked.contains("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"),
1326            "mask must not echo the full secret value; got: {masked}"
1327        );
1328        // Must start with "ghp_AA" (first 6 chars of the token).
1329        assert!(
1330            masked.starts_with("ghp_AA"),
1331            "mask must show first 6 chars; got: {masked}"
1332        );
1333    }
1334
1335    // ── False-positive suite ─────────────────────────────────────────────────
1336
1337    #[test]
1338    fn allows_sha256_hex() {
1339        // 64-char lowercase hex — typical sha256 digest.
1340        let sha = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
1341        assert!(
1342            scan(sha).is_none(),
1343            "sha256 hex must pass (allowlisted); fired: {:?}",
1344            scan(sha)
1345        );
1346    }
1347
1348    #[test]
1349    fn allows_uuid() {
1350        let uuid = "550e8400-e29b-41d4-a716-446655440000";
1351        assert!(
1352            scan(uuid).is_none(),
1353            "UUID must pass; fired: {:?}",
1354            scan(uuid)
1355        );
1356    }
1357
1358    #[test]
1359    fn allows_git_sha() {
1360        // 40-char lowercase git SHA.
1361        let sha = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
1362        assert!(
1363            scan(sha).is_none(),
1364            "git SHA must pass; fired: {:?}",
1365            scan(sha)
1366        );
1367    }
1368
1369    #[test]
1370    fn allows_normal_prose() {
1371        let prose =
1372            "The FlashAttention paper introduces IO-aware tiling for transformer self-attention.";
1373        assert!(scan(prose).is_none(), "normal prose must pass");
1374    }
1375
1376    #[test]
1377    fn allows_code_snippet() {
1378        let code = r#"fn create_entity(name: &str, kind: &str) -> RuntimeResult<Entity> {
1379    self.validate_entity_kind(kind)?;
1380    Ok(Entity::new("local", kind, name))
1381}"#;
1382        assert!(
1383            scan(code).is_none(),
1384            "code snippet must pass; fired: {:?}",
1385            scan(code)
1386        );
1387    }
1388
1389    #[test]
1390    fn allows_long_url_without_credentials() {
1391        let url = "https://docs.example.com/api/v2/entities?kind=concept&limit=100";
1392        assert!(scan(url).is_none(), "URL without userinfo must pass");
1393    }
1394
1395    #[test]
1396    fn allows_base64_image_stub() {
1397        // Realistic short base64 data URI stub — no trigger words, below threshold length.
1398        let b64 = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQI12NgAAIABQ";
1399        assert!(
1400            scan(b64).is_none(),
1401            "base64 image stub without trigger word must pass; fired: {:?}",
1402            scan(b64)
1403        );
1404    }
1405
1406    #[test]
1407    fn allows_long_plain_url() {
1408        let url = "https://api.github.com/repos/ohdearquant/khive/pulls/76/comments?per_page=100";
1409        assert!(
1410            scan(url).is_none(),
1411            "plain URL must pass; fired: {:?}",
1412            scan(url)
1413        );
1414    }
1415
1416    #[test]
1417    fn allows_manifest_content_hash() {
1418        // A string like what appears in Cargo.lock or npm lockfiles.
1419        let line =
1420            "checksum = \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"";
1421        assert!(
1422            scan(line).is_none(),
1423            "manifest content hash line must pass; fired: {:?}",
1424            scan(line)
1425        );
1426    }
1427
1428    #[test]
1429    fn masked_excerpt_format() {
1430        let fake = "AKIAFAKEKEY1234567890";
1431        let m = scan(fake).unwrap();
1432        // Format: first6...Nchars
1433        assert!(m.masked.contains("..."), "masked must contain '...'");
1434        assert!(m.masked.ends_with("chars"), "masked must end with 'chars'");
1435    }
1436
1437    // ── Gate function ────────────────────────────────────────────────────────
1438
1439    #[test]
1440    fn check_returns_ok_for_safe_content() {
1441        assert!(check("A normal memory note about LoRA.").is_ok());
1442    }
1443
1444    #[test]
1445    fn check_returns_err_for_secret() {
1446        let fake = "AKIAFAKEKEY1234567890";
1447        let result = check(fake);
1448        assert!(result.is_err(), "check must fail for AKIA key");
1449        let err = result.unwrap_err();
1450        assert!(
1451            matches!(err, RuntimeError::SecretDetected(_)),
1452            "error variant must be SecretDetected"
1453        );
1454    }
1455
1456    // ── Entropy helpers ──────────────────────────────────────────────────────
1457
1458    #[test]
1459    fn entropy_of_uniform_string_is_zero() {
1460        let s = "aaaaaaaaaaaaaaaa";
1461        assert!(shannon_entropy(s.as_bytes()) < 0.01);
1462    }
1463
1464    #[test]
1465    fn entropy_of_random_bytes_is_high() {
1466        // A truly random-looking string should exceed 4.5 bits/char.
1467        let s = b"X9kZ2vQpLrT8nJwYuAeHfBsDcGiONvM1"; // 32 mixed base64 chars
1468        assert!(shannon_entropy(s) > 4.5, "entropy={}", shannon_entropy(s));
1469    }
1470
1471    #[test]
1472    fn cjk_prose_near_trigger_is_not_flagged() {
1473        // Regression: a multibyte CJK run (~19 chars = 57 bytes) clears the
1474        // byte-length floor, and `shannon_entropy` over UTF-8 bytes reads it as
1475        // high-entropy — so a Chinese title near the `auth` trigger word used to
1476        // false-positive as `high-entropy-token`.  Non-ASCII tokens are now
1477        // skipped by the entropy heuristic: real base64/hex credentials are
1478        // ASCII, so this cannot hide a secret.
1479        let content = "更新 auth 配置数据库连接管理系统核心模块设计文档";
1480        assert!(
1481            check(content).is_ok(),
1482            "CJK prose near a trigger word must not be flagged as a secret"
1483        );
1484    }
1485
1486    #[test]
1487    fn ascii_secret_near_trigger_still_flagged() {
1488        // The non-ASCII skip must NOT weaken detection of genuine ASCII
1489        // high-entropy credentials near a trigger word.
1490        let content = "api_key X9kZ2vQpLrT8nJwYuAeHfBsDcGiONvM1";
1491        assert!(
1492            check(content).is_err(),
1493            "ASCII high-entropy token near a trigger word must still be blocked"
1494        );
1495    }
1496
1497    #[test]
1498    fn ascii_secret_in_cjk_context_does_not_panic_and_is_flagged() {
1499        // The ±120-byte trigger window around an ASCII token can land in the
1500        // middle of a multibyte CJK character when the token is embedded in
1501        // non-Latin prose.  Slicing on a non-char-boundary would panic — the
1502        // window bounds are snapped via `floor_char_boundary`.  Detection of
1503        // the genuine ASCII secret must still fire.
1504        let cjk = "数据库连接管理系统核心模块设计文档".repeat(6); // 17 chars × 6 = 306 bytes
1505                                                                  // The leading single-byte `x` breaks 3-byte CJK alignment so the window
1506                                                                  // start (token_offset - 120) lands mid-character without the snap.
1507        let content = format!("{cjk}x api_key X9kZ2vQpLrT8nJwYuAeHfBsDcGiONvM1 {cjk}");
1508        assert!(
1509            check(&content).is_err(),
1510            "ASCII secret in CJK context must still be blocked (and must not panic)"
1511        );
1512    }
1513
1514    #[test]
1515    fn ascii_secret_glued_to_cjk_is_still_flagged() {
1516        // Regression: a prefixless high-entropy credential glued (no ASCII
1517        // whitespace) to CJK text, CJK brackets/quotes, a fullwidth space, or a
1518        // fullwidth colon used to slip through, because the whole whitespace token
1519        // contained a non-ASCII byte and was skipped wholesale.  Non-ASCII is now
1520        // a token delimiter, so the ASCII credential run is isolated and
1521        // entropy-checked while the surrounding ±120-byte window still sees the
1522        // trigger word.
1523        let secret = "X9kZ2vQpLrT8nJwYuAeHfBsDcGiONvM1"; // gitleaks:allow
1524        let cases = [
1525            format!("api_key {secret}数据"),     // CJK suffix glued to the token
1526            format!("api_key 「{secret}」"),     // CJK brackets wrap the token
1527            format!("api_key {secret}"),        // U+3000 ideographic space separator
1528            format!("api_key:{secret}"),        // U+FF1A fullwidth colon separator
1529            format!("数据{secret}更新 api_key"), // CJK-glued prefix, trigger after
1530        ];
1531        for content in &cases {
1532            assert!(
1533                check(content).is_err(),
1534                "ASCII secret glued to CJK must be blocked: {content:?}"
1535            );
1536        }
1537    }
1538
1539    #[test]
1540    fn high_entropy_ascii_run_without_trigger_is_not_flagged() {
1541        // The non-ASCII-as-delimiter change must not weaken the trigger-context
1542        // discipline: a high-entropy ASCII run isolated from CJK prose but NOT
1543        // near a credential trigger word is still allowed (only the tokenizer
1544        // changed, not the `near_trigger` gate).
1545        let secret = "X9kZ2vQpLrT8nJwYuAeHfBsDcGiONvM1"; // gitleaks:allow
1546        let content = format!("数据库连接{secret}核心模块设计文档");
1547        assert!(
1548            check(&content).is_ok(),
1549            "high-entropy ASCII run with no trigger word must not be flagged"
1550        );
1551    }
1552
1553    #[test]
1554    fn known_prefix_secret_glued_after_cjk_is_still_flagged() {
1555        // A Layer-1 known-prefix secret glued directly after
1556        // CJK prose (no ASCII whitespace) was missed, because the prefix boundary
1557        // check used `is_alphanumeric` — which Rust counts true for CJK — so the
1558        // preceding ideograph was not treated as a delimiter.  These credentials
1559        // must be caught with no nearby ASCII trigger word, on the left side too.
1560        let cases = [
1561            "数据AKIAIOSFODNN7EXAMPLE",             // gitleaks:allow
1562            "令牌github_pat_11ABCDEFG0HIJKLMNOPQR", // gitleaks:allow
1563            "密钥sk-ant-api03-AAAAAAAAAAAAAAAAAA",  // gitleaks:allow
1564            "配置FlyV1 fm2_AAAABBBBCCCCDDDD",       // gitleaks:allow
1565        ];
1566        for content in cases {
1567            assert!(
1568                check(content).is_err(),
1569                "known-prefix secret glued after CJK must be blocked: {content:?}"
1570            );
1571        }
1572    }
1573
1574    #[test]
1575    fn url_userinfo_after_cjk_does_not_panic_and_is_flagged() {
1576        // A credential URL glued after CJK prose panicked,
1577        // because scheme_start was (separator byte index + 1) — one byte into a
1578        // multibyte CJK separator — and the slice fell on a non-char boundary.
1579        // The public check() API must return a controlled error, never panic.
1580        let cases = [
1581            "数据postgresql://dbuser:S3cr3tP4ss@db.example.com/db", // gitleaks:allow
1582            "配置mysql://root:hunter2pw@10.0.0.1:3306/app",         // gitleaks:allow
1583            "连接redis://svc:V3ryS3cretPw@cache.internal:6379",     // gitleaks:allow
1584        ];
1585        for content in cases {
1586            assert!(
1587                check(content).is_err(),
1588                "credential URL after CJK must be blocked, not panic: {content:?}"
1589            );
1590        }
1591    }
1592
1593    #[test]
1594    fn non_ascii_glued_token_trigger_is_still_flagged() {
1595        // `token=`/`token:`/standalone `token` glued directly
1596        // after non-ASCII prose was missed because has_standalone_token /
1597        // has_token_assignment used is_alphanumeric for the word boundary — CJK,
1598        // accented letters, and fullwidth digits all count as alphanumeric in
1599        // Rust, so the preceding char was not seen as a boundary and the `token`
1600        // trigger was suppressed, leaving the high-entropy value unflagged.
1601        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
1602        let blocked = [
1603            format!("数据token={opaque}"),    // CJK + assignment form, ASCII '='
1604            format!("配置token: {opaque}"),   // CJK + assignment form, ASCII ':'
1605            format!("密钥token {opaque}"),    // CJK + standalone-word form
1606            format!("résumétoken: {opaque}"), // accented letter before `token`
1607            format!("1token: {opaque}"),     // fullwidth digit before `token`
1608        ];
1609        for content in &blocked {
1610            assert!(
1611                check(content).is_err(),
1612                "non-ASCII-glued token trigger must flag the value: {content:?}"
1613            );
1614        }
1615        // Compound identifiers stay excluded — the `_` boundary rule is unchanged
1616        // and an ASCII letter before `token` is still a continuation, so these
1617        // (including the pure-ASCII `servicetoken:`) must still pass.
1618        let allowed = [
1619            format!("数据next_token: {opaque}"),
1620            format!("数据token_count: {opaque}"),
1621            format!("servicetoken: {opaque}"),
1622        ];
1623        for content in &allowed {
1624            assert!(
1625                check(content).is_ok(),
1626                "compound token identifier must not be flagged: {content:?}"
1627            );
1628        }
1629    }
1630
1631    #[test]
1632    fn allowlist_passes_sha256() {
1633        // A plain sha256 hex digest passes via `is_pure_hex` (not `is_allowlisted`
1634        // because hex is now context-dependent; this tests the primitive directly).
1635        let sha = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
1636        assert!(is_pure_hex(sha));
1637    }
1638
1639    #[test]
1640    fn allowlist_passes_uuid_canonical() {
1641        assert!(is_uuid_canonical("550e8400-e29b-41d4-a716-446655440000"));
1642    }
1643
1644    #[test]
1645    fn allowlist_does_not_pass_mixed_token() {
1646        // A token that starts with letters but mixes in non-hex chars.
1647        assert!(!is_pure_hex("sk-aaaaaabbbbbbccccccddddddeeeeeeffffgg"));
1648    }
1649
1650    // ── Structured-field gate helpers ────────────────────────────────────────
1651
1652    #[test]
1653    fn check_json_blocks_secret_in_object_value() {
1654        let props = serde_json::json!({ "api_key": "AKIAFAKEKEY1234567890" });
1655        assert!(
1656            check_json(&props).is_err(),
1657            "secret in properties object value must be blocked"
1658        );
1659    }
1660
1661    #[test]
1662    fn check_json_blocks_secret_in_nested_object() {
1663        let props = serde_json::json!({ "credentials": { "token": "sk-proj-FAKEKEY00000000000000000000000000000000" } }); // gitleaks:allow
1664        assert!(
1665            check_json(&props).is_err(),
1666            "secret in nested properties object must be blocked"
1667        );
1668    }
1669
1670    #[test]
1671    fn check_json_blocks_secret_in_array() {
1672        let props = serde_json::json!(["normal", "AKIAFAKEKEY1234567890"]);
1673        assert!(
1674            check_json(&props).is_err(),
1675            "secret in JSON array must be blocked"
1676        );
1677    }
1678
1679    #[test]
1680    fn check_json_passes_safe_properties() {
1681        let props = serde_json::json!({
1682            "domain": "attention",
1683            "status": "researched",
1684            "year": 2024
1685        });
1686        assert!(
1687            check_json(&props).is_ok(),
1688            "normal properties must pass; fired: {:?}",
1689            check_json(&props).err()
1690        );
1691    }
1692
1693    #[test]
1694    fn check_tags_blocks_credential_tag() {
1695        let tags = vec![
1696            "type:concept".to_string(),
1697            "AKIAFAKEKEY1234567890".to_string(),
1698        ];
1699        assert!(
1700            check_tags(&tags).is_err(),
1701            "credential-shaped tag must be blocked"
1702        );
1703    }
1704
1705    #[test]
1706    fn check_tags_passes_normal_tags() {
1707        let tags = vec!["type:concept".to_string(), "domain:attention".to_string()];
1708        assert!(
1709            check_tags(&tags).is_ok(),
1710            "normal tags must pass; fired: {:?}",
1711            check_tags(&tags).err()
1712        );
1713    }
1714
1715    // ── False-positive: sk-learn and scikit-learn slugs ──────────────────────
1716
1717    #[test]
1718    fn allows_sk_learn_prose() {
1719        // scikit-learn slug used as an entity name or knowledge atom.
1720        let texts = &[
1721            "sk-learn is a Python machine learning library",
1722            "sk-learn-compatible transformer pipeline reference",
1723            "sk-learn scikit-learn estimator interface",
1724        ];
1725        for t in texts {
1726            assert!(
1727                scan(t).is_none(),
1728                "sk-learn prose must pass; fired: {:?} on {:?}",
1729                scan(t),
1730                t
1731            );
1732        }
1733    }
1734
1735    #[test]
1736    fn blocks_openai_sk_proj_not_confused_with_sk_learn() {
1737        // Real OpenAI key shape must still be caught.
1738        let fake = "sk-proj-FAKEKEY00000000000000000000000000000000"; // gitleaks:allow
1739        assert!(
1740            scan(fake).is_some(),
1741            "sk-proj- key must still be caught after sk-learn exemption"
1742        );
1743    }
1744
1745    // ── False-positive: SRI / tokenizer hash metadata ────────────────────────
1746
1747    #[test]
1748    fn blocks_sri_hash_near_key_word_accepted_fp() {
1749        // SRI hash as used in HTML integrity attributes (sha384, base64-encoded),
1750        // placed directly beside the trigger word "key". The content-hash
1751        // allowlist is a prose-context exemption, not unconditional: near a
1752        // credential trigger, a sha-prefixed hash falls through to the explicit
1753        // near-trigger content-hash detector like any other high-entropy
1754        // candidate. This is an accepted false positive on a real but rare
1755        // shape (an integrity hash literally next to the word "key").
1756        let line = "integrity key: sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC";
1757        assert!(
1758            scan(line).is_some(),
1759            "SRI hash near trigger word 'key' must now be blocked (accepted FP); passed unexpectedly"
1760        );
1761    }
1762
1763    #[test]
1764    fn allows_base64_tokenizer_hash_metadata() {
1765        // Tokenizer metadata containing a base64 hash near technical keywords.
1766        let line = "tokenizer_vocab_hash: Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM"; // gitleaks:allow
1767        assert!(
1768            scan(line).is_none(),
1769            "tokenizer hash metadata must pass; fired: {:?}",
1770            scan(line)
1771        );
1772    }
1773
1774    #[test]
1775    fn allows_npm_lockfile_integrity() {
1776        // npm lockfile integrity line with sha512 base64url hash (86 base64 chars + ==).
1777        // sha512 digest = 64 bytes → base64 = 88 chars (86 unpadded + ==).
1778        let body_86 = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM1234567890abcdefghijklmnopqrstuvwxABCDEFGHIJKLMNOPQRST";
1779        assert_eq!(body_86.len(), 86, "test body must be exactly 86 chars");
1780        let line = format!(
1781            "resolved: https://registry.npmjs.org/foo/-/foo-1.0.0.tgz\nintegrity: sha512-{body_86}=="
1782        );
1783        assert!(
1784            scan(&line).is_none(),
1785            "npm lockfile integrity must pass; fired: {:?}",
1786            scan(&line)
1787        );
1788    }
1789
1790    // ── False-positive: tokenizer vs token trigger word ─────────────────────
1791
1792    #[test]
1793    fn allows_tokenizer_vocab_hash_no_block() {
1794        // `tokenizer_vocab_hash` contains the substring "token" but NOT as a
1795        // standalone word (followed by 'i' which is alphanumeric), so the
1796        // standalone-token boundary check must not fire here.
1797        let line = "tokenizer_vocab_hash = Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM"; // gitleaks:allow
1798        assert!(
1799            scan(line).is_none(),
1800            "tokenizer_vocab_hash must pass; 'token' is only standalone-word matched; fired: {:?}",
1801            scan(line)
1802        );
1803    }
1804
1805    // ── True-positives: bare base64 at sha-lengths near trigger words ────────
1806
1807    #[test]
1808    fn blocks_bare_base64url_43chars_near_key() {
1809        // A 43-char base64url token (= sha256 body length) near the word "key".
1810        // Without a sha<N>- prefix this MUST be caught, not allowlisted.
1811        let token_43 = "wJalrXUtnFEMI-K7MDENGbPxRfiCYEXAMPLEKEYX123"; // gitleaks:allow
1812        assert_eq!(token_43.len(), 43, "test token must be exactly 43 chars");
1813        let line = format!("api key {token_43}");
1814        assert!(
1815            scan(&line).is_some(),
1816            "43-char base64url token near 'key' must be caught (no sha-prefix = not a hash); fired: {:?}",
1817            scan(&line)
1818        );
1819    }
1820
1821    #[test]
1822    fn blocks_bare_base64url_64chars_near_secret() {
1823        // A 64-char base64url token (= sha384 body length) near "secret".
1824        // Must be caught without sha<N>- prefix.
1825        let token_64 = "wJalrXUtnFEMI-K7MDENGbPxRfiCYEXAMPLEKEYX123wJalrXUtnFEMI-K7MDENa"; // gitleaks:allow
1826        assert_eq!(token_64.len(), 64, "test token must be exactly 64 chars");
1827        let line = format!("secret: {token_64}");
1828        assert!(
1829            scan(&line).is_some(),
1830            "64-char base64url token near 'secret' must be caught; got: {:?}",
1831            scan(&line)
1832        );
1833    }
1834
1835    #[test]
1836    fn blocks_bare_base64url_86chars_near_auth() {
1837        // An 86-char base64url token (= sha512 body length) near "auth".
1838        // Must be caught without sha<N>- prefix.
1839        let token_86 = "wJalrXUtnFEMI-K7MDENGbPxRfiCYEXAMPLEKEYX123wJalrXUtnFEMI-K7MDENwJalrXUtnFEMI-K7MDENabc"; // gitleaks:allow
1840        assert_eq!(token_86.len(), 86, "test token must be exactly 86 chars");
1841        let line = format!("auth header {token_86}");
1842        assert!(
1843            scan(&line).is_some(),
1844            "86-char base64url token near 'auth' must be caught; got: {:?}",
1845            scan(&line)
1846        );
1847    }
1848
1849    // ── True-positives: standalone `token` trigger ───────────────────────────
1850
1851    #[test]
1852    fn blocks_service_token_opaque_value() {
1853        // "service token <opaque-high-entropy>" — `token` as a standalone word
1854        // with a high-entropy value must be caught.
1855        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
1856        assert!(
1857            opaque.len() >= 24,
1858            "opaque must be long enough for entropy check"
1859        );
1860        let line = format!("service token {opaque}");
1861        assert!(
1862            scan(&line).is_some(),
1863            "service token <opaque> must be caught by standalone 'token' check; got: {:?}",
1864            scan(&line)
1865        );
1866    }
1867
1868    #[test]
1869    fn blocks_token_equals_credential() {
1870        // `token=<high-entropy>` (assignment form) must be caught via has_token_assignment.
1871        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
1872        let line = format!("token={opaque}");
1873        assert!(
1874            scan(&line).is_some(),
1875            "token=<value> must be caught via token= trigger; got: {:?}",
1876            scan(&line)
1877        );
1878    }
1879
1880    #[test]
1881    fn blocks_token_colon_credential() {
1882        // `token: <high-entropy>` (key-value form) must be caught via has_token_assignment.
1883        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
1884        let line = format!("token: {opaque}");
1885        assert!(
1886            scan(&line).is_some(),
1887            "token: <value> must be caught via token: trigger; got: {:?}",
1888            scan(&line)
1889        );
1890    }
1891
1892    #[test]
1893    fn allows_next_token_technical_context() {
1894        // `next_token` is a technical term; the high-entropy value here has low
1895        // entropy anyway, so it must pass.
1896        let line = "next_token: cursor-page-2-abcdef12345678";
1897        assert!(
1898            scan(line).is_none(),
1899            "next_token technical context must not be blocked; fired: {:?}",
1900            scan(line)
1901        );
1902    }
1903
1904    // ── Boundary-aware token= / token: (compound identifiers must pass) ─────
1905
1906    #[test]
1907    fn allows_next_token_high_entropy_cursor() {
1908        // `next_token:` with a realistic high-entropy pagination cursor must NOT be
1909        // blocked.  `next_token` has `_token` suffix — not a standalone assignment form.
1910        let cursor = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
1911        let line = format!("next_token: {cursor}");
1912        assert!(
1913            scan(&line).is_none(),
1914            "next_token with high-entropy cursor must pass (compound identifier); fired: {:?}",
1915            scan(&line)
1916        );
1917    }
1918
1919    #[test]
1920    fn allows_token_count_high_entropy() {
1921        // `token_count:` with a high-entropy value must NOT be blocked.
1922        // `token_count` has `token_` prefix — the word boundary after `token` is `_`,
1923        // which is excluded by has_token_assignment.
1924        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
1925        let line = format!("token_count: {opaque}");
1926        assert!(
1927            scan(&line).is_none(),
1928            "token_count with high-entropy value must pass; fired: {:?}",
1929            scan(&line)
1930        );
1931    }
1932
1933    // ── Hex allowlist is not applied when trigger context is present ────────
1934    //
1935    // Pure hex strings have a theoretical maximum entropy of log2(16) = 4.0 bits/char,
1936    // which is below the ENTROPY_THRESHOLD of 4.5.  That means pure hex tokens cannot
1937    // reach the entropy threshold and will never be flagged by the heuristic alone.
1938    //
1939    // However, the hex allowlist was previously applied BEFORE the trigger window was
1940    // computed, meaning a future threshold reduction or edge case could silently
1941    // skip credential-context hex.  The fix: compute trigger context first; only
1942    // apply the hex allowlist when NOT near a trigger.  The tests below verify the
1943    // structural change is in place by confirming that non-pure-hex high-entropy
1944    // tokens near triggers are caught (showing the trigger path is live), and that
1945    // purely hex tokens near triggers still correctly pass (entropy too low to flag).
1946
1947    #[test]
1948    fn hex_near_key_blocked_in_credential_context() {
1949        // A pure-hex 32-char token near "api key" is a credential-shaped hex
1950        // token in trigger context.  Entropy alone cannot flag it (hex max =
1951        // 4.0 < 4.5 threshold), but the explicit hex-credential-token path
1952        // must catch it.
1953        let hex32 = "4f9c2e8a1d3b5c7e9f0a2b4d6e8c0a2b";
1954        assert_eq!(hex32.len(), 32);
1955        let line = format!("api key {hex32}");
1956        assert!(
1957            scan(&line).is_some(),
1958            "32-char pure hex near 'api key' must be blocked; got None"
1959        );
1960    }
1961
1962    #[test]
1963    fn hex_credential_lengths_blocked_near_trigger() {
1964        // Verify all four credential-shaped lengths are caught near a trigger.
1965        let hex40 = "a3f5c2e9d1b8047e63a1f4c2d5b6e8f1a9c3d2e4";
1966        let hex64 = "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b";
1967        let hex128 = format!("{hex64}{hex64}");
1968        assert_eq!(hex40.len(), 40);
1969        assert_eq!(hex64.len(), 64);
1970        assert_eq!(hex128.len(), 128);
1971
1972        for (label, hex) in &[
1973            ("hex40", hex40),
1974            ("hex64", hex64),
1975            ("hex128", hex128.as_str()),
1976        ] {
1977            let line = format!("secret key: {hex}");
1978            assert!(
1979                scan(&line).is_some(),
1980                "{label} near 'secret key' must be blocked; got None"
1981            );
1982        }
1983    }
1984
1985    #[test]
1986    fn hex_blocked_when_trigger_and_hash_word_coexist() {
1987        // Credential trigger dominates: adding "hash" or "sha" to the window does
1988        // not rescue a pure-hex token when a credential trigger is also present.
1989        // An attacker controlling the prose could otherwise bypass the gate with
1990        // one extra word, so the hash-word exception must NOT apply in trigger context.
1991        let hex32 = "4f9c2e8a1d3b5c7e9f0a2b4d6e8c0a2b";
1992        let key_hash_line = format!("api key hash {hex32}");
1993        let secret_sha_line = format!("secret sha {hex32}");
1994        assert!(
1995            scan(&key_hash_line).is_some(),
1996            "'api key hash <hex32>' must be blocked; got None"
1997        );
1998        assert!(
1999            scan(&secret_sha_line).is_some(),
2000            "'secret sha <hex32>' must be blocked; got None"
2001        );
2002    }
2003
2004    #[test]
2005    fn hex_near_sha_context_word_allowed() {
2006        // A 40-char hex with "sha" or "commit" in the window — but no credential
2007        // trigger — must be allowed (git SHA or content hash in normal prose).
2008        let hex40 = "da39a3ee5e6b4b0d3255bfef95601890afd80709";
2009        let sha_line = format!("sha1: {hex40}");
2010        let commit_line = format!("commit sha {hex40}");
2011        assert!(
2012            scan(&sha_line).is_none(),
2013            "hex40 near 'sha1' context must be allowed; fired: {:?}",
2014            scan(&sha_line)
2015        );
2016        assert!(
2017            scan(&commit_line).is_none(),
2018            "hex40 near 'commit sha' context must be allowed; fired: {:?}",
2019            scan(&commit_line)
2020        );
2021    }
2022
2023    #[test]
2024    fn hex64_near_hash_context_allowed() {
2025        // A 64-char hex near "sha256" or "hash" — with no credential trigger —
2026        // must be allowed (content digest in normal prose).
2027        let hex64 = "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b";
2028        let sha_line = format!("sha256: {hex64}");
2029        let hash_line = format!("hash value {hex64}");
2030        assert!(
2031            scan(&sha_line).is_none(),
2032            "hex64 near 'sha256' must be allowed; fired: {:?}",
2033            scan(&sha_line)
2034        );
2035        assert!(
2036            scan(&hash_line).is_none(),
2037            "hex64 near 'hash' must be allowed; fired: {:?}",
2038            scan(&hash_line)
2039        );
2040    }
2041
2042    #[test]
2043    fn blocks_high_entropy_hex_like_token_near_key() {
2044        // A token whose character set exceeds pure hex (contains mixed-case, digits,
2045        // and non-hex chars) that ALSO passes `is_pure_hex = false` AND has high
2046        // entropy AND appears near "key" MUST be caught.  This is the realistic
2047        // real-world case: hex-looking API tokens often mix case and non-hex chars.
2048        // Example: a 32-char mixed-charset token near "api key".
2049        let mixed = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM"; // gitleaks:allow — not pure hex
2050        assert!(!is_pure_hex(mixed), "test token must not be pure hex");
2051        let line = format!("api key {mixed}");
2052        assert!(
2053            scan(&line).is_some(),
2054            "mixed-charset high-entropy token near 'api key' must be caught; got: {:?}",
2055            scan(&line)
2056        );
2057    }
2058
2059    #[test]
2060    fn allows_hex40_without_trigger() {
2061        // 40-char hex string in a neutral context (no trigger word) must still pass —
2062        // it's likely a git commit SHA or content hash.
2063        let hex40 = "da39a3ee5e6b4b0d3255bfef95601890afd80709";
2064        let line = format!("commit: {hex40}");
2065        assert!(
2066            scan(&line).is_none(),
2067            "40-char hex without trigger word must pass; fired: {:?}",
2068            scan(&line)
2069        );
2070    }
2071
2072    // ── check_json scans object keys ─────────────────────────────────────────
2073
2074    #[test]
2075    fn check_json_blocks_secret_in_object_key() {
2076        // A credential used as a JSON object key (not a value) must be caught.
2077        let props = serde_json::json!({ "ghp_FakeGitHubToken0000000000000000000": "redacted" }); // gitleaks:allow
2078        assert!(
2079            check_json(&props).is_err(),
2080            "credential as JSON object key must be blocked"
2081        );
2082    }
2083
2084    #[test]
2085    fn check_json_blocks_nested_secret_key() {
2086        // Nested credential key must be caught.
2087        let props = serde_json::json!({
2088            "metadata": {
2089                "AKIAFAKEKEY000000000": "value" // gitleaks:allow
2090            }
2091        });
2092        assert!(
2093            check_json(&props).is_err(),
2094            "nested credential as JSON object key must be blocked"
2095        );
2096    }
2097
2098    // ── PEM masking format ───────────────────────────────────────────────────
2099
2100    #[test]
2101    fn pem_masked_excerpt_reflects_block_length_not_rest_of_string() {
2102        let header = ["-----BEGIN RSA", " PRIVATE KEY-----"].concat(); // gitleaks:allow
2103        let fake = format!(
2104            "{}\nMIIEo\u{2026}\n-----END RSA PRIVATE KEY-----\nsome trailing text that is very long",
2105            header
2106        );
2107        let m = scan(&fake).unwrap();
2108        assert_eq!(m.detector, "pem-private-key");
2109        // The masked length should reflect only the key block, not the whole string.
2110        // "some trailing text that is very long" is ~37 chars; total string is much longer.
2111        // The block ends after "-----END RSA PRIVATE KEY-----\n".
2112        // We just verify it is shorter than the full string length.
2113        let full_len = fake.chars().count();
2114        let reported_len: usize = m
2115            .masked
2116            .trim_end_matches("chars")
2117            .rsplit("...")
2118            .next()
2119            .and_then(|s| s.parse().ok())
2120            .unwrap_or(full_len + 1);
2121        assert!(
2122            reported_len < full_len,
2123            "masked length ({reported_len}) should be less than full string length ({full_len})"
2124        );
2125    }
2126
2127    // ── UTF-8 char-boundary reproduction tests ───────────────────────────────
2128    //
2129    // These tests verify that no code path in secret_gate panics when multibyte
2130    // UTF-8 characters (emoji, CJK, accented Latin) appear at positions where
2131    // byte-level slicing could land mid-codepoint.  Each test targets a specific
2132    // code path.  A panic means the bug is live; a pass means the path is safe.
2133
2134    /// `build_match` masked preview: if the detected candidate starts with
2135    /// multibyte chars the "first 6 chars" preview must not slice on a byte
2136    /// boundary that falls mid-codepoint.  build_match already uses
2137    /// `chars().take(6)`, but we exercise it with emoji-prefixed candidates.
2138    #[test]
2139    fn utf8_build_match_preview_multibyte_prefix_no_panic() {
2140        // "🔑" = 4 bytes; repeat 3 times = 12 bytes for only 3 chars.
2141        // A ghp_-prefixed token with an emoji: let's construct a scenario where
2142        // a known-prefix secret is immediately adjacent to multibyte content so
2143        // that build_match receives a slice starting at a multibyte char.
2144        // PEM block with multibyte chars in the body exercises build_match on a
2145        // candidate that may contain non-ASCII.
2146        let header = ["-----BEGIN RSA", " PRIVATE KEY-----"].concat(); // gitleaks:allow
2147        let fake = format!("{}\n🔑密钥\n-----END RSA PRIVATE KEY-----", header);
2148        // Must not panic; mask must not echo full body.
2149        let m = scan(&fake);
2150        assert!(m.is_some(), "PEM with emoji body must still be caught");
2151        let m = m.unwrap();
2152        assert!(
2153            !m.masked.contains("🔑密钥"),
2154            "mask must not echo the emoji body"
2155        );
2156    }
2157
2158    /// `extract_token` called with a string starting with multibyte chars:
2159    /// the FlyV1 handler calls `extract_token(&text[payload_start..])` where
2160    /// `payload_start` is just past "FlyV1 " (ASCII).  If the payload is ASCII
2161    /// this is trivially safe, but we verify it cannot panic when the rest of
2162    /// the text after the payload contains multibyte chars.
2163    #[test]
2164    fn utf8_extract_token_multibyte_suffix_no_panic() {
2165        // "FlyV1 ABCDEFGHIJ密钥" — the payload is "ABCDEFGHIJ密钥"; extract_token
2166        // must stop at the ideographic chars (which are NOT ASCII whitespace) and
2167        // return the whole glued run without panicking.
2168        let text = "FlyV1 ABCDEFGHIJ密钥";
2169        // scan() must not panic.
2170        let _ = scan(text);
2171    }
2172
2173    /// `find_prefix_token` with multibyte chars immediately before and after
2174    /// the known prefix: checks text[..abs] boundary slices and
2175    /// extract_token(&text[abs..]) do not panic.
2176    #[test]
2177    fn utf8_prefix_detector_multibyte_adjacent_no_panic() {
2178        // 🔑 (4 bytes) immediately before AKIA: boundary at abs = 4, which is a
2179        // valid char boundary (end of the emoji).  extract_token sees ASCII from abs.
2180        let text = "🔑AKIAFAKEKEY00000000000000";
2181        let _ = scan(text); // must not panic
2182
2183        // é (U+00E9 = 2 bytes) immediately before ghp_:
2184        let text2 = "éghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
2185        let _ = scan(text2); // must not panic
2186
2187        // Emoji immediately after the token — extract_token ends at the emoji
2188        // (non-whitespace, but non-ASCII acts as delimiter in entropy heuristic).
2189        // For prefix tokens extract_token stops at ASCII whitespace only, so the
2190        // emoji would be included in the token length measurement.
2191        let text3 = "AKIAFAKEKEY00000000000000🔑";
2192        let _ = scan(text3); // must not panic
2193    }
2194
2195    /// `find_jwt` with multibyte chars as "whitespace" adjacent to a JWT-like
2196    /// candidate: `i = end + 1` could skip into a multibyte char if `end`
2197    /// pointed at a non-ASCII byte.  The position() search only looks for ASCII
2198    /// whitespace bytes, so a multibyte space (U+3000) is NOT found — `end`
2199    /// equals bytes.len() and `i = bytes.len() + 1` exits the loop.  Still
2200    /// verify no panic on CJK-surrounded JWT-like content.
2201    #[test]
2202    fn utf8_jwt_multibyte_adjacent_no_panic() {
2203        // A (fake) JWT-like triple surrounded by CJK text.
2204        let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.FAKE_SIG_XXXXXXXXXXXX"; // gitleaks:allow
2205        let text = format!("数据{jwt}密钥");
2206        let _ = scan(&text); // must not panic
2207
2208        // JWT followed by ideographic space (U+3000 = 3 bytes 0xE3 0x80 0x80) —
2209        // not matched by the ASCII-whitespace position() search.
2210        let text2 = format!("{jwt}\u{3000}morecontent");
2211        let _ = scan(&text2); // must not panic
2212
2213        // JWT followed by emoji
2214        let text3 = format!("{jwt}🔑");
2215        let _ = scan(&text3); // must not panic
2216    }
2217
2218    /// `find_url_userinfo` with multibyte chars between "://" and "@":
2219    /// `at_pos` from `rest.find('@')` and `colon` from `userinfo.find(':')` are
2220    /// ASCII markers (char boundaries), but `scheme_start` calculation uses
2221    /// char_indices().rev() which must handle multibyte chars in the scheme
2222    /// prefix correctly.
2223    #[test]
2224    fn utf8_url_userinfo_multibyte_scheme_no_panic() {
2225        // CJK glued to a credential URL — the scheme_start walker must not place
2226        // the start inside a multibyte codepoint.
2227        let cases = [
2228            "🔑postgresql://dbuser:S3cr3tP4ss@db.example.com/db", // gitleaks:allow
2229            "密钥mysql://root:hunter2pw@10.0.0.1:3306/app",       // gitleaks:allow
2230            "éredis://svc:V3ryS3cretPw@cache.internal:6379",      // gitleaks:allow
2231        ];
2232        for text in &cases {
2233            // Must not panic and must detect the credential.
2234            let result = scan(text);
2235            assert!(
2236                result.is_some(),
2237                "URL credential after multibyte must be caught: {text:?}"
2238            );
2239        }
2240    }
2241
2242    /// `check_entropy_heuristic` window slicing with multibyte content at the
2243    /// ±TRIGGER_WINDOW boundary: `floor_char_boundary` must prevent slicing
2244    /// on a non-char boundary.
2245    #[test]
2246    fn utf8_entropy_window_multibyte_boundary_no_panic() {
2247        // Construct content where the TRIGGER_WINDOW (120 bytes) boundary falls
2248        // inside a 3-byte CJK character.  Repeat "数" (U+6570 = 3 bytes) to fill
2249        // exactly 119 bytes, then add an ASCII trigger word + high-entropy token.
2250        // Window start: token_offset - 120 = lands inside one of the CJK chars.
2251        let cjk_fill = "数".repeat(39); // 39 × 3 = 117 bytes
2252        assert_eq!(cjk_fill.len(), 117);
2253        // Pad with 2 more ASCII chars ("xy") so that the 120-byte window lands at
2254        // byte 119 which is the second byte of the 40th "数" — mid-multibyte.
2255        let secret = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM1"; // gitleaks:allow
2256        let content = format!("{cjk_fill}xy key {secret}");
2257        let _ = scan(&content); // must not panic
2258
2259        // Also test the right edge: token ends at byte offset, window_end =
2260        // token_offset + raw_token.len() + 120 may land mid-multibyte.
2261        let content2 = format!("key {secret}{cjk_fill}xy");
2262        let _ = scan(&content2); // must not panic
2263    }
2264
2265    /// `check()` top-level fuzz: a large batch of inputs with multibyte
2266    /// characters at various offsets to catch any remaining panic sites.
2267    /// All results must be either Ok or Err (not a panic).
2268    #[test]
2269    fn utf8_no_panic_property_test() {
2270        let multibyte_items = [
2271            "🔑",       // 4-byte emoji
2272            "密",       // 3-byte CJK
2273            "é",        // 2-byte accented Latin
2274            "\u{3000}", // 3-byte ideographic space
2275            "🇺🇸",       // 8-byte emoji flag (two surrogate-like scalars)
2276        ];
2277        let secrets = [
2278            "AKIAFAKEKEY00000000000000",
2279            "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
2280            "sk-ant-api03-AAAAAAAAAAAAAAA",
2281            "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM1",
2282            "FlyV1 fm2_AAAABBBBCCCCDDDDEEEEFFFF",
2283        ];
2284        for mb in &multibyte_items {
2285            for secret in &secrets {
2286                for sep in &["", " ", "\n"] {
2287                    // multibyte before secret
2288                    let s = format!("{mb}{sep}{secret}");
2289                    let _ = check(&s);
2290                    // multibyte after secret
2291                    let s = format!("{secret}{sep}{mb}");
2292                    let _ = check(&s);
2293                    // multibyte both sides
2294                    let s = format!("{mb}{sep}{secret}{sep}{mb}");
2295                    let _ = check(&s);
2296                    // repeated multibyte filling TRIGGER_WINDOW boundary
2297                    let fill = mb.repeat(50);
2298                    let s = format!("{fill} api_key {secret} {fill}");
2299                    let _ = check(&s);
2300                }
2301            }
2302        }
2303    }
2304
2305    // ── mask_secrets: in-place redaction reusing the canonical detector ───────
2306
2307    #[test]
2308    fn mask_secrets_borrows_clean_text() {
2309        let clean = "The FlashAttention paper introduces IO-aware tiling.";
2310        let masked = mask_secrets(clean);
2311        assert!(
2312            matches!(masked, std::borrow::Cow::Borrowed(_)),
2313            "clean text must not allocate"
2314        );
2315        assert_eq!(masked, clean);
2316    }
2317
2318    #[test]
2319    fn mask_secrets_redacts_shapes_the_old_mirror_regex_missed() {
2320        // These are exactly the detectors the session mirror's previous local
2321        // regex did NOT cover, which is why it now shares this masker.
2322        let cases = [
2323            "key: sk-proj-FAKEKEY00000000000000000000000000000000", // gitleaks:allow
2324            "cred ASIAFAKEKEY00000000000",                          // gitleaks:allow
2325            "stripe sk_live_FAKESTRIPE0000000000000",               // gitleaks:allow
2326            "db postgresql://dbuser:S3cr3tP4ss@db.example.com/db",  // gitleaks:allow
2327        ];
2328        for c in &cases {
2329            let masked = mask_secrets(c);
2330            assert!(
2331                masked.contains(REDACTION_MARKER),
2332                "must redact: {c:?} -> {masked:?}"
2333            );
2334        }
2335    }
2336
2337    #[test]
2338    fn mask_secrets_redacts_every_span_and_keeps_prose() {
2339        let line =
2340            "first sk-ant-api03-AAAAAAAAAAAAAAA then ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA end";
2341        let masked = mask_secrets(line);
2342        assert!(
2343            !masked.contains("sk-ant-api03") && !masked.contains("ghp_AAAA"),
2344            "no secret may survive: {masked}"
2345        );
2346        assert_eq!(
2347            masked.matches(REDACTION_MARKER).count(),
2348            2,
2349            "both secrets must be redacted: {masked}"
2350        );
2351        assert!(masked.starts_with("first "), "prose preserved: {masked}");
2352        assert!(masked.ends_with(" end"), "prose preserved: {masked}");
2353    }
2354
2355    #[test]
2356    fn mask_secrets_output_passes_check() {
2357        // The masked output must itself be clean — no credential left for the
2358        // write-time gate to catch.
2359        let line = "token=ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA and AKIAFAKEKEY1234567890";
2360        let masked = mask_secrets(line).into_owned();
2361        assert!(
2362            check(&masked).is_ok(),
2363            "masked output must pass the gate: {masked}"
2364        );
2365    }
2366
2367    #[test]
2368    fn mask_secrets_redacts_entropy_secret_left_of_known_secret() {
2369        // Cross-layer leftmost regression: a Layer-2 entropy secret sits to the
2370        // LEFT of a Layer-1 known-prefix secret. A scan that short-circuits on
2371        // the first known match (or returns first-by-detector-priority) would
2372        // redact `ghp_…` and copy the entropy token before it verbatim — leaking
2373        // it. `scan_match` must fold both layers through leftmost selection.
2374        let line =
2375            "secret=Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM and ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; // gitleaks:allow
2376        let masked = mask_secrets(line).into_owned();
2377        assert!(
2378            !masked.contains("Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM") && !masked.contains("ghp_AAAA"),
2379            "neither the entropy secret nor the known secret may survive: {masked}"
2380        );
2381        assert_eq!(
2382            masked.matches(REDACTION_MARKER).count(),
2383            2,
2384            "both secrets must be redacted exactly once: {masked}"
2385        );
2386        assert!(
2387            check(&masked).is_ok(),
2388            "masked output must pass the gate: {masked}"
2389        );
2390    }
2391
2392    #[test]
2393    fn github_app_token_families_are_masked() {
2394        // ghu_ (user-to-server), ghs_ (server-to-server), and ghr_ (refresh)
2395        // GitHub App tokens are real credential families. They are
2396        // context-free: no trigger word needed.
2397        let cases = [
2398            "ghu_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", // gitleaks:allow
2399            "ghs_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",  // gitleaks:allow
2400            "ghr_CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC",  // gitleaks:allow
2401        ];
2402        for token in &cases {
2403            assert!(
2404                check(token).is_err(),
2405                "gate must hard-block GitHub App token {token}"
2406            );
2407            let line = format!("auth: {token} trailing");
2408            let masked = mask_secrets(&line).into_owned();
2409            assert!(
2410                !masked.contains(token),
2411                "GitHub App token must not survive masking: {masked}"
2412            );
2413            assert!(
2414                check(&masked).is_ok(),
2415                "masked output must pass the gate: {masked}"
2416            );
2417        }
2418    }
2419
2420    #[test]
2421    fn mask_secrets_redacts_entropy_token_whose_trigger_is_left_of_earlier_secret() {
2422        // The entropy detector only fires near a
2423        // trigger word. When the trigger (`api_key`) sits to the LEFT of an
2424        // earlier known-prefix secret (`ghp_…`), a masker that rescans only the
2425        // suffix after each redaction loses that context and leaks the later
2426        // high-entropy token. Spans must be discovered against the ORIGINAL text.
2427        let line =
2428            "api_key ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM1"; // gitleaks:allow
2429        let masked = mask_secrets(line).into_owned();
2430        assert!(
2431            !masked.contains("ghp_AAAA"),
2432            "the known secret must be redacted: {masked}"
2433        );
2434        assert!(
2435            !masked.contains("Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM1"),
2436            "the later entropy token must be redacted even though its trigger \
2437             word sits left of the earlier redaction: {masked}"
2438        );
2439        assert_eq!(
2440            masked.matches(REDACTION_MARKER).count(),
2441            2,
2442            "both secrets must be redacted exactly once: {masked}"
2443        );
2444        assert!(
2445            check(&masked).is_ok(),
2446            "masked output must pass the gate: {masked}"
2447        );
2448    }
2449
2450    // ── Structured-identifier exemption: file paths / branch names ──────────
2451    //
2452    // The entropy heuristic tokenizes on whitespace, so a full file path is
2453    // one long token, and mixed-case+digit+punctuation paths can legitimately
2454    // exceed the Shannon-entropy threshold. The structured-identifier
2455    // exemption does not apply in trigger context (see the module doc): no
2456    // sound signal separates a real path from an attacker-chopped/padded
2457    // credential once Shannon entropy is the only measure and the attacker
2458    // controls run boundaries. The three cases below are accepted false
2459    // positives: their own full-token entropy exceeds ENTROPY_THRESHOLD, so
2460    // they block near a trigger word.
2461
2462    #[test]
2463    fn blocks_high_entropy_file_path_near_secret_word() {
2464        // Full-token entropy 4.5994 > ENTROPY_THRESHOLD (4.5).
2465        let content =
2466            "workspace path fable-ops/ADR-DRAFT-adr079-slices234.md for the secret gate bug";
2467        assert!(
2468            check(content).is_err(),
2469            "accepted FP: structured file path near 'secret' is now \
2470             blocked; got {:?}",
2471            scan(content)
2472        );
2473    }
2474
2475    #[test]
2476    fn blocks_high_entropy_workspace_path_near_key_word() {
2477        // Full-token entropy 4.7938 > 4.5.
2478        let content = "key: see internal/workspaces/20260701/adr079-slices234/PACKET.md";
2479        assert!(
2480            check(content).is_err(),
2481            "accepted FP: workspace path near 'key' is now blocked; \
2482             got {:?}",
2483            scan(content)
2484        );
2485    }
2486
2487    #[test]
2488    fn blocks_high_entropy_short_run_path_near_auth_word() {
2489        // Full-token entropy 4.5955 > 4.5.
2490        let content =
2491            "auth work saved at internal/workspaces/20260701/cloud-rebuild/R1-repo-audit.md";
2492        assert!(
2493            check(content).is_err(),
2494            "accepted FP: path with a short 'R1' run near 'auth' is \
2495             now blocked; got {:?}",
2496            scan(content)
2497        );
2498    }
2499
2500    #[test]
2501    fn allows_branch_and_review_filename_near_key_word() {
2502        let content =
2503            "branch feat-session-mirror pushed, see review_pr335_round2.md for the key findings";
2504        assert!(
2505            check(content).is_ok(),
2506            "branch name and review filename near 'key' must not be blocked; fired: {:?}",
2507            scan(content)
2508        );
2509    }
2510
2511    #[test]
2512    fn allows_adr_doc_path_near_password_word() {
2513        let content = "password reset doc: docs/adr/ADR-055-epistemic-edge-relations.md";
2514        assert!(
2515            check(content).is_ok(),
2516            "ADR doc path near 'password' must not be blocked; fired: {:?}",
2517            scan(content)
2518        );
2519    }
2520
2521    #[test]
2522    fn allows_source_file_path_near_credential_word() {
2523        let content = "credential handling code crates/khive-pack-session/src/mirror/ingest.rs";
2524        assert!(
2525            check(content).is_ok(),
2526            "source file path near 'credential' must not be blocked; fired: {:?}",
2527            scan(content)
2528        );
2529    }
2530
2531    #[test]
2532    fn allows_long_snake_case_identifier_near_key_word() {
2533        let content = "api key handling lives in check_entropy_heuristic_impl";
2534        assert!(
2535            check(content).is_ok(),
2536            "snake_case identifier near 'key' must not be blocked; fired: {:?}",
2537            scan(content)
2538        );
2539    }
2540
2541    // ── Structured-identifier exemption: catch-suite regression ─────────────
2542
2543    #[test]
2544    fn hyphenated_random_secret_is_not_a_structured_identifier() {
2545        // Same token as `blocks_bare_base64url_43chars_near_key`: hyphenated
2546        // but not word-shaped. The second run exceeds the 24-char run cap,
2547        // and the first run's case-transition density (~0.42) exceeds the
2548        // 0.3 threshold on its own, so this must not be exempted and the
2549        // existing catch-suite test must keep blocking it.
2550        assert!(!is_structured_identifier(
2551            "wJalrXUtnFEMI-K7MDENGbPxRfiCYEXAMPLEKEYX123"
2552        ));
2553        let line = "api key wJalrXUtnFEMI-K7MDENGbPxRfiCYEXAMPLEKEYX123";
2554        assert!(
2555            scan(line).is_some(),
2556            "hyphenated random secret must still be blocked; got: {:?}",
2557            scan(line)
2558        );
2559    }
2560
2561    // ── Structured-identifier exemption: direct unit tests ───────────────────
2562
2563    #[test]
2564    fn structured_identifier_true_for_repro_paths() {
2565        let paths = [
2566            "fable-ops/ADR-DRAFT-adr079-slices234.md",
2567            "internal/workspaces/20260701/adr079-slices234/PACKET.md",
2568            "internal/workspaces/20260701/cloud-rebuild/R1-repo-audit.md",
2569            "review_pr335_round2.md",
2570            "docs/adr/ADR-055-epistemic-edge-relations.md",
2571            "crates/khive-pack-session/src/mirror/ingest.rs",
2572            "check_entropy_heuristic_impl",
2573        ];
2574        for p in paths {
2575            assert!(
2576                is_structured_identifier(p),
2577                "expected structured identifier: {p}"
2578            );
2579        }
2580    }
2581
2582    #[test]
2583    fn structured_identifier_false_without_separator() {
2584        // No `/`, `-`, `_`, or `.` present — fails rule 1 outright.
2585        assert!(!is_structured_identifier(
2586            "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM"
2587        ));
2588    }
2589
2590    #[test]
2591    fn structured_identifier_false_for_leetspeak_digit_interleaving() {
2592        // Digits interleaved with letters within a run (not a trailing digit
2593        // suffix) fail the `[A-Za-z]+[0-9]*` / `[0-9]+` shape check.
2594        assert!(!is_structured_identifier("S3cr3t-P4ssw0rd-t0ken-here!"));
2595    }
2596
2597    #[test]
2598    fn structured_identifier_false_for_run_over_length_cap() {
2599        // A 26-char single alphabetic run between separators fails the
2600        // 24-char per-run length cap even though it is otherwise trivially
2601        // word-shaped (uniform lowercase, zero case transitions).
2602        let long_run = "a".repeat(26);
2603        let token = format!("prefix-{long_run}-suffix");
2604        assert!(!is_structured_identifier(&token));
2605    }
2606
2607    // ── Structured-identifier exemption drops entirely in trigger context ───
2608    //
2609    // Narrower fixes that keep some exemption alive in trigger context (e.g.
2610    // requiring a trailing file-extension run, or requiring >= 2 path-shaped
2611    // runs with a low average per-run letters-only entropy) are all
2612    // attacker-defeatable: a random credential can be extension-suffixed, or
2613    // split/padded into short runs that drive each run's own entropy toward
2614    // its length ceiling (log2(run_len)), which real short path words already
2615    // sit at. Shannon entropy over an attacker-chosen run boundary cannot
2616    // distinguish "distinct letters that spell an English word" from
2617    // "distinct letters chosen adversarially" — both hit the same
2618    // log2(length) ceiling, so no aggregation is sound. The exemption is
2619    // therefore dropped unconditionally in trigger context: a
2620    // structured-identifier-shaped token near a trigger word is
2621    // entropy-checked like any other token, with 3 known false positives
2622    // accepted (see `accepted_false_positive_*` below).
2623
2624    #[test]
2625    fn blocks_separator_secret_access_key_bypass() {
2626        // Adversarial bypass shape: an AWS-secret-key-like value split into
2627        // separator-delimited word-shaped runs to dodge the entropy check.
2628        let content = "secret_access_key abcdefghij/klmnopqrst/uvwxyzabcd/efghijk";
2629        assert!(
2630            check(content).is_err(),
2631            "AWS Secret Access Key shaped bypass must be blocked: {:?}",
2632            scan(content)
2633        );
2634    }
2635
2636    #[test]
2637    fn blocks_adversarial_lowercase_only_separator_token_near_access_key() {
2638        let content = "access_key qrstuvwxyz/abcdefghij/klmnopqrst/uvwxyzab";
2639        assert!(
2640            check(content).is_err(),
2641            "lowercase-only separator-delimited high-entropy token near \
2642             'access_key' must be blocked: {:?}",
2643            scan(content)
2644        );
2645    }
2646
2647    #[test]
2648    fn blocks_adversarial_digit_and_word_mixed_token_near_api_key() {
2649        // A mix of pure-digit runs and letters-then-digits runs (both
2650        // individually word-shaped) whose combined alphabet diversity crosses
2651        // the entropy threshold.
2652        let content = "api_key attaycofrsm827/festwqjhc493/8261947350/qwikjzx982";
2653        assert!(
2654            check(content).is_err(),
2655            "digit-and-word-mixed high-entropy token near 'api_key' must be blocked: {:?}",
2656            scan(content)
2657        );
2658    }
2659
2660    #[test]
2661    fn blocks_adversarial_token_assignment_separator_delimited_secret() {
2662        let content = "token=zxkqwmvbpl/trfhysjgnc/dweiaoutkz-mnbvcxzlk";
2663        assert!(
2664            check(content).is_err(),
2665            "token= with lowercase-only separator-delimited high-entropy value \
2666             must be blocked: {:?}",
2667            scan(content)
2668        );
2669    }
2670
2671    #[test]
2672    fn blocks_extension_suffix_bypass_secret_access_key() {
2673        // A file-extension check alone would exempt this: appending `.md`
2674        // to a random credential must not bypass detection.
2675        let content = "secret_access_key abcdefghij/klmnopqrst/uvwxyzabcd/efghijk.md";
2676        assert!(
2677            check(content).is_err(),
2678            "extension-suffixed AWS Secret Access Key shaped bypass must be blocked: {:?}",
2679            scan(content)
2680        );
2681    }
2682
2683    #[test]
2684    fn blocks_extension_suffix_bypass_token_assignment() {
2685        let content = "token=zxkqwmvbpl/trfhysjgnc/dweiaoutkz-mnbvcxzlk.rs";
2686        assert!(
2687            check(content).is_err(),
2688            "extension-suffixed token= bypass must be blocked: {:?}",
2689            scan(content)
2690        );
2691    }
2692
2693    #[test]
2694    fn blocks_unsuffixed_separator_split_credential_bypasses() {
2695        let cases = [
2696            "secret_access_key abcdefghij/klmnopqrst/uvwxyzabcd/efghijk",
2697            "token=zxkqwmvbpl/trfhysjgnc/dweiaoutkz-mnbvcxzlk",
2698        ];
2699        for content in cases {
2700            assert!(
2701                check(content).is_err(),
2702                "bypass string must still be blocked: {content:?}, got: {:?}",
2703                scan(content)
2704            );
2705        }
2706    }
2707
2708    #[test]
2709    fn blocks_digit_run_suffix_bypass_attempt() {
2710        let cases = [
2711            "secret_access_key abcdefghij/klmnopqrst/uvwxyzabcd/efghijk2024",
2712            "secret_access_key abcdefghij2024/klmnopqrst/uvwxyzabcd/efghijk.md",
2713        ];
2714        for content in cases {
2715            assert!(
2716                check(content).is_err(),
2717                "digit-run-suffixed bypass attempt must be blocked: {content:?}, got: {:?}",
2718                scan(content)
2719            );
2720        }
2721    }
2722
2723    #[test]
2724    fn blocks_low_entropy_padding_run_bypass_attempts() {
2725        // A low-entropy padding run (`aaaa`) inserted before short/digit-shaped
2726        // runs would drag any AVERAGE per-run entropy signal below its
2727        // threshold. With the exemption dropped entirely, these must be
2728        // blocked purely on full-token entropy, same as any other
2729        // near-trigger high-entropy token.
2730        let cases = [
2731            "secret_access_key abcdefghij/klmnopqrst/uvwxyzabcd/efghijk/aaaa/R1.md",
2732            "token=zxkqwmvbpl/trfhysjgnc/dweiaoutkz/mnbvcxzlk/aaaa/R1.rs",
2733            "secret_access_key abcdefghij/klmnopqrst/uvwxyzabcd/efghijk/aaaa/bbbb/R1.md",
2734            "token=zxkqwmvbpl/trfhysjgnc/dweiaoutkz/mnbvcxzlk/aaaa/bbbb/R1.rs",
2735        ];
2736        for content in cases {
2737            assert!(
2738                check(content).is_err(),
2739                "padding-run bypass attempt must be blocked: {content:?}, got: {:?}",
2740                scan(content)
2741            );
2742        }
2743    }
2744
2745    #[test]
2746    fn blocks_run_splitting_bypass_attempts() {
2747        // Splitting a credential into short (4-6 char) runs drives EVERY
2748        // run's own letters-only entropy toward log2(run_len), which ordinary
2749        // short English path words already sit at or near: this is exactly
2750        // why any per-run entropy ceiling is unsound as an exemption signal.
2751        // With the exemption dropped, these are blocked on full-token entropy
2752        // regardless of run shape.
2753        let cases = [
2754            "secret_access_key abcd/efgh/ijkl/mnop/qrst/uvwx/yzab/cdef.md",
2755            "secret_access_key abcde/fghij/klmno/pqrst/uvwxy/zabcd.md",
2756            "secret_access_key abcdef/ghijkl/mnopqr/stuvwx/yzabcd.md",
2757        ];
2758        for content in cases {
2759            assert!(
2760                check(content).is_err(),
2761                "run-splitting bypass attempt must be blocked: {content:?}, got: {:?}",
2762                scan(content)
2763            );
2764        }
2765    }
2766
2767    #[test]
2768    fn allows_fp_paths_whose_full_token_entropy_is_already_below_threshold() {
2769        // 4 of the 7 original FP-repro paths stay OK near a trigger word even
2770        // with NO structured-identifier exemption at all, because their own
2771        // full-token Shannon entropy already reads below ENTROPY_THRESHOLD
2772        // (4.5) — the exemption was never load-bearing for these regardless
2773        // of which version of it existed.
2774        let paths = [
2775            "review_pr335_round2.md",
2776            "docs/adr/ADR-055-epistemic-edge-relations.md",
2777            "crates/khive-pack-session/src/mirror/ingest.rs",
2778            "check_entropy_heuristic_impl",
2779        ];
2780        for p in paths {
2781            let content = format!("api_key handling in {p}");
2782            assert!(
2783                check(&content).is_ok(),
2784                "{p} must stay allowed near 'api_key' (full-token entropy already \
2785                 below threshold): fired {:?}",
2786                scan(&content)
2787            );
2788        }
2789    }
2790
2791    #[test]
2792    fn accepted_false_positive_adr_draft_path_near_trigger() {
2793        // Accepted tradeoff: this path's full-token Shannon entropy (4.5994)
2794        // exceeds ENTROPY_THRESHOLD (4.5) on its own. With the
2795        // structured-identifier exemption dropped in trigger context, it is
2796        // blocked near an explicit credential trigger word: a deliberate,
2797        // documented false positive, not a regression to fix, since no sound
2798        // signal exists to distinguish this from a chopped/padded credential
2799        // of the same shape.
2800        let content = "api_key handling in fable-ops/ADR-DRAFT-adr079-slices234.md";
2801        assert!(
2802            check(content).is_err(),
2803            "accepted FP: ADR-DRAFT path near 'api_key' is now blocked; \
2804             got {:?}",
2805            scan(content)
2806        );
2807    }
2808
2809    #[test]
2810    fn accepted_false_positive_workspace_packet_path_near_trigger() {
2811        // Same tradeoff as above: full-token entropy 4.7938 > 4.5.
2812        let content = "api_key handling in internal/workspaces/20260701/adr079-slices234/PACKET.md";
2813        assert!(
2814            check(content).is_err(),
2815            "accepted FP: PACKET.md workspace path near 'api_key' is now blocked \
2816             got {:?}",
2817            scan(content)
2818        );
2819    }
2820
2821    #[test]
2822    fn blocks_high_entropy_repo_audit_path_near_api_key() {
2823        // Same tradeoff as above: full-token entropy 4.5955 > 4.5.
2824        let content =
2825            "api_key handling in internal/workspaces/20260701/cloud-rebuild/R1-repo-audit.md";
2826        assert!(
2827            check(content).is_err(),
2828            "accepted FP: R1-repo-audit path near 'api_key' is now blocked; \
2829             got {:?}",
2830            scan(content)
2831        );
2832    }
2833
2834    // ── UUID / content-hash allowlists are prose-context only ───────────────
2835
2836    #[test]
2837    fn blocks_uuid_directly_labeled_as_api_key() {
2838        let content = "api_key 550e8400-e29b-41d4-a716-446655440000";
2839        assert!(
2840            check(content).is_err(),
2841            "UUID-shaped token labeled api_key must be blocked; got {:?}",
2842            scan(content)
2843        );
2844    }
2845
2846    #[test]
2847    fn blocks_sha256_content_hash_labeled_as_secret() {
2848        let content = "secret sha256-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq";
2849        assert!(
2850            check(content).is_err(),
2851            "sha256-prefixed hash labeled secret must be blocked; got {:?}",
2852            scan(content)
2853        );
2854    }
2855
2856    #[test]
2857    fn blocks_sha384_content_hash_labeled_as_api_key() {
2858        let content =
2859            "api_key sha384-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
2860        assert!(
2861            check(content).is_err(),
2862            "sha384-prefixed hash labeled api_key must be blocked; got {:?}",
2863            scan(content)
2864        );
2865    }
2866
2867    #[test]
2868    fn blocks_sha512_content_hash_labeled_as_auth() {
2869        let content = "auth sha512-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/ABCDEFGHIJKLMNOPQRSTUV";
2870        assert!(
2871            check(content).is_err(),
2872            "sha512-prefixed hash labeled auth must be blocked; got {:?}",
2873            scan(content)
2874        );
2875    }
2876
2877    #[test]
2878    fn allows_uuid_with_no_trigger_within_window() {
2879        // Common benign shape: a UUID (e.g. an internal record id) with no
2880        // credential trigger word anywhere in the surrounding window stays
2881        // allowed — the allowlist still applies outside trigger context.
2882        let content =
2883            "task 550e8400-e29b-41d4-a716-446655440000 was created and assigned to the team";
2884        assert!(
2885            check(content).is_ok(),
2886            "UUID with no nearby trigger word must stay allowed; got {:?}",
2887            scan(content)
2888        );
2889    }
2890
2891    #[test]
2892    fn allows_area_id_uuid_near_authorized_substring() {
2893        // An internal task `area_id` UUID field sitting within the trigger
2894        // window of the SUBSTRING "auth" inside
2895        // `authorized_write_requires_dominance` is not a genuine mention of
2896        // the word "auth": it is a pure substring collision with
2897        // "authorized". Bare trigger words match at a word boundary (see
2898        // `contains_bounded_word`), so `auth` does not match inside
2899        // `authorized`; this UUID has no trigger in its window and passes via
2900        // the ordinary out-of-context UUID allowlist.
2901        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.";
2902        assert!(
2903            check(content).is_ok(),
2904            "internal area_id UUID near the 'authorized' substring \
2905             (not a genuine 'auth' mention) must now pass; got {:?}",
2906            scan(content)
2907        );
2908    }
2909
2910    // ── UUID/hash value extraction from assignment and wrapper syntax ───────
2911
2912    #[test]
2913    fn blocks_uuid_glued_to_assignment_equals() {
2914        let content = "api_key=550e8400-e29b-41d4-a716-446655440000";
2915        assert!(
2916            check(content).is_err(),
2917            "UUID glued via '=' to a trigger word must be blocked; got {:?}",
2918            scan(content)
2919        );
2920    }
2921
2922    #[test]
2923    fn blocks_uuid_with_trailing_sentence_period() {
2924        let content = "api_key 550e8400-e29b-41d4-a716-446655440000.";
2925        assert!(
2926            check(content).is_err(),
2927            "UUID with a trailing sentence period near a trigger must be blocked; got {:?}",
2928            scan(content)
2929        );
2930    }
2931
2932    #[test]
2933    fn blocks_uuid_wrapped_in_parens() {
2934        let content = "api_key (550e8400-e29b-41d4-a716-446655440000)";
2935        assert!(
2936            check(content).is_err(),
2937            "UUID wrapped in parens near a trigger must be blocked; got {:?}",
2938            scan(content)
2939        );
2940    }
2941
2942    #[test]
2943    fn blocks_uuid_in_json_object() {
2944        let content = "{\"api_key\":\"550e8400-e29b-41d4-a716-446655440000\"}";
2945        assert!(
2946            check(content).is_err(),
2947            "UUID in a JSON-ish object near a trigger key must be blocked; got {:?}",
2948            scan(content)
2949        );
2950    }
2951
2952    #[test]
2953    fn blocks_content_hash_glued_to_assignment_equals() {
2954        let content = "secret=sha256-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq";
2955        assert!(
2956            check(content).is_err(),
2957            "sha256-prefixed hash glued via '=' to a trigger word must be blocked; \
2958             got {:?}",
2959            scan(content)
2960        );
2961    }
2962
2963    #[test]
2964    fn blocks_content_hash_with_trailing_sentence_period() {
2965        let content = "secret sha256-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq.";
2966        assert!(
2967            check(content).is_err(),
2968            "sha256-prefixed hash with a trailing period near a trigger must be \
2969             blocked; got {:?}",
2970            scan(content)
2971        );
2972    }
2973
2974    #[test]
2975    fn blocks_content_hash_wrapped_in_parens() {
2976        let content = "secret (sha256-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq)";
2977        assert!(
2978            check(content).is_err(),
2979            "sha256-prefixed hash wrapped in parens near a trigger must be blocked; \
2980             got {:?}",
2981            scan(content)
2982        );
2983    }
2984
2985    #[test]
2986    fn blocks_content_hash_in_json_object() {
2987        let content = "{\"secret\":\"sha256-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq\"}";
2988        assert!(
2989            check(content).is_err(),
2990            "sha256-prefixed hash in a JSON-ish object near a trigger key must be \
2991             blocked; got {:?}",
2992            scan(content)
2993        );
2994    }
2995
2996    #[test]
2997    fn allows_uuid_wrapped_in_parens_with_no_trigger_nearby() {
2998        // Control: the prose allowlist must survive for wrapper syntax when
2999        // there is no credential trigger word anywhere in the window — only
3000        // the trigger-context extraction changed, not the outside-context
3001        // allowlist itself.
3002        let content = "wrapper (550e8400-e29b-41d4-a716-446655440000) present";
3003        assert!(
3004            check(content).is_ok(),
3005            "UUID wrapped in parens with no trigger word nearby must stay allowed; \
3006             got {:?}",
3007            scan(content)
3008        );
3009    }
3010
3011    #[test]
3012    fn blocks_padded_content_hash_glued_to_assignment_with_trailing_period() {
3013        // A padded base64 value ends in its own `=`, which is also a valid
3014        // separator character — `value_candidates` must enumerate the
3015        // suffix after every `=`/`:`, not assume any single separator
3016        // position, so the true value is recovered regardless of which
3017        // separator happens to sit where.
3018        let content = "secret=sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=.";
3019        assert!(
3020            check(content).is_err(),
3021            "padded sha256 hash glued via '=' with a trailing period must be \
3022             blocked; got {:?}",
3023            scan(content)
3024        );
3025    }
3026
3027    #[test]
3028    fn blocks_padded_content_hash_in_json_object() {
3029        let content = "{\"secret\":\"sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"}";
3030        assert!(
3031            check(content).is_err(),
3032            "padded sha256 hash in a JSON-ish object near a trigger key must be \
3033             blocked; got {:?}",
3034            scan(content)
3035        );
3036    }
3037
3038    #[test]
3039    fn blocks_uuid_when_json_label_itself_contains_colon() {
3040        // The label can itself contain the separator character
3041        // (`"api:key"` rather than `"api_key"`); the first `:` after
3042        // wrapper-stripping then lands inside the label, not at the
3043        // label/value boundary. value_candidates must still surface the
3044        // bare UUID as a later suffix candidate.
3045        let content = "{\"api:key\":\"550e8400-e29b-41d4-a716-446655440000\"}";
3046        assert!(
3047            check(content).is_err(),
3048            "UUID must be blocked even when the JSON label contains ':'; got {:?}",
3049            scan(content)
3050        );
3051    }
3052
3053    #[test]
3054    fn blocks_uuid_when_json_label_itself_contains_equals() {
3055        let content = "{\"api=key\":\"550e8400-e29b-41d4-a716-446655440000\"}";
3056        assert!(
3057            check(content).is_err(),
3058            "UUID must be blocked even when the JSON label contains '='; got {:?}",
3059            scan(content)
3060        );
3061    }
3062
3063    #[test]
3064    fn blocks_uuid_behind_doubled_assignment() {
3065        // key=label=value: the first `=` lands between two labels, not at
3066        // the true value boundary.
3067        let content = "api_key=label=550e8400-e29b-41d4-a716-446655440000"; // gitleaks:allow
3068        assert!(
3069            check(content).is_err(),
3070            "UUID must be blocked behind a doubled assignment; got {:?}",
3071            scan(content)
3072        );
3073    }
3074
3075    #[test]
3076    fn blocks_padded_content_hash_behind_doubled_assignment_equals() {
3077        let content = "secret=label=sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=.";
3078        assert!(
3079            check(content).is_err(),
3080            "padded content hash must be blocked behind a doubled '=' assignment; \
3081             got {:?}",
3082            scan(content)
3083        );
3084    }
3085
3086    #[test]
3087    fn blocks_padded_content_hash_behind_doubled_assignment_colon() {
3088        let content = "secret:label=sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=.";
3089        assert!(
3090            check(content).is_err(),
3091            "padded content hash must be blocked behind a doubled ':'+'=' \
3092             assignment; got {:?}",
3093            scan(content)
3094        );
3095    }
3096
3097    #[test]
3098    fn allows_benign_url_with_scheme_and_path_separators() {
3099        // `value_candidates`'s any-suffix semantics must not block ordinary
3100        // URLs, whose `://` and `/` characters produce several suffix
3101        // candidates but none of them are UUID- or content-hash-shaped.
3102        // Placed near a real trigger word ("key") so the check actually
3103        // exercises the trigger-context path rather than being skipped
3104        // outright.
3105        let content = "api_key endpoint=https://example.test/resource/for/testing";
3106        assert!(
3107            check(content).is_ok(),
3108            "a benign URL near a trigger word must stay allowed; got {:?}",
3109            scan(content)
3110        );
3111    }
3112
3113    // ── Trigger word-boundary matching ──────────────────────────────────────
3114
3115    #[test]
3116    fn allows_trigger_substrings_inside_benign_path_slugs() {
3117        let paths = [
3118            "docs/_archive/adr_v0/ADR-051-cli-auth-and-kg-git-workflow.md",
3119            "docs/platform/oauth-callback-docs-and-redirect-handling-v2.md",
3120            "docs/research/author-attribution-and-collaboration-notes.md",
3121            "docs/security/passwordless-authentication-overview-v3.md",
3122            "docs/platform/private_keynote-authoring-guide-v2.md",
3123        ];
3124        for path in paths {
3125            assert!(
3126                check(path).is_ok(),
3127                "trigger substring inside a benign path slug must not make the path \
3128                 its own credential context: {path:?}, got {:?}",
3129                scan(path)
3130            );
3131        }
3132    }
3133
3134    #[test]
3135    fn blocks_inline_auth_assignment_with_high_entropy_value() {
3136        let content = "auth=Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
3137        assert!(
3138            check(content).is_err(),
3139            "auth=<high-entropy-value> must still be blocked; got {:?}",
3140            scan(content)
3141        );
3142    }
3143
3144    #[test]
3145    fn blocks_suffix_bearing_compound_credential_assignments() {
3146        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
3147        let cases = [
3148            format!("api_keyv2={opaque}"),
3149            format!("access_keyv2={opaque}"),
3150            format!("private_keyv2={opaque}"),
3151            format!("API_KEYV2={opaque}"),
3152            format!(r#"{{"private_keyv2":"{opaque}"}}"#),
3153        ];
3154        for content in &cases {
3155            assert!(
3156                check(content).is_err(),
3157                "suffix-bearing compound credential assignment must be blocked: \
3158                 {content:?}, got {:?}",
3159                scan(content)
3160            );
3161        }
3162    }
3163
3164    #[test]
3165    fn blocks_spaced_suffix_bearing_compound_credential_assignments() {
3166        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
3167        for label in ["api_keyv2", "access_keyv2", "private_keyv2"] {
3168            let cases = [
3169                format!("{label} = {opaque}"),
3170                format!("{label} : {opaque}"),
3171                format!(r#"{{"{label}": "{opaque}"}}"#),
3172            ];
3173            for content in &cases {
3174                assert!(
3175                    check(content).is_err(),
3176                    "spaced suffix-bearing compound credential assignment must be \
3177                     blocked: {content:?}, got {:?}",
3178                    scan(content)
3179                );
3180            }
3181        }
3182    }
3183
3184    #[test]
3185    fn blocks_suffix_bearing_compound_credentials_without_assignment_separator() {
3186        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
3187        for label in ["api_keyv2", "access_keyv2", "private_keyv2"] {
3188            let cases = [format!("{label} {opaque}"), format!("{label}{opaque}")];
3189            for content in &cases {
3190                assert!(
3191                    check(content).is_err(),
3192                    "suffix-bearing compound credential without an assignment separator must be \
3193                     blocked: {content:?}, got {:?}",
3194                    scan(content)
3195                );
3196                assert!(
3197                    mask_secrets(content).contains(REDACTION_MARKER),
3198                    "shared secret masker must redact separator-free compound credential: \
3199                     {content:?}"
3200                );
3201            }
3202        }
3203
3204        let prefixed = "xapi_keyv2=Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
3205        assert!(
3206            check(prefixed).is_err(),
3207            "prefix-bearing compound credential assignment must be blocked: \
3208             {prefixed:?}, got {:?}",
3209            scan(prefixed)
3210        );
3211    }
3212
3213    #[test]
3214    fn allows_authorized_and_authentication_prose_near_uuid() {
3215        // The word-boundary fix directly: "auth" no longer matches the
3216        // substring inside "authorized"/"authentication", so ordinary prose
3217        // using those words does not poison the trigger window for a nearby
3218        // UUID or other allowlisted shape.
3219        let cases = [
3220            "authorized_write_requires_dominance was converted from axiom to theorem, id 550e8400-e29b-41d4-a716-446655440000",
3221            "authentication flow diagram lives at 550e8400-e29b-41d4-a716-446655440000",
3222        ];
3223        for content in cases {
3224            assert!(
3225                check(content).is_ok(),
3226                "'authorized'/'authentication' substring must not trigger the \
3227                 entropy heuristic: {content:?}, got {:?}",
3228                scan(content)
3229            );
3230        }
3231    }
3232
3233    #[test]
3234    fn allows_turkey_monkey_keyword_prose_near_uuid() {
3235        // Other bare-word substring collisions in TRIGGER_WORDS ("key") must
3236        // likewise not fire on ordinary English words that merely contain it.
3237        let cases = [
3238            "the turkey and monkey story references id 550e8400-e29b-41d4-a716-446655440000",
3239            "keyword research doc: 550e8400-e29b-41d4-a716-446655440000",
3240        ];
3241        for content in cases {
3242            assert!(
3243                check(content).is_ok(),
3244                "'turkey'/'monkey'/'keyword' substring must not trigger the \
3245                 entropy heuristic: {content:?}, got {:?}",
3246                scan(content)
3247            );
3248        }
3249    }
3250
3251    #[test]
3252    fn blocks_opaque_tokens_near_standalone_trigger_words() {
3253        // Word-boundary matching only removes SUBSTRING collisions; a genuine
3254        // standalone trigger word must still dominate exactly as before.
3255        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
3256        let cases = [
3257            format!("auth header {opaque}"),
3258            format!("the key is {opaque}"),
3259            format!("secret value: {opaque}"),
3260        ];
3261        for content in &cases {
3262            assert!(
3263                check(content).is_err(),
3264                "a genuine standalone trigger word must still block: {content:?}, \
3265                 got {:?}",
3266                scan(content)
3267            );
3268        }
3269    }
3270
3271    #[test]
3272    fn blocks_workspace_artifact_path_near_standalone_secret() {
3273        // A dot-prefixed root + date segment + hyphenated topic dir +
3274        // SCREAMING_SNAKE filename, discussed in prose that genuinely (not by
3275        // substring collision) mentions "secret". `secret` here is a real
3276        // standalone word, not a substring collision, so it still dominates
3277        // and the path still falls through to the entropy heuristic like any
3278        // other near-trigger token. A documented accepted tradeoff, not a
3279        // regression.
3280        let content = "writing up the secret gate false positive repro: \
3281             .workspace/20260101/fix-secret-gate-trigger-false-positive/MEASUREMENT_REPORT.md";
3282        assert!(
3283            check(content).is_err(),
3284            "accepted FP: workspace artifact path near a \
3285             genuine standalone 'secret' mention is still blocked; got {:?}",
3286            scan(content)
3287        );
3288    }
3289
3290    #[test]
3291    fn blocks_archive_doc_path_near_standalone_secret() {
3292        // An archive-style doc path discussed near a genuine standalone
3293        // "secret" mention.
3294        let content =
3295            "secret scanner archive notes: docs/_archive/ADR051-TenantEncryption-v2Notes.md";
3296        assert!(
3297            check(content).is_err(),
3298            "accepted FP: archive doc path near a genuine \
3299             standalone 'secret' mention is still blocked; got {:?}",
3300            scan(content)
3301        );
3302    }
3303
3304    #[test]
3305    fn blocks_absolute_path_near_standalone_auth() {
3306        // An absolute path written as one unbroken token, with the
3307        // surrounding message genuinely (not via substring collision)
3308        // discussing "auth".
3309        let content = "the auth scanner flagged this file: /home/user/projects/workspace/SessionNotes20260107/AuthGateFollowup2.md";
3310        assert!(
3311            check(content).is_err(),
3312            "accepted FP: absolute path near a genuine \
3313             standalone 'auth' mention is still blocked; got {:?}",
3314            scan(content)
3315        );
3316    }
3317
3318    #[test]
3319    fn blocks_assignment_shaped_credential_disguised_as_path_near_api_key() {
3320        // Adversarial negative: a credential-shaped value glued via '='
3321        // directly to a trigger word must not be exempted just because it is
3322        // path-shaped (separator-delimited, word-shaped runs) and looks
3323        // superficially like the accepted-FP repro paths above. The compound
3324        // label `api_key` is assignment-shaped, and the structured-identifier
3325        // exemption is unconditionally dropped in trigger context, so this
3326        // must block.
3327        let content = "api_key=/home/user/workspaces/2026/topic-name-example/SECRET_VALUE_HERE.md";
3328        assert!(
3329            check(content).is_err(),
3330            "assignment-shaped credential disguised as a path must still be \
3331             blocked: {content:?}, got {:?}",
3332            scan(content)
3333        );
3334    }
3335
3336    #[test]
3337    fn blocks_separator_split_secret_access_key_compound() {
3338        // Adversarial negative: a separator-split bypass shape must still be
3339        // blocked. The `secret` and `key` entries both match because underscore
3340        // is a boundary for bare `TRIGGER_WORDS`. This asserts the end-to-end
3341        // outcome.
3342        let content = "secret_access_key abcdefghij/klmnopqrst/uvwxyzabcd/efghijk.md";
3343        assert!(
3344            check(content).is_err(),
3345            "secret_access_key bypass shape must still be blocked: {content:?}, \
3346             got {:?}",
3347            scan(content)
3348        );
3349    }
3350
3351    // ── Underscore is a BOUNDARY for bare TRIGGER_WORDS, not a continuation ─
3352    //
3353    // Bare TRIGGER_WORDS are word-boundary-aware, but underscore must be
3354    // treated as a boundary rather than a word character (continuation) for
3355    // this set specifically: the opposite of `has_standalone_token`'s rule
3356    // for `token`. Treating underscore as a continuation would silently drop
3357    // detection of extremely common underscore-joined credential-config
3358    // compounds (`SECRET_KEY=`, `auth_token=`, `signing_key=`,
3359    // `session_secret_...`), since `secret`/`key`/`auth` would never be
3360    // bounded by `_` under that rule. Fixed by treating `_` as a boundary for
3361    // the bare `TRIGGER_WORDS` check specifically (see `contains_word`'s
3362    // `underscore_is_word_char` parameter), while leaving
3363    // `has_standalone_token`'s `token`-specific underscore-as-continuation
3364    // rule (the `tokenizer`/`next_token`/`token_count` exemption) unchanged.
3365
3366    #[test]
3367    fn blocks_secret_key_assignment_when_underscore_bounds_trigger() {
3368        // `SECRET_KEY=<value>` must block via the plain-substring `secret`
3369        // trigger even though `secret` is followed by `_` rather than a
3370        // non-word-char boundary.
3371        let content = "SECRET_KEY=dGhpc2lzYXNlY3JldGtleXZhbHVlMTIzNDU2Nzg5MA=="; // gitleaks:allow
3372        assert!(
3373            check(content).is_err(),
3374            "SECRET_KEY=<value> (Django/Flask-style config) must still be \
3375             blocked: {content:?}, got {:?}",
3376            scan(content)
3377        );
3378    }
3379
3380    #[test]
3381    fn blocks_auth_token_assignment_when_underscore_bounds_trigger() {
3382        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
3383        let content = format!("auth_token={opaque}");
3384        assert!(
3385            check(&content).is_err(),
3386            "auth_token=<value> must still be blocked: {content:?}, got {:?}",
3387            scan(&content)
3388        );
3389    }
3390
3391    #[test]
3392    fn blocks_underscore_joined_session_secret_and_signing_key_compounds() {
3393        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
3394        let cases = [
3395            format!("session_secret_{opaque}"),
3396            format!("signing_key={opaque}"),
3397        ];
3398        for content in &cases {
3399            assert!(
3400                check(content).is_err(),
3401                "underscore-joined credential compound must still be blocked: \
3402                 {content:?}, got {:?}",
3403                scan(content)
3404            );
3405        }
3406    }
3407
3408    #[test]
3409    fn allows_letter_joined_trigger_substrings_in_benign_prose() {
3410        // The letter-joined substring-collision exemption must survive the
3411        // underscore-as-boundary change, since that change only affects the
3412        // underscore character, not letter-joined words.
3413        let cases = [
3414            "authorized_write_requires_dominance was converted from axiom to theorem, id 550e8400-e29b-41d4-a716-446655440000",
3415            "authentication flow diagram lives at 550e8400-e29b-41d4-a716-446655440000",
3416            "the turkey and monkey story references id 550e8400-e29b-41d4-a716-446655440000",
3417            "keyword research doc: 550e8400-e29b-41d4-a716-446655440000",
3418        ];
3419        for content in cases {
3420            assert!(
3421                check(content).is_ok(),
3422                "letter-joined substring collision must stay exempt: \
3423                 {content:?}, got {:?}",
3424                scan(content)
3425            );
3426        }
3427    }
3428
3429    #[test]
3430    fn block_message_carries_actionable_guidance() {
3431        let fake = "AKIAFAKEKEY1234567890";
3432        let m = scan(fake).unwrap();
3433        let rendered = m.to_string();
3434        assert!(
3435            rendered.contains("real credential"),
3436            "block message must carry actionable guidance: {rendered}"
3437        );
3438    }
3439
3440    #[test]
3441    fn block_message_shape_guidance_mentions_rewording() {
3442        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
3443        let content = format!("auth header {opaque}");
3444        let m = scan(&content).unwrap();
3445        let rendered = m.to_string();
3446        assert!(
3447            rendered.contains("reword") || rendered.contains("own line"),
3448            "shape-based detector guidance must suggest rewording/splitting: {rendered}"
3449        );
3450    }
3451}
3452
3453// ─── Corpus replay harness (manual, opt-in) ─────────────────────────────────
3454//
3455// Measures how many real note/entity strings the gate blocks, so a detector
3456// change can be evaluated against production content rather than intuition
3457// (see the module doc). Opens the target database
3458// STRICTLY read-only (`SQLITE_OPEN_READ_ONLY`) and never mutates it. Point
3459// `KHIVE_REPLAY_DB` at a copy or a live KG database file path; the harness
3460// never writes, locks aggressively, or deletes anything.
3461//
3462// Run with: `KHIVE_REPLAY_DB=/path/to/khive.db cargo test -p khive-runtime \
3463//   --release -- --ignored --nocapture corpus_replay`
3464#[cfg(test)]
3465mod corpus_replay {
3466    use super::*;
3467    use rusqlite::{Connection, OpenFlags};
3468
3469    #[test]
3470    #[ignore]
3471    fn replay_against_corpus() {
3472        let db_path = std::env::var("KHIVE_REPLAY_DB")
3473            .expect("set KHIVE_REPLAY_DB=/path/to/khive.db to run the corpus replay (read-only)");
3474        let conn = Connection::open_with_flags(
3475            &db_path,
3476            OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
3477        )
3478        .expect("open corpus DB read-only");
3479
3480        let mut total = 0usize;
3481        let mut blocked = 0usize;
3482        let mut samples: Vec<String> = Vec::new();
3483
3484        let mut collect = |sql: &str| {
3485            let mut stmt = conn.prepare(sql).expect("prepare replay query");
3486            let mut rows = stmt.query([]).expect("query replay rows");
3487            while let Some(row) = rows.next().expect("read replay row") {
3488                let content: Option<String> = row.get(0).unwrap_or(None);
3489                let Some(content) = content else { continue };
3490                if content.is_empty() {
3491                    continue;
3492                }
3493                total += 1;
3494                if let Some(m) = scan(&content) {
3495                    blocked += 1;
3496                    if samples.len() < 30 {
3497                        samples.push(format!(
3498                            "{} :: {}",
3499                            m,
3500                            content.chars().take(160).collect::<String>()
3501                        ));
3502                    }
3503                }
3504            }
3505        };
3506
3507        collect("SELECT content FROM notes WHERE deleted_at IS NULL");
3508        collect("SELECT description FROM entities WHERE deleted_at IS NULL");
3509
3510        eprintln!("corpus replay: {blocked}/{total} strings blocked");
3511        for s in &samples {
3512            eprintln!("  BLOCKED: {s}");
3513        }
3514    }
3515}