Skip to main content

kernel/secret/
scan.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! Secret scanning: shape-table-first, entropy-second, float-free.
7//! The part `crates/kernel/spec/Secret.lean` specifies this module with the
8//! rest of `kernel::secret`.
9
10use crate::consts_external::{SECRET_SHAPES, SecretCharset};
11use crate::consts_policy::SECRET_ENTROPY_MIN;
12
13use super::hex_run::is_labelled_hex_secret;
14use super::span::SecretSpan;
15use std::sync::LazyLock;
16
17fn charset_admits(charset: SecretCharset, byte: u8) -> bool {
18    match charset {
19        SecretCharset::Base62 => byte.is_ascii_alphanumeric(),
20        SecretCharset::Base64Url => {
21            byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_' || byte == b'='
22        }
23        SecretCharset::HexLower => byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte),
24        SecretCharset::UpperBase36 => byte.is_ascii_uppercase() || byte.is_ascii_digit(),
25    }
26}
27
28/// Entropy-detector alphabet: the union of common token charsets.
29/// `=` is deliberately absent: as base64 padding it only trails (the
30/// pre-padding run still crosses the length floor), while as an
31/// assignment sign it welds two identifiers into one fake token
32/// (`NAME=value` — a false-positive class).
33fn token_byte(byte: u8) -> bool {
34    byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'+' | b'/')
35}
36
37/// The entropy detector only fires on mixed-alphabet runs (upper AND
38/// lower AND digit): unknown-shape API keys are mixed-case base62/64,
39/// while the city's own artifacts — blake3 hex, uuids, digit runs — are
40/// single-case by construction and must not light up every ledger line.
41/// An all-lowercase secret evades this detector; the shape table stays
42/// the primary net and rotation the last remedy.
43fn mixed_alphabet(bytes: &[u8]) -> bool {
44    let (mut upper, mut lower, mut digit) = (false, false, false);
45    for byte in bytes {
46        upper |= byte.is_ascii_uppercase();
47        lower |= byte.is_ascii_lowercase();
48        digit |= byte.is_ascii_digit();
49    }
50    upper && lower && digit
51}
52
53/// Minimum span for the entropy detector: mainstream API keys start
54/// around 20 chars; anything shorter is prose noise. Internal affair —
55/// changing it changes recall, so it moves with this SPEC only.
56pub(crate) const ENTROPY_SPAN_MIN_BYTES: usize = 20;
57
58/// log2 in Q10 fixed point (10 fractional bits), shift-and-square.
59/// Zero input returns zero (callers guard); the loop bound is the
60/// constant 10, so termination is by construction (V5).
61fn log2_q10(x: u64) -> u64 {
62    if x == 0 {
63        return 0;
64    }
65    let int_part = u64::from(x.ilog2());
66    // Normalize the mantissa into [1, 2) as Q32 in u128.
67    let mantissa_q32: u128 = (u128::from(x) << 32) >> x.ilog2();
68    let mut y = mantissa_q32;
69    let mut frac: u64 = 0;
70    for _ in 0..10 {
71        y = (y.saturating_mul(y)) >> 32;
72        frac <<= 1;
73        if y >= (2u128 << 32) {
74            frac |= 1;
75            y >>= 1;
76        }
77    }
78    (int_part << 10) | frac
79}
80
81/// Shannon entropy per char in millibits (1/1000 bit). Saturation can
82/// only over-approximate, which errs toward detection — the recoverable
83/// direction (entrance replaces, never refuses).
84pub(super) fn entropy_millibits_per_char(bytes: &[u8]) -> u64 {
85    let Ok(n) = u64::try_from(bytes.len()) else {
86        return 0; // unreachable on real targets; zero reads as low entropy
87    };
88    if n == 0 {
89        return 0;
90    }
91    let mut counts = [0u64; 256];
92    for byte in bytes {
93        let slot = counts.get_mut(usize::from(*byte));
94        if let Some(c) = slot {
95            *c = c.saturating_add(1);
96        }
97    }
98    let log_n = log2_q10(n);
99    let mut sum_q10: u64 = 0;
100    for count in counts {
101        if count == 0 {
102            continue;
103        }
104        let term = count.saturating_mul(log_n.saturating_sub(log2_q10(count)));
105        sum_q10 = sum_q10.saturating_add(term);
106    }
107    // Q10 -> millibits: * 1000 / 1024, then / n for the per-char figure.
108    sum_q10
109        .saturating_mul(1000)
110        .checked_div(1024)
111        .and_then(|mb| mb.checked_div(n))
112        .unwrap_or(0)
113}
114
115fn entropy_passes(bytes: &[u8], work: &mut Work) -> bool {
116    let mb = work.entropy_of(bytes);
117    let num = u64::from(SECRET_ENTROPY_MIN.num);
118    let den = u64::from(SECRET_ENTROPY_MIN.den);
119    // mb >= (num / den) * 1000  <=>  mb * den >= num * 1000
120    mb.saturating_mul(den) >= num.saturating_mul(1000)
121}
122
123/// What one scan did, counted rather than timed, so a test can hold how
124/// the work grows with the input (`crates/kernel/spec/Secret.lean` §8-25).
125#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
126pub(super) struct Work {
127    /// Bytes a detector loop read, once per loop that read them.
128    pub(super) bytes_read: u64,
129    /// Entropy readings taken: a 256-slot count table each, and one
130    /// fixed-point logarithm per byte value present.
131    pub(super) entropy_readings: u64,
132}
133
134impl Work {
135    fn read(&mut self, bytes: usize) {
136        self.bytes_read = self
137            .bytes_read
138            .saturating_add(u64::try_from(bytes).unwrap_or(u64::MAX));
139    }
140
141    /// The entropy of `run` in millibits per char, counted as one reading.
142    pub(super) fn entropy_of(&mut self, run: &[u8]) -> u64 {
143        self.entropy_readings = self.entropy_readings.saturating_add(1);
144        self.read(run.len());
145        entropy_millibits_per_char(run)
146    }
147}
148
149/// Custody's detection half. Shape table first;
150/// entropy second over token runs of at least [`ENTROPY_SPAN_MIN_BYTES`]
151/// that no shape already claimed. Pure, total, deterministic.
152pub fn scan(bytes: &[u8]) -> Vec<SecretSpan> {
153    scan_counting(bytes).0
154}
155
156/// [`scan`], with the work it did.
157pub(super) fn scan_counting(bytes: &[u8]) -> (Vec<SecretSpan>, Work) {
158    let mut work = Work::default();
159    let mut hits = find_shape_hits(bytes, &mut work);
160    let mut found = Vec::new();
161    let mut claimed = Claimed::new(&hits);
162    let mut at = 0usize;
163    while at < bytes.len() {
164        if !bytes.get(at).copied().is_some_and(token_byte) {
165            at = at.saturating_add(1);
166            continue;
167        }
168        let end = bytes
169            .get(at..)
170            .and_then(|rest| rest.iter().position(|b| !token_byte(*b)))
171            .map_or(bytes.len(), |rel| at.saturating_add(rel));
172        let len = end.saturating_sub(at);
173        work.read(len);
174        if len >= ENTROPY_SPAN_MIN_BYTES
175            && !claimed.overlaps(at, end)
176            && (bytes.get(at..end).is_some_and(|run| {
177                work.read(run.len());
178                mixed_alphabet(run) && entropy_passes(run, &mut work)
179            }) || is_labelled_hex_secret(bytes, at, end, &mut work))
180        {
181            found.push(SecretSpan {
182                start: at,
183                len,
184                provider: None,
185            });
186        }
187        at = end;
188    }
189    hits.append(&mut found);
190    hits.sort_by_key(|h| (h.start, h.len));
191    (hits, work)
192}
193
194/// Whether some shape prefix starts with this byte, so the shape pass
195/// compares prefixes only where one can begin.
196static OPENS_A_SHAPE: LazyLock<[bool; 256]> = LazyLock::new(|| {
197    let mut table = [false; 256];
198    for shape in &SECRET_SHAPES {
199        if let Some(slot) = shape
200            .prefix
201            .as_bytes()
202            .first()
203            .and_then(|first| table.get_mut(usize::from(*first)))
204        {
205            *slot = true;
206        }
207    }
208    table
209});
210
211/// The shape hits, sorted by start and length. One pass over the bytes;
212/// each shape resumes after the prefix of its own previous match, which
213/// is the hit set a pass per shape gives.
214fn find_shape_hits(bytes: &[u8], work: &mut Work) -> Vec<SecretSpan> {
215    let mut resume = [0usize; SECRET_SHAPES.len()];
216    let mut hits = Vec::new();
217    let opens: &[bool; 256] = &OPENS_A_SHAPE;
218    work.read(bytes.len());
219    for (start, first) in bytes.iter().enumerate() {
220        if !opens.get(usize::from(*first)).copied().unwrap_or(false) {
221            continue;
222        }
223        let Some(rest) = bytes.get(start..) else {
224            continue;
225        };
226        for (shape, next) in SECRET_SHAPES.iter().zip(resume.iter_mut()) {
227            let prefix = shape.prefix.as_bytes();
228            if start < *next || prefix.is_empty() || !rest.starts_with(prefix) {
229                continue;
230            }
231            let body = rest.get(prefix.len()..).map_or(0, |tail| {
232                tail.iter()
233                    .take_while(|b| charset_admits(shape.charset, **b))
234                    .count()
235            });
236            work.read(body);
237            let total = prefix.len().saturating_add(body);
238            if total >= usize::from(shape.len.0) {
239                hits.push(SecretSpan {
240                    start,
241                    len: total.min(usize::from(shape.len.1)),
242                    provider: Some(shape.provider),
243                });
244            }
245            *next = start.saturating_add(prefix.len());
246        }
247    }
248    hits.sort_by_key(|h| (h.start, h.len));
249    hits
250}
251
252/// The spans the shape table claimed, asked in rising order of start
253/// whether a token run overlaps one of them. Each span is passed once,
254/// so the questions of one scan cost the number of spans in total.
255struct Claimed<'a> {
256    spans: &'a [SecretSpan],
257    next: usize,
258    /// The furthest end of every span that starts before the last run asked.
259    reach: usize,
260}
261
262impl<'a> Claimed<'a> {
263    fn new(spans: &'a [SecretSpan]) -> Claimed<'a> {
264        Claimed {
265            spans,
266            next: 0,
267            reach: 0,
268        }
269    }
270
271    /// Whether `start..end` overlaps a claimed span; `start` never falls
272    /// between two calls.
273    fn overlaps(&mut self, start: usize, end: usize) -> bool {
274        while let Some(span) = self.spans.get(self.next).filter(|s| s.start < start) {
275            self.reach = self.reach.max(span.start.saturating_add(span.len));
276            self.next = self.next.saturating_add(1);
277        }
278        self.reach > start
279            || self
280                .spans
281                .get(self.next..)
282                .unwrap_or_default()
283                .iter()
284                .take_while(|s| s.start < end)
285                .any(|s| s.start.saturating_add(s.len) > start)
286    }
287}
288
289/// Whether a *name* reads as the name of a credential.
290///
291/// The other half of this module judges bytes that might be a secret;
292/// this half judges a label that would sit in front of one, which is
293/// what a configuration file offers when it declares an environment
294/// variable. Both live here so "what looks like a credential" has one
295/// authority rather than two.
296///
297/// Case-insensitive substring match against
298/// [`crate::consts_policy::CREDENTIAL_NAME_MARKERS`]. Over-inclusive on
299/// purpose: the caller refuses with an alternative, and a false refusal
300/// costs a person one line of configuration while a missed one leaks a
301/// key into every child process the run starts.
302#[must_use]
303pub fn names_a_credential(name: &str) -> bool {
304    let folded = name.to_ascii_lowercase();
305    crate::consts_policy::CREDENTIAL_NAME_MARKERS
306        .iter()
307        .any(|marker| folded.contains(marker))
308}
309
310#[cfg(test)]
311#[allow(
312    clippy::unwrap_used,
313    clippy::expect_used,
314    clippy::panic,
315    clippy::indexing_slicing,
316    clippy::string_slice,
317    clippy::arithmetic_side_effects,
318    clippy::as_conversions,
319    reason = "test code"
320)]
321mod tests {
322    use super::*;
323    use proptest::prelude::*;
324    #[test]
325    fn known_provider_shapes_are_found_with_offsets_only() {
326        let text = format!("config = \"sk-ant-{}\" # key", "a1B2c3D4e5".repeat(9));
327        let hits = scan(text.as_bytes());
328        assert!(!hits.is_empty());
329        let hit = &hits[0];
330        assert_eq!(hit.provider, Some("anthropic"));
331        let prefix_end = hit.start.checked_add(7).unwrap();
332        assert_eq!(&text.as_bytes()[hit.start..prefix_end], b"sk-ant-");
333    }
334
335    /// A shape's body runs over every byte of its charset: the `-`, `_`
336    /// and `=` a base64url key carries, and both the capitals and the
337    /// digits of an upper-base36 one. A body cut at the first of them
338    /// would leave the rest of the key outside the span, or drop the hit.
339    #[test]
340    fn a_shape_s_body_runs_over_every_byte_of_its_charset() {
341        let url_body = format!("ab-cd_ef={}", "x".repeat(20));
342        let anthropic = format!("sk-ant-{url_body}");
343        let aws = format!("AKIA{}", "AB12CD34EF56");
344        for (text, provider) in [(anthropic, "anthropic"), (aws, "aws")] {
345            assert_eq!(
346                scan(text.as_bytes()),
347                vec![SecretSpan {
348                    start: 0,
349                    len: text.len(),
350                    provider: Some(provider),
351                }],
352                "{text}"
353            );
354        }
355    }
356
357    /// A prefix that overlaps itself opens one key, not two: `AKIA`
358    /// ends with the letter it starts with, and the second `AKIA` inside
359    /// `AKIAKIA` lies within the first key's prefix.
360    #[test]
361    fn a_self_overlapping_prefix_opens_one_key() {
362        let text = format!(
363            "{}{}",
364            "AKIA".repeat(2).replacen("AA", "A", 1),
365            "B2C3D4E5F6G7H8"
366        );
367        let hits = scan(text.as_bytes());
368        assert_eq!(hits, reference::scan(text.as_bytes()));
369        assert_eq!(
370            hits.iter()
371                .map(|hit| (hit.start, hit.provider))
372                .collect::<Vec<_>>(),
373            [(0, Some("aws"))]
374        );
375    }
376
377    /// A scan looks at every byte it is given at least once, and counts
378    /// that it did: the work the growth test compares is work done.
379    #[test]
380    fn a_scan_counts_every_byte_it_reads() {
381        let text = format!("note: {} and prose", "a1B2c3D4e5".repeat(3));
382        let (_, work) = scan_counting(text.as_bytes());
383        assert!(
384            work.bytes_read >= u64::try_from(text.len()).unwrap(),
385            "{work:?}"
386        );
387    }
388
389    #[test]
390    fn high_entropy_tokens_hit_and_prose_does_not() {
391        // The probe token is assembled at runtime so the source file
392        // itself never holds a scannable span (xtask secret runs on us).
393        let token = ["kJ8vQ2xR9m", "W4nZ7pL3sT", "6yB1cD5fG0", "hN8aE2iU4o"].concat();
394        let noisy = format!("token = {token} ok");
395        let hits = scan(noisy.as_bytes());
396        assert!(hits.iter().any(|h| h.provider.is_none()));
397        let prose = b"the quick brown fox jumps over the lazy dog again and again";
398        assert!(scan(prose).is_empty());
399        let repeated = b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
400        assert!(scan(repeated).is_empty(), "zero entropy never hits");
401    }
402
403    #[test]
404    fn city_native_artifacts_do_not_light_up() {
405        // blake3 hex64: all-lowercase hex — no mixed alphabet, no hit.
406        let hex = format!("prev: {}", "ab".repeat(32));
407        assert!(scan(hex.as_bytes()).is_empty());
408        // uuid: lowercase hex + hyphens.
409        let uuid = b"run: 0198f6a2-7c4a-7bbb-9d1e-000000000001";
410        assert!(scan(uuid).is_empty());
411        // digit runs (timestamps, sizes).
412        let digits = b"t: 17555443211234567890123";
413        assert!(scan(digits).is_empty());
414    }
415
416    #[test]
417    fn shape_hits_swallow_overlapping_entropy_hits() {
418        let text = format!("sk-ant-{}", "a1B2c3D4e5".repeat(9));
419        let hits = scan(text.as_bytes());
420        assert_eq!(hits.len(), 1);
421        assert_eq!(hits[0].provider, Some("anthropic"));
422    }
423
424    #[test]
425    fn aggregator_tokens_with_lowercase_hex_bodies_are_found() {
426        // Every probe is assembled at runtime: `xtask secret` scans this
427        // file, and a literal token here would be a finding in itself.
428        //
429        // These three matter because the entropy detector cannot reach
430        // them. Their bodies are all-lowercase hex, so `mixed_alphabet`
431        // is false and the second detector never fires - the shape table
432        // is the only net.
433        let hex64 = "0f1e2d3c".repeat(8);
434        for (prefix, provider) in [("sk-or-v1-", "openrouter"), ("sk-ai-v1-", "zenmux")] {
435            let text = format!("KEY={prefix}{hex64}");
436            let found: Vec<_> = scan(text.as_bytes()).iter().map(|h| h.provider).collect();
437            assert_eq!(found, vec![Some(provider)], "{prefix} is one shape hit");
438        }
439        let groq = format!("KEY=gsk_{}", "a1B2c3D4e5".repeat(5));
440        assert_eq!(
441            scan(groq.as_bytes()).first().and_then(|h| h.provider),
442            Some("groq")
443        );
444    }
445
446    #[test]
447    fn a_bare_hex_run_still_does_not_hit_without_its_prefix() {
448        // The guard on the entry above: these shapes must not turn every
449        // blake3 hash in the ledger into a finding.
450        let hex64 = "0f1e2d3c".repeat(8);
451        assert!(scan(format!("prev: {hex64}").as_bytes()).is_empty());
452    }
453
454    #[test]
455    fn log2_q10_matches_known_points() {
456        assert_eq!(log2_q10(1), 0);
457        assert_eq!(log2_q10(2), 1 << 10);
458        assert_eq!(log2_q10(4), 2 << 10);
459        // log2(3) = 1.58496...; Q10 => 1623.0 => 1623 (truncated toward 0)
460        let three = log2_q10(3);
461        assert!((1622..=1624).contains(&three), "{three}");
462    }
463
464    #[test]
465    fn uniform_bytes_have_full_entropy() {
466        // 64 distinct byte values once each: log2(64) = 6 bits/char.
467        let bytes: Vec<u8> = (0u8..64).collect();
468        let mb = entropy_millibits_per_char(&bytes);
469        assert!((5900..=6000).contains(&mb), "{mb}");
470    }
471
472    proptest! {
473        /// Total on arbitrary inputs, spans in bounds.
474        #[test]
475        fn scan_is_total_and_in_bounds(bytes in proptest::collection::vec(any::<u8>(), 0..256)) {
476            for hit in scan(&bytes) {
477                prop_assert!(hit.start.checked_add(hit.len).unwrap() <= bytes.len());
478            }
479        }
480
481        /// The scanner reports exactly the spans the reference reports,
482        /// on text built to reach every detector and every boundary.
483        #[test]
484        fn scan_reports_what_the_reference_reports(pieces in proptest::collection::vec(piece(), 0..24)) {
485            let text = pieces.concat();
486            prop_assert_eq!(scan(text.as_bytes()), reference::scan(text.as_bytes()));
487        }
488
489        /// The same on arbitrary bytes, which reach the boundaries no
490        /// generator above was written for.
491        #[test]
492        fn scan_matches_the_reference_on_any_bytes(bytes in proptest::collection::vec(any::<u8>(), 0..512)) {
493            prop_assert_eq!(scan(&bytes), reference::scan(&bytes));
494        }
495    }
496
497    /// The probes the other tests of this module and of `hex_run` use,
498    /// each assembled at runtime so this file holds no scannable span.
499    #[test]
500    fn scan_matches_the_reference_on_the_test_corpus() {
501        let mixed = "a1B2c3D4e5".repeat(9);
502        let hex40 = ["9f3c1a7e5b", "20d48c6f1e", "a7b3905d2c", "e81f64b07a"].concat();
503        let hex64 = "0f1e2d3c".repeat(8);
504        let token = ["kJ8vQ2xR9m", "W4nZ7pL3sT", "6yB1cD5fG0", "hN8aE2iU4o"].concat();
505        let mut corpus = vec![
506            format!("config = \"sk-ant-{mixed}\" # key"),
507            format!("token = {token} ok"),
508            format!("prev: {}", "ab".repeat(32)),
509            "run: 0198f6a2-7c4a-7bbb-9d1e-000000000001".to_owned(),
510            format!("HMAC_KEY={hex40}"),
511            format!("api_token: \"{hex40}\""),
512            format!("{{\"secret\": \"{hex40}\"}}"),
513            format!("commit {hex40} oid={hex40} KEY={}", "0f1e2d3c".repeat(5)),
514            format!("KEY=gsk_{}", "a1B2c3D4e5".repeat(5)),
515            format!("密钥 KEY = '{hex40}' 和 {token}é {mixed}"),
516        ];
517        for shape in &SECRET_SHAPES {
518            corpus.push(format!("KEY={}{hex64}{mixed}", shape.prefix));
519            corpus.push(format!("{}{}{}", shape.prefix, shape.prefix, &mixed[..12]));
520        }
521        for text in corpus {
522            assert_eq!(
523                scan(text.as_bytes()),
524                reference::scan(text.as_bytes()),
525                "{text}"
526            );
527        }
528    }
529
530    /// One piece of generated text: a provider shape with a body of its
531    /// own charset (some too short), a hex run behind a credential name
532    /// or another name (some of low entropy), a mixed token, prose with
533    /// multibyte characters, or a separator.
534    fn piece() -> impl Strategy<Value = String> {
535        let shape = (0..SECRET_SHAPES.len(), "[A-Za-z0-9_=-]{0,90}").prop_map(|(at, body)| {
536            let prefix = SECRET_SHAPES.get(at).map_or("", |s| s.prefix);
537            format!("{prefix}{body}")
538        });
539        let label = prop_oneof![
540            Just("HMAC_KEY"),
541            Just("api_token"),
542            Just("\"secret\""),
543            Just("oid"),
544            Just("commit"),
545            Just("name.session"),
546            Just("x")
547        ];
548        let separator = prop_oneof![Just("="), Just(": "), Just(" = '"), Just(":\""), Just(" ")];
549        let hex = prop_oneof!["[0-9a-f]{28,70}", "(0f1e2d3c){4,6}", "[0-9A-F]{32,40}"];
550        let labelled = (label, separator, hex).prop_map(|(l, s, h)| format!("{l}{s}{h}"));
551        prop_oneof![
552            shape,
553            labelled,
554            "[A-Za-z0-9+/_-]{15,60}",
555            "[a-z ]{0,30}",
556            Just("密钥 é 🔑 ".to_owned()),
557            prop_oneof![
558                Just(" "),
559                Just(
560                    "
561"
562                ),
563                Just("\""),
564                Just("="),
565                Just(":")
566            ]
567            .prop_map(str::to_owned),
568        ]
569    }
570
571    /// Today's scanner, kept as the judge of every faster one: a shape
572    /// pass per table entry, the overlap test against every hit, and the
573    /// hex run's entropy read before its label. It counts its work the
574    /// way [`Work`] does, so the growth test can compare the two.
575    mod reference {
576        use super::super::{
577            ENTROPY_SPAN_MIN_BYTES, SECRET_ENTROPY_MIN, Work, charset_admits,
578            entropy_millibits_per_char, token_byte,
579        };
580        use crate::consts_external::SECRET_SHAPES;
581        use crate::secret::hex_run::{HEX_ENTROPY_MIN_MILLIBITS, HEX_SPAN_MIN_BYTES, label_before};
582        use crate::secret::names_a_credential;
583        use crate::secret::span::SecretSpan;
584
585        fn count(work: &mut Work, bytes: usize) {
586            work.bytes_read += bytes as u64;
587        }
588
589        fn entropy(work: &mut Work, run: &[u8]) -> u64 {
590            work.entropy_readings += 1;
591            count(work, run.len());
592            entropy_millibits_per_char(run)
593        }
594
595        fn mixed_alphabet(work: &mut Work, bytes: &[u8]) -> bool {
596            count(work, 3 * bytes.len());
597            let has_upper = bytes.iter().any(u8::is_ascii_uppercase);
598            let has_lower = bytes.iter().any(u8::is_ascii_lowercase);
599            let has_digit = bytes.iter().any(u8::is_ascii_digit);
600            has_upper && has_lower && has_digit
601        }
602
603        fn entropy_passes(work: &mut Work, bytes: &[u8]) -> bool {
604            let mb = entropy(work, bytes);
605            let num = u64::from(SECRET_ENTROPY_MIN.num);
606            let den = u64::from(SECRET_ENTROPY_MIN.den);
607            mb.saturating_mul(den) >= num.saturating_mul(1000)
608        }
609
610        fn overlaps(a: &SecretSpan, start: usize, len: usize) -> bool {
611            let a_end = a.start.saturating_add(a.len);
612            let b_end = start.saturating_add(len);
613            a.start < b_end && start < a_end
614        }
615
616        fn is_labelled_hex_secret(work: &mut Work, bytes: &[u8], start: usize, end: usize) -> bool {
617            let Some(run) = bytes.get(start..end) else {
618                return false;
619            };
620            run.len() >= HEX_SPAN_MIN_BYTES
621                && run.iter().all(u8::is_ascii_hexdigit)
622                && entropy(work, run) >= HEX_ENTROPY_MIN_MILLIBITS
623                && bytes
624                    .get(..start)
625                    .and_then(label_before)
626                    .is_some_and(names_a_credential)
627        }
628
629        fn find_shape_hits(work: &mut Work, bytes: &[u8]) -> Vec<SecretSpan> {
630            let mut hits = Vec::new();
631            for shape in &SECRET_SHAPES {
632                let prefix = shape.prefix.as_bytes();
633                if prefix.is_empty() || bytes.len() < prefix.len() {
634                    continue;
635                }
636                count(work, bytes.len());
637                let mut at = 0usize;
638                while let Some(window) = bytes.get(at..) {
639                    let Some(rel) = window.windows(prefix.len()).position(|w| w == prefix) else {
640                        break;
641                    };
642                    let start = at + rel;
643                    let body_start = start + prefix.len();
644                    let mut end = body_start;
645                    while bytes
646                        .get(end)
647                        .is_some_and(|b| charset_admits(shape.charset, *b))
648                    {
649                        end += 1;
650                    }
651                    count(work, end - body_start);
652                    let total = end - start;
653                    let (min_len, max_len) = (usize::from(shape.len.0), usize::from(shape.len.1));
654                    if total >= min_len {
655                        hits.push(SecretSpan {
656                            start,
657                            len: total.min(max_len),
658                            provider: Some(shape.provider),
659                        });
660                    }
661                    at = body_start;
662                }
663            }
664            hits.sort_by_key(|h| (h.start, h.len));
665            hits
666        }
667
668        pub(super) fn scan(bytes: &[u8]) -> Vec<SecretSpan> {
669            scan_counting(bytes).0
670        }
671
672        pub(super) fn scan_counting(bytes: &[u8]) -> (Vec<SecretSpan>, Work) {
673            let mut work = Work::default();
674            let mut hits = find_shape_hits(&mut work, bytes);
675            let mut at = 0usize;
676            while at < bytes.len() {
677                if !bytes.get(at).copied().is_some_and(token_byte) {
678                    at += 1;
679                    continue;
680                }
681                let mut end = at;
682                while bytes.get(end).copied().is_some_and(token_byte) {
683                    end += 1;
684                }
685                let len = end - at;
686                count(&mut work, len);
687                if len >= ENTROPY_SPAN_MIN_BYTES
688                    && !hits.iter().any(|h| overlaps(h, at, len))
689                    && (bytes.get(at..end).is_some_and(|run| {
690                        mixed_alphabet(&mut work, run) && entropy_passes(&mut work, run)
691                    }) || is_labelled_hex_secret(&mut work, bytes, at, end))
692                {
693                    hits.push(SecretSpan {
694                        start: at,
695                        len,
696                        provider: None,
697                    });
698                }
699                at = end;
700            }
701            hits.sort_by_key(|h| (h.start, h.len));
702            (hits, work)
703        }
704    }
705
706    /// Text shaped like history: hashes and oids, a labelled key, a
707    /// provider token, a mixed token, prose, repeated `lines` times.
708    fn history(lines: usize) -> String {
709        let hex64 = "9f3c1a7e5b20d48c6f1ea7b3905d2ce81f64b07a".repeat(2);
710        let token = ["kJ8vQ2xR9m", "W4nZ7pL3sT", "6yB1cD5fG0", "hN8aE2iU4o"].concat();
711        let line = format!(
712            "{{\"prev\":\"{hex64}\",\"oid\":\"{}\",\"HMAC_KEY\":\"{}\",\"note\":\"see sk-ant-{} and {token} for the run\"}}\n",
713            &hex64[..40],
714            &hex64[3..43],
715            "a1B2c3D4e5".repeat(4)
716        );
717        line.repeat(lines)
718    }
719
720    /// The work a scan does grows no faster than its input, and is less
721    /// than the reference's at every size: the growth a slower scanner
722    /// would bring back is seen here as a count, not as a timing.
723    #[test]
724    fn scan_work_is_linear_and_below_the_reference() {
725        let sizes = [64usize, 128, 256];
726        let mut readings = Vec::new();
727        for lines in sizes {
728            let text = history(lines);
729            let (spans, work) = scan_counting(text.as_bytes());
730            let (expected, before) = reference::scan_counting(text.as_bytes());
731            assert_eq!(spans, expected);
732            assert!(
733                work.bytes_read < before.bytes_read,
734                "{lines}: {work:?} {before:?}"
735            );
736            assert!(
737                work.entropy_readings < before.entropy_readings,
738                "{lines}: {work:?} {before:?}"
739            );
740            readings.push(work);
741        }
742        let base = readings[0];
743        for (factor, work) in [(1u64, readings[0]), (2, readings[1]), (4, readings[2])] {
744            assert!(work.bytes_read <= factor * base.bytes_read, "{work:?}");
745            assert!(
746                work.entropy_readings <= factor * base.entropy_readings,
747                "{work:?}"
748            );
749        }
750    }
751}
752
753#[cfg(kani)]
754mod verification {
755    //! V5: the fixed-point logarithm terminates and never panics on any
756    //! `u64`. The loop bound is the constant ten, so CBMC derives it and
757    //! no global unwind is passed.
758    //!
759    //! The whole-function property stops here on purpose:
760    //! `entropy_millibits_per_char` calls `log2_q10` once per slot of a
761    //! 256-slot count table and each call squares a `u128` ten times, so
762    //! a harness over the table hands the solver some 2,560 symbolic
763    //! non-linear multiplications and returns no verdict. Totality of
764    //! the whole scan is held by the proptest above; proving it here
765    //! would need a per-slot function this module does not have
766    //! (`crates/kernel/Spec.lean` §2).
767
768    use super::*;
769
770    #[kani::proof]
771    fn log2_q10_is_total() {
772        let x: u64 = kani::any();
773        let _ = log2_q10(x);
774    }
775}