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
//! close-coherence: VERIFY-BLOCK COUNT doc↔corpus coherence.
//!
//! Docs intentionally avoid copied detector counts. This gate proves every
//! shipped `[detector.verify]` block has a URL and rejects stale numeric claims
//! on the user-facing surfaces.
use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::TempDir;
/// The keyhog binary under test (injected by Cargo for integration tests).
fn binary() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_keyhog"))
}
/// Run `keyhog scan <extra...> <clean-file>` offline and return the exit code.
fn scan_clean(extra: &[&str]) -> Option<i32> {
let dir = TempDir::new().unwrap();
let path = dir.path().join("clean.txt");
std::fs::write(&path, "clean prose, no secrets here\n").unwrap();
// Pin an explicit backend: a default `scan` resolves the backend from the
// persisted autoroute cache and FAILS CLOSED (exit 2) when no decision exists
// (Law 10, it never guesses). This test only checks that the --verify-rate /
// --verify-batch flags parse and exit 0 on a clean file; it must not depend on
// an ambient ~/.cache/keyhog/autoroute.json that exists on a dev box but never
// on a fresh CI runner. `--backend cpu` bypasses autoroute deterministically.
let mut args: Vec<&str> = vec!["scan", "--daemon=off", "--backend", "cpu"];
args.extend_from_slice(extra);
let p = path.to_string_lossy().into_owned();
args.push(&p);
let out = Command::new(binary())
.args(&args)
.output()
.expect("spawn keyhog");
let code = out.status.code();
if code != Some(0) {
eprintln!("Command failed: keyhog scan {extra:?}");
eprintln!("STDOUT:\n{}", String::from_utf8_lossy(&out.stdout));
eprintln!("STDERR:\n{}", String::from_utf8_lossy(&out.stderr));
}
code
}
/// Repo root: `crates/cli/../..`.
fn repo_root() -> PathBuf {
let mut d = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
d.pop();
d.pop();
d
}
/// Count detector TOMLs whose body contains a top-level `[detector.verify]`
/// header, and the subset of those whose verify block carries a `url` line.
/// Returns `(total_detectors, with_verify_header, with_verify_url)`.
fn count_verify_corpus(detectors: &Path) -> (usize, usize, usize) {
let mut total = 0usize;
let mut with_verify = 0usize;
let mut with_verify_url = 0usize;
let mut entries: Vec<PathBuf> = std::fs::read_dir(detectors)
.expect("detectors/ dir readable")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "toml"))
.collect();
entries.sort();
for path in entries {
total += 1;
let src = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("read detector {}: {e}", path.display()));
// Find the `[detector.verify]` section and check the lines that follow
// (until the next `[` section header) for a `url` key.
let mut in_verify = false;
let mut has_header = false;
let mut has_url = false;
for line in src.lines() {
let t = line.trim();
if let Some(rest) = t.strip_prefix('[') {
// A new section header.
let header = rest.trim_end_matches(']').trim();
in_verify = header == "detector.verify";
if in_verify {
has_header = true;
}
continue;
}
if in_verify {
let key = t.split('=').next().map(str::trim).unwrap_or("");
if key == "url" {
has_url = true;
}
}
}
if has_header {
with_verify += 1;
}
if has_header && has_url {
with_verify_url += 1;
}
}
(total, with_verify, with_verify_url)
}
/// The corpus is the source of truth: every detector advertising a verify block
/// must carry a URL (otherwise the count is dishonest, a block with no URL is
/// `unverifiable`, not a live endpoint).
#[test]
fn every_verify_block_has_a_url() {
let root = repo_root();
let (_total, with_verify, with_verify_url) = count_verify_corpus(&root.join("detectors"));
assert_eq!(
with_verify_url, with_verify,
"every `[detector.verify]` block must carry a `url` line, a verify block without a \
URL is `unverifiable`, not a live endpoint, so counting it as one is dishonest. \
{} of {with_verify} verify blocks have a url.",
with_verify_url
);
}
/// User docs must derive corpus counts from the installed binary, not copy them.
#[test]
fn user_docs_do_not_pin_detector_counts() {
let root = repo_root();
let readme = std::fs::read_to_string(root.join("README.md")).expect("README.md");
let doc =
std::fs::read_to_string(root.join("docs/src/verification.md")).expect("verification.md");
assert!(
!readme.contains("detectors carry an active `[detector.verify]` endpoint")
&& doc.contains("keyhog detectors --format json"),
"docs must query the installed corpus instead of copying a detector count"
);
}
/// verification.md's "Rate limits" section must quote the REAL default verify
/// rate. The verifier's process-wide limiter defaults to 5 rps (a 200 ms gap),
/// and the `--verify-rate` flag's clap default is `5.0`. A prior doc said
/// "100 ms gap" (which would be 10 rps), wrong. This pins the corrected prose so
/// it can't drift back: the doc must say "5 requests/second" and "200 ms", and
/// must NOT carry the stale "100 ms gap".
#[test]
fn verification_doc_rate_limit_matches_default_rps() {
let root = repo_root();
let doc =
std::fs::read_to_string(root.join("docs/src/verification.md")).expect("verification.md");
assert!(
doc.contains("5 requests/second"),
"verification.md Rate-limits section must state the real default of 5 requests/second \
(the verifier limiter and `--verify-rate` default are both 5.0 rps)."
);
assert!(
doc.contains("200 ms"),
"verification.md must state the real 200 ms inter-call gap (1s / 5 rps = 200 ms), \
not the stale 100 ms."
);
assert!(
!doc.contains("100 ms gap"),
"verification.md still carries the stale \"100 ms gap\" claim; 5 rps is a 200 ms gap."
);
// The doc must mention the two flags that tune this, they are real `scan`
// flags, so documenting them closes the wiring gap.
for flag in ["--verify-rate", "--verify-batch"] {
assert!(
doc.contains(flag),
"verification.md Rate-limits section must mention the real `{flag}` flag that \
controls verification rate limiting."
);
}
}
/// The `--verify-rate <RPS>` and `--verify-batch` flags verification.md now
/// advertises must be REAL `scan` flags: passing each on a clean file exits 0,
/// never exit 2 (clap unknown-flag). Drives the real binary so a doc claim about
/// a flag can never outrun the flag's existence.
#[test]
fn verify_rate_and_batch_flags_are_accepted() {
// `--verify-batch` requires `--verify`; on a clean file there is nothing to
// verify, so the scan stays offline and exits 0.
for extra in [
vec!["--verify-rate", "2.5", "--verify"],
vec!["--verify-batch", "--verify"],
] {
let code = scan_clean(&extra);
assert_eq!(
code,
Some(0),
"`keyhog scan {extra:?}` on a clean file must exit 0 (the flag is a real, \
documented `scan` flag in docs/src/verification.md); got {code:?}"
);
}
}