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