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
//! Regression: `--require-gpu` must FAIL CLOSED (exit 12) on the
//! no-GPU path, independent of backend routing (findings C0 / C1).
//!
//! Before the fix the require-GPU hard-fail only lived inside the
//! GPU-SELECTED dispatch paths. On a host with no discrete GPU, routing
//! degraded to SimdCpu, those paths were never reached, and the scan
//! completed on CPU exiting 0/1/10 instead of the documented exit 12 - the
//! literal `require-gpu-fails-closed|--require-gpu|...|12` docker
//! scenario (tests/docker/scenarios.sh) and the env.md contract
//! ("refuse to run when no usable GPU adapter is detected").
//!
//! The CLI now runs an explicit require-GPU preflight before any scan
//! (`keyhog_scanner::gpu::require_gpu_preflight`, wired in
//! `orchestrator::run`) that returns the documented `ExitCode` 12 through
//! the CLI - not a scanner-lib `process::exit` - so an embedder using the
//! library directly is never hard-killed (finding M12).
use std::path::PathBuf;
use std::process::Command;
use tempfile::TempDir;
fn binary() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_keyhog"))
}
/// Write a planted AWS credential to a temp file and return (dir, path).
/// The dir guard must stay alive for the scan to see the file.
fn aws_leak_fixture() -> (TempDir, PathBuf) {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("aws_leak.env");
// Split literal so this source file is not itself a self-flagging leak.
let fixture = concat!("AWS_ACCESS_KEY_ID = \"AKIA", "QYLPMN5HFIQR7XYA\"\n");
std::fs::write(&path, fixture).expect("write fixture");
(dir, path)
}
/// Deterministic, host-independent guard for the invalid contradiction:
/// the operator cannot both require and disable GPU init in one command.
/// clap must reject that before routing or scanning.
#[test]
fn require_gpu_and_no_gpu_flags_conflict() {
let (_dir, path) = aws_leak_fixture();
let output = Command::new(binary())
.args(["scan", "--require-gpu", "--no-gpu"])
.arg(&path)
.output()
.expect("spawn keyhog scan");
assert_eq!(
output.status.code(),
Some(2),
"--require-gpu and --no-gpu must be rejected as conflicting flags; stderr={}",
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("--require-gpu") && stderr.contains("--no-gpu"),
"conflict diagnostic should name both GPU policy flags; stderr={stderr}"
);
}
/// Detect whether this host reports a usable (non-software) GPU by reading
/// the `keyhog backend` hardware report. Used to gate the natural docker
/// scenario below so it asserts the strict contract only on the no-GPU
/// hosts (CI runners, the docker test image) the flag targets.
fn host_has_usable_gpu() -> bool {
let out = Command::new(binary())
.arg("backend")
.output()
.expect("spawn keyhog backend");
let stdout = String::from_utf8_lossy(&out.stdout);
let gpu_line = stdout
.lines()
.find(|l| l.trim_start().starts_with("gpu:"))
.unwrap_or("");
!gpu_line.contains("not detected") && !gpu_line.contains("software renderer")
}
/// The literal docker scenario: `keyhog scan --require-gpu <leak>`
/// with nothing else set. On a no-GPU host (CI runners always set
/// CI=true/GITHUB_ACTIONS=true, which previously auto-skipped the GPU and
/// masked the requirement - finding C1) this must still exit 12. The
/// require flag keeps GPU probing open so the CI auto-skip cannot defeat the
/// gate. On a real GPU host the scan proceeds normally, so we only assert the
/// strict exit 12 when no usable GPU is detected.
#[test]
fn require_gpu_on_no_gpu_host_exits_twelve() {
if host_has_usable_gpu() {
// Real GPU present: the requirement is satisfiable, so the scan
// runs and exits on the finding (1) rather than the require gate.
// The fail-closed contract is exercised deterministically by
// `require_gpu_with_no_gpu_forced_exits_twelve` regardless.
return;
}
let (_dir, path) = aws_leak_fixture();
let output = Command::new(binary())
.args(["scan", "--require-gpu"])
.arg(&path)
.output()
.expect("spawn keyhog scan");
assert_eq!(
output.status.code(),
Some(12),
"on a no-GPU host --require-gpu must fail closed with exit 12 \
(the CI auto-skip must not mask the requirement); stderr={}",
String::from_utf8_lossy(&output.stderr)
);
}
/// The refusal names every way GPU became required, not just the flag.
///
/// An explicit `--backend gpu-cuda` also resolves the policy to required, and
/// the message used to say only "--require-gpu requested ... run without
/// --require-gpu". An operator who never passed that flag was sent looking for
/// it. Both routes now produce a message naming the condition and both exits
/// from it.
#[test]
fn the_gpu_refusal_names_every_route_that_required_it() {
if host_has_usable_gpu() {
return;
}
for demand in [
vec!["scan", "--no-config", "--daemon=off", "--require-gpu"],
vec![
"scan",
"--no-config",
"--daemon=off",
"--backend",
"gpu-cuda",
],
] {
let (_dir, path) = aws_leak_fixture();
let output = Command::new(binary())
.args(&demand)
.arg(&path)
.output()
.expect("spawn keyhog scan");
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(
output.status.code(),
Some(12),
"{demand:?} must fail closed; stderr={stderr}"
);
assert!(
stderr.contains("required by the resolved runtime policy")
&& stderr.contains("--backend gpu-cuda/gpu-metal/gpu-wgpu"),
"{demand:?} must name the condition and the explicit-backend route; stderr={stderr}"
);
assert!(
stderr.contains("drop --require-gpu and any explicit GPU --backend"),
"{demand:?} must tell the operator every way out; stderr={stderr}"
);
}
}