keyhog 0.5.86

GPU-accelerated secret scanner for code, Git history, cloud, containers, browser assets, and live credential verification
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
//! CLI value parsers for typed command-line options.

use clap::ValueEnum;

/// Build a uniform "unparseable typed value" rejection. clap already prefixes
/// the offending input and the flag name (`invalid value '<got>' for
/// '<--flag>'`), so this states only what was *expected*: the accepted
/// range/form plus a concrete valid example. That turns a bare "not a valid
/// number", which leaves the user guessing the bounds, into a message that is
/// itself the fix. Centralizing the wording keeps every numeric parser's
/// parse-failure branch consistent instead of drifting across a dozen
/// hand-written strings.
fn unparseable(kind: &str, accepted: &str, example: &str) -> String {
    format!("not a valid {kind}. Expected {accepted}; example: {example}")
}

/// Build a uniform "parsed, but out of range" rejection: it names the violated
/// bound and a concrete in-range example so the message states the fix, not
/// just the constraint. Used by the lower-bound (`>= 1`) parsers.
fn out_of_range(constraint: &str, example: &str) -> String {
    format!("{constraint}; example: {example}")
}

/// Accepted-form phrase shared by every `>= 1` integer parser's parse-failure
/// message. One owner so the wording cannot drift between them.
const POSITIVE_INTEGER_ACCEPTED: &str = "a positive integer (>= 1)";

/// Shared "parse a `>= 1` integer, reject 0" body for the positive count/timeout
/// knobs. `T: FromStr + Default + PartialEq` covers both the `usize` and `u64`
/// callers (`Default` is `0` for every integer type, so `val == T::default()` is
/// the zero test). Each wrapper supplies only its parse-failure `accepted` phrase,
/// its out-of-range `constraint` phrase (which names its own flag/unit), and a
/// concrete `example`: the parse + zero-check now has exactly ONE home instead of
/// six byte-identical copies. Mirrors `parse_unit_interval` for the `[0,1]` knobs.
fn parse_positive_int<T>(
    s: &str,
    accepted: &str,
    constraint: &str,
    example: &str,
) -> Result<T, String>
where
    T: std::str::FromStr + Default + PartialEq,
{
    let val: T = s
        .parse()
        .map_err(|_| unparseable("integer", accepted, example))?;
    if val == T::default() {
        Err(out_of_range(constraint, example))
    } else {
        Ok(val)
    }
}

/// Shared parser for the `[0.0, 1.0]` closed-interval "confidence-style" knobs
/// (`min_confidence`, `ml_weight`) that are configurable on BOTH surfaces, the
/// CLI flag AND the `.keyhog.toml` key. `key_label` is the BARE key name (no
/// `--`) so the single message reads correctly on either surface (the config
/// merge reuses these validators via `parse_config_*`). NaN and ±Inf are
/// rejected because `RangeInclusive::contains` is `false` for every non-finite
/// value, so a poisoned score can never slip through as "always/never passes"
/// (the CLI-003 class of bug). One definitional home for the parse + bound +
/// non-finite handling; each per-knob function only supplies its key and example.
fn parse_unit_interval(s: &str, key_label: &str, example: &str) -> Result<f64, String> {
    let val: f64 = s
        .parse()
        .map_err(|_| unparseable("decimal", "a value in [0.0, 1.0]", example))?;
    if (0.0..=1.0).contains(&val) {
        Ok(val)
    } else {
        Err(out_of_range(
            &format!("{key_label} must be between 0.0 and 1.0"),
            example,
        ))
    }
}

pub(crate) fn parse_min_confidence(s: &str) -> Result<f64, String> {
    parse_unit_interval(s, "min_confidence", "0.85")
}

/// `--ml-weight W` / config `ml_weight`: an explicit scan-wide override for
/// detector-local model blend weights, as a finite f64 in `[0.0, 1.0]`. A value
/// above 1.0 over-weights the model and a negative one inverts it; NaN would
/// silently poison every confidence. Shares
/// `parse_unit_interval` with `min_confidence` and (like it) is reused for the
/// `.keyhog.toml` `ml_weight` key via `parse_config_ml_weight`, which is why the
/// message names the bare key rather than the `--ml-weight` flag.
pub(crate) fn parse_ml_weight(s: &str) -> Result<f64, String> {
    parse_unit_interval(s, "ml_weight", "0.5")
}

/// `--entropy-bpe-max-bytes-per-token RATIO`: a finite, strictly positive
/// bytes-per-token ceiling. Large finite values are intentionally valid because
/// they are the documented way to disable the precision gate for a scan. Keep
/// this parser shared with `.keyhog.toml` so neither input surface can silently
/// normalize an invalid operator request into a different policy.
pub(crate) fn parse_entropy_bpe_max_bytes_per_token(s: &str) -> Result<f64, String> {
    let value: f64 = s
        .parse()
        .map_err(|_| unparseable("decimal", "a finite value greater than 0.0", "2.2"))?;
    if value.is_finite() && value > 0.0 {
        Ok(value)
    } else {
        Err(out_of_range(
            "entropy_bpe_max_bytes_per_token must be finite and greater than 0.0",
            "2.2",
        ))
    }
}

/// `--entropy-threshold BITS`: Shannon entropy over bytes is mathematically
/// bounded to `[0, 8]`. Reject non-finite and out-of-range operator input at the
/// boundary instead of letting the defensive scanner sanitizer silently replace
/// or clamp it to a different policy.
pub(crate) fn parse_entropy_threshold(s: &str) -> Result<f64, String> {
    let value: f64 = s
        .parse()
        .map_err(|_| unparseable("decimal", "a value in [0.0, 8.0]", "4.5"))?;
    if (0.0..=8.0).contains(&value) {
        Ok(value)
    } else {
        Err(out_of_range(
            "entropy_threshold must be a finite value between 0.0 and 8.0",
            "4.5",
        ))
    }
}

/// `--verify-rate RPS`: must be finite, > 0, and <= 10_000 (a sanity
/// cap that comfortably covers every real-world API; rejects accidental
/// `--verify-rate 1e308` typos that would otherwise be silently clamped
/// to 1 rps deep inside the limiter).
pub(crate) fn parse_verify_rate(s: &str) -> Result<f64, String> {
    let val: f64 = s
        .parse()
        .map_err(|_| unparseable("number", "a positive rate in (0, 10000] rps", "50"))?;
    if !val.is_finite() {
        return Err(format!("--verify-rate must be a finite number, got {val}"));
    }
    if val <= 0.0 {
        return Err(format!(
            "--verify-rate must be > 0 rps, got {val} \
             (use --no-verify to disable verification entirely)"
        ));
    }
    if val > 10_000.0 {
        return Err(format!(
            "--verify-rate {val} exceeds the 10_000 rps sanity cap; \
             no real provider permits that rate from a single IP"
        ));
    }
    Ok(val)
}

/// `--ml-threshold T`: must be a finite f64 in `[0.0, 1.0]`. NaN
/// silently becoming "every prediction passes" was the CLI-003 bug.
pub(crate) fn parse_ml_threshold(s: &str) -> Result<f64, String> {
    let val: f64 = s
        .parse()
        .map_err(|_| unparseable("decimal", "a value in [0.0, 1.0]", "0.5"))?;
    if !val.is_finite() {
        return Err(format!(
            "--ml-threshold must be a finite number (no NaN/Inf), got {val}"
        ));
    }
    if !(0.0..=1.0).contains(&val) {
        return Err(out_of_range(
            "--ml-threshold must be between 0.0 and 1.0",
            "0.5",
        ));
    }
    Ok(val)
}

pub(crate) fn parse_decode_depth(s: &str) -> Result<usize, String> {
    let limit = keyhog_core::max_decode_depth_limit();
    let val: usize = s
        .parse()
        .map_err(|_| unparseable("integer", &format!("an integer in [1, {limit}]"), "3"))?;
    if (1..=limit).contains(&val) {
        Ok(val)
    } else {
        Err(out_of_range(
            &format!("decode depth must be between 1 and {limit}"),
            "3",
        ))
    }
}

pub(crate) fn parse_min_secret_len(s: &str) -> Result<usize, String> {
    parse_positive_int(
        s,
        POSITIVE_INTEGER_ACCEPTED,
        "--min-secret-len must be >= 1",
        "16",
    )
}

pub(crate) fn parse_positive_thread_count(s: &str) -> Result<usize, String> {
    parse_positive_int(s, POSITIVE_INTEGER_ACCEPTED, "--threads must be >= 1", "4")
}

// Gate = the UNION of every caller's feature (args/limits.rs): `limit_git_chunks`
// (git), `limit_cloud_max_objects` (s3/gcs/azure), `limit_hosted_git_pages`
// (github/gitlab/bitbucket). Gating this to `git` alone made `--features s3` /
// `--features github` (no git) fail with `parse_positive_limit_count` not found
// the value-parser is referenced by their clap args but was cfg'd out.
#[cfg(any(
    feature = "git",
    feature = "s3",
    feature = "gcs",
    feature = "azure",
    feature = "github",
    feature = "gitlab",
    feature = "bitbucket"
))]
pub(crate) fn parse_positive_limit_count(s: &str) -> Result<usize, String> {
    parse_positive_int(
        s,
        POSITIVE_INTEGER_ACCEPTED,
        "limit count must be >= 1",
        "100",
    )
}

pub(crate) fn parse_positive_usize(s: &str) -> Result<usize, String> {
    parse_positive_int(s, POSITIVE_INTEGER_ACCEPTED, "value must be >= 1", "1")
}

pub(crate) fn parse_daemon_request_timeout_secs(s: &str) -> Result<u64, String> {
    parse_positive_int(
        s,
        "a positive number of seconds (>= 1)",
        "--request-timeout-secs must be >= 1",
        "30",
    )
}

pub(crate) fn parse_positive_millis(s: &str) -> Result<u64, String> {
    parse_positive_int(
        s,
        "a positive number of milliseconds (>= 1)",
        "millisecond timeout must be >= 1",
        "500",
    )
}

pub(crate) fn parse_byte_size(s: &str) -> Result<usize, String> {
    let trimmed = s.trim();
    // Empty input keeps the historical Ok(0) contract - clap callers
    // that accept an optional size flag rely on it. Only inputs that
    // are POSITIVELY malformed (bare numbers, overflow, bad unit)
    // should error.
    if trimmed.is_empty() {
        return Ok(0);
    }
    let split_idx = trimmed
        .find(|c: char| !c.is_ascii_digit() && c != '.')
        .unwrap_or(trimmed.len()); // LAW10: search/boundary miss => span end (whole remainder), recall-safe boundary default
    let (num_part, suffix) = trimmed.split_at(split_idx);

    let suffix_upper = suffix.trim().to_ascii_uppercase();
    let multiplier: u64 = match suffix_upper.as_str() {
        "" => {
            // Bare numbers like "10" are ambiguous with the GB-scale
            // defaults the rest of the CLI uses (`50G`). The test
            // fixtures explicitly assert this must error rather than
            // silently mean bytes.
            return Err(format!(
                "byte size '{trimmed}' is missing a unit. Use `B`, `K`/`KB`, `M`/`MB`, `G`/`GB`, or `T`/`TB`."
            ));
        }
        "B" => 1,
        "K" | "KB" | "KIB" => 1024,
        "M" | "MB" | "MIB" => 1024 * 1024,
        "G" | "GB" | "GIB" => 1024 * 1024 * 1024,
        "T" | "TB" | "TIB" => 1024_u64.pow(4),
        other => {
            return Err(format!(
                "unknown size suffix '{other}'. Supported: B, K/KB, M/MB, G/GB, T/TB"
            ));
        }
    };

    // Parse the number. Try integer first (most common, lossless,
    // overflows cleanly to Err on numbers wider than u64). Fall back
    // to f64 for fractional inputs like "1.5G".
    if let Ok(n_int) = num_part.parse::<u64>() {
        // LAW10: optional exact integer path; fractional inputs are validated by the finite f64 path below.
        // Overflow-safe integer multiply. The previous `as usize`
        // path silently saturated to usize::MAX for `u64::MAX B`,
        // which the test fixtures explicitly assert must error.
        let bytes = n_int.checked_mul(multiplier).ok_or_else(|| {
            format!(
                "byte size '{trimmed}' overflows u64 ({} * {} bytes)",
                n_int, multiplier
            )
        })?;
        // Sanity cap: real disk/RAM sizes are < 1 EiB even on the
        // largest known machines, and inputs beyond `usize::MAX / 2`
        // are almost certainly typos or attacks (the test fixtures
        // assert `u64::MAX B` must error, which it does at this gate).
        // Half of usize::MAX leaves headroom for downstream code that
        // adds offsets without overflow checks.
        let cap = usize::MAX / 2;
        if bytes as u128 > cap as u128 {
            return Err(format!(
                "byte size '{trimmed}' exceeds the {cap}-byte sanity cap"
            ));
        }
        usize::try_from(bytes).map_err(|_| {
            format!(
                "byte size '{trimmed}' overflows usize (max {} bytes on this platform)",
                usize::MAX
            )
        })
    } else {
        let n: f64 = num_part
            .parse()
            .map_err(|e| format!("bad number '{num_part}': {e}"))?;
        if !n.is_finite() || n < 0.0 {
            return Err(format!(
                "byte size must be a finite, non-negative number, got: {num_part}"
            ));
        }
        let bytes_f = n * multiplier as f64;
        // f64 can't represent usize::MAX exactly on 64-bit (rounds up
        // to 2^64), so the strict ceiling for a safe `as usize` cast
        // is `bytes_f < 2^64`.
        let max_safe = 2.0_f64.powi(64);
        if !bytes_f.is_finite() || bytes_f < 0.0 || bytes_f >= max_safe {
            return Err(format!(
                "byte size '{trimmed}' overflows usize (max {} bytes)",
                usize::MAX
            ));
        }
        Ok(bytes_f as usize)
    }
}

/// `--decode-size-limit SIZE`: must be a non-empty byte size >= 4B.
/// Rejects empty input and values below 4 bytes with a named error and corrective example.
pub(crate) fn parse_decode_size_limit(s: &str) -> Result<usize, String> {
    let trimmed = s.trim();
    if trimmed.is_empty() {
        return Err(
            "decode size limit cannot be empty. Expected a non-zero byte size >= 4B; example: 512KB".to_string()
        );
    }
    let size = parse_byte_size(trimmed)?;
    if size < 4 {
        return Err(format!(
            "decode size limit '{trimmed}' is too small ({size} bytes). Minimum decode size is 4B; example: 512KB"
        ));
    }
    Ok(size)
}

/// `--window-overlap SIZE` / `[scan].window_overlap`: at least 1KB and strictly
/// less than the 1MB streaming window size, which the slicer asserts.
pub(crate) fn parse_window_overlap(s: &str) -> Result<usize, String> {
    let trimmed = s.trim();
    if trimmed.is_empty() {
        return Err(
            "window overlap cannot be empty. Minimum window overlap is 1KB; example: 128KB"
                .to_string(),
        );
    }
    let size = parse_byte_size(trimmed)?;
    if size < 1024 {
        return Err(format!(
            "window overlap '{trimmed}' is too small. Minimum window overlap is 1KB; example: 128KB"
        ));
    }
    if size >= keyhog_core::DEFAULT_WINDOW_SIZE_BYTES {
        return Err(format!(
            "window overlap '{trimmed}' must be strictly less than the 1MB window size; example: 128KB"
        ));
    }
    Ok(size)
}

/// Parse a clap value enum, including aliases, without allocating a normalized
/// copy of already canonical input.
fn parse_value_enum<T: ValueEnum>(value: &str) -> Option<T> {
    T::from_str(value, true).ok()
}

/// Build the config diagnostic from the canonical names owned by the enum.
pub(crate) fn value_enum_expected<T: ValueEnum>() -> String {
    let mut expected = String::from("expected one of ");
    for (index, variant) in T::value_variants().iter().enumerate() {
        let Some(possible) = variant.to_possible_value() else {
            panic!("ValueEnum variants used by config must have possible values");
        };
        if index != 0 {
            expected.push_str(", ");
        }
        expected.push_str(possible.get_name());
    }
    expected
}

/// Parse a severity string into the CLI filter enum. Shared by the flat
/// top-level `severity` field and the `[scan]` nested table in `.keyhog.toml`
/// so both surfaces accept identical spellings.
pub(crate) fn parse_severity_filter(s: &str) -> Option<crate::args::SeverityFilter> {
    parse_value_enum(s)
}

/// Parse an output-format string. Shared by the flat `format` field and `[scan]`.
pub(crate) fn parse_output_format(s: &str) -> Option<crate::args::OutputFormat> {
    parse_value_enum(s)
}

/// Parse a dedup-scope string. Shared by the flat `dedup` field and `[scan]`.
pub(crate) fn parse_dedup_scope(s: &str) -> Option<crate::args::CliDedupScope> {
    parse_value_enum(s)
}

#[cfg(test)]
#[path = "../tests/unit/value_parser_diagnostics.rs"]
mod tests;