1use std::process::{Command, Output};
9use std::time::Instant;
10
11use camino::Utf8PathBuf;
12
13use crate::model::{
14 Check, Compatibility, ProbeReport, ProbeStep, ProbeStepKind, RunnerOs, Subject, SubjectKind,
15};
16
17pub const EXCERPT_LIMIT: usize = 4 * 1024;
18
19const DOCKER_BIN_ENV: &str = "GHA_CONTAINER_PROOF_DOCKER";
20
21#[derive(Debug, Clone)]
22pub struct ProbeInput {
23 pub image: String,
24 pub runner_os: RunnerOs,
25 pub tools: Vec<String>,
26 pub commands: Vec<String>,
27 pub allow_pull: bool,
28 pub docker_bin: Option<Utf8PathBuf>,
29}
30
31pub fn probe(input: &ProbeInput) -> Subject {
32 let mut subject = Subject::new(SubjectKind::DockerProbe);
33 subject.image = Some(input.image.clone());
34 subject.runner_os = Some(input.runner_os);
35 subject.requires_docker = true;
36 subject.requires_pull = input.allow_pull;
37
38 let mut report = ProbeReport::new();
39
40 let cli = resolve_docker_bin(input.docker_bin.as_deref());
41 let cli_path = match cli {
42 Some(path) => path,
43 None => {
44 subject.classification = Compatibility::Simulated;
45 subject.push(Check::skip(
46 "probe.docker_cli_not_found",
47 "docker CLI not found on PATH and no --docker-bin override; skipping every probe",
48 ));
49 for tool in &input.tools {
50 subject.push(Check::skip(
51 "probe.tool_skipped",
52 format!("tool `{tool}` skipped: docker CLI unavailable"),
53 ));
54 }
55 for command in &input.commands {
56 subject.push(Check::skip(
57 "probe.command_skipped",
58 format!("command `{command}` skipped: docker CLI unavailable"),
59 ));
60 }
61 subject.probe = Some(report);
62 return subject;
63 }
64 };
65 report.docker_cli_available = true;
66 report.docker_bin = Some(cli_path.to_string());
67
68 let inspect_step = run_docker(
70 &cli_path,
71 &["image", "inspect", &input.image],
72 ProbeStepKind::Inspect,
73 );
74 let mut image_available = false;
75
76 if let Some(spawn_err) = &inspect_step.spawn_error {
77 subject.classification = Compatibility::Unsupported;
78 subject.push(Check::fail(
79 "probe.docker_daemon_unreachable",
80 format!(
81 "could not invoke docker for image `{}`: {spawn_err}",
82 input.image
83 ),
84 ));
85 } else if inspect_step.success {
86 image_available = true;
87 subject.push(Check::pass(
88 "probe.image_inspect_ok",
89 format!("`docker image inspect {}` succeeded", input.image),
90 ));
91 } else {
92 let daemon = inspect_step
93 .stderr
94 .as_deref()
95 .map(|stderr| {
96 stderr
97 .to_ascii_lowercase()
98 .contains("cannot connect to the docker daemon")
99 })
100 .unwrap_or(false);
101 if daemon {
102 subject.classification = Compatibility::Unsupported;
103 subject.push(Check::fail(
104 "probe.docker_daemon_unreachable",
105 format!(
106 "`docker image inspect {}` could not reach the daemon",
107 input.image
108 ),
109 ));
110 } else {
111 subject.requires_pull = input.allow_pull;
112 if input.allow_pull {
113 subject.push(Check::warn(
114 "probe.image_pull_required",
115 format!(
116 "image `{}` not present locally; `--allow-pull` is set",
117 input.image
118 ),
119 ));
120 } else {
121 subject.classification = Compatibility::Unsupported;
122 subject.push(Check::fail(
123 "probe.image_inspect_missing",
124 format!(
125 "image `{}` not present locally and `--allow-pull` was not set",
126 input.image
127 ),
128 ));
129 }
130 }
131 }
132 report.inspect = Some(inspect_step);
133
134 if !image_available && input.allow_pull {
136 let pull_step = run_docker(&cli_path, &["pull", &input.image], ProbeStepKind::Pull);
137 if pull_step.success {
138 image_available = true;
139 subject.push(Check::pass(
140 "probe.image_pull_attempted",
141 format!("`docker pull {}` succeeded", input.image),
142 ));
143 } else if let Some(err) = &pull_step.spawn_error {
144 subject.push(Check::fail(
145 "probe.image_pull_attempted",
146 format!("`docker pull {}` could not spawn: {err}", input.image),
147 ));
148 } else {
149 subject.push(Check::fail(
150 "probe.image_pull_attempted",
151 format!(
152 "`docker pull {}` exited {}",
153 input.image,
154 pull_step.exit_code.unwrap_or(-1)
155 ),
156 ));
157 }
158 report.commands.push(pull_step);
159 }
160
161 let can_run = image_available
162 && subject
163 .checks
164 .iter()
165 .all(|check| check.id != "probe.docker_daemon_unreachable");
166
167 for tool in &input.tools {
169 if !can_run {
170 subject.push(Check::skip(
171 "probe.tool_skipped",
172 format!("tool `{tool}` skipped: image not available"),
173 ));
174 continue;
175 }
176 let step = run_docker(
177 &cli_path,
178 &["run", "--rm", &input.image, tool, "--version"],
179 ProbeStepKind::Tool,
180 );
181 if let Some(err) = &step.spawn_error {
182 subject.push(Check::fail(
183 "probe.tool_spawn_failed",
184 format!(
185 "`docker run --rm {} {} --version` could not spawn: {err}",
186 input.image, tool
187 ),
188 ));
189 } else if step.success {
190 subject.push(Check::pass(
191 "probe.tool_ok",
192 format!("`{tool} --version` succeeded in `{}`", input.image),
193 ));
194 } else {
195 subject.push(Check::fail(
196 "probe.tool_exit_nonzero",
197 format!(
198 "`{tool} --version` exited {} in `{}`",
199 step.exit_code.unwrap_or(-1),
200 input.image
201 ),
202 ));
203 }
204 report.tools.push(step);
205 }
206
207 for command in &input.commands {
209 if !can_run {
210 subject.push(Check::skip(
211 "probe.command_skipped",
212 format!("command `{command}` skipped: image not available"),
213 ));
214 continue;
215 }
216 let step = run_docker(
217 &cli_path,
218 &["run", "--rm", &input.image, "sh", "-c", command],
219 ProbeStepKind::Command,
220 );
221 if let Some(err) = &step.spawn_error {
222 subject.push(Check::fail(
223 "probe.command_spawn_failed",
224 format!(
225 "`docker run --rm {} sh -c {command}` could not spawn: {err}",
226 input.image
227 ),
228 ));
229 } else if step.success {
230 subject.push(Check::pass(
231 "probe.command_ok",
232 format!("command `{command}` succeeded in `{}`", input.image),
233 ));
234 } else {
235 subject.push(Check::fail(
236 "probe.command_exit_nonzero",
237 format!(
238 "command `{command}` exited {} in `{}`",
239 step.exit_code.unwrap_or(-1),
240 input.image
241 ),
242 ));
243 }
244 report.commands.push(step);
245 }
246
247 subject.probe = Some(report);
248 subject
249}
250
251fn resolve_docker_bin(override_path: Option<&camino::Utf8Path>) -> Option<Utf8PathBuf> {
252 if let Some(path) = override_path {
253 return Some(path.to_owned());
254 }
255 if let Ok(value) = std::env::var(DOCKER_BIN_ENV) {
256 if !value.trim().is_empty() {
257 return Some(Utf8PathBuf::from(value));
258 }
259 }
260 let found = which::which("docker").ok()?;
261 Utf8PathBuf::from_path_buf(found).ok()
262}
263
264fn run_docker(bin: &camino::Utf8Path, argv: &[&str], kind: ProbeStepKind) -> ProbeStep {
265 let display = format!("{} {}", bin, argv.join(" "));
266 let start = Instant::now();
267 let result = Command::new(bin.as_str()).args(argv).output();
268 let elapsed_ms = start.elapsed().as_millis();
269 match result {
270 Ok(output) => probe_step_from_output(kind, display, output, elapsed_ms),
271 Err(err) => ProbeStep {
272 kind,
273 command: display,
274 success: false,
275 exit_code: None,
276 elapsed_ms,
277 stdout: None,
278 stderr: None,
279 spawn_error: Some(err.to_string()),
280 },
281 }
282}
283
284fn probe_step_from_output(
285 kind: ProbeStepKind,
286 command: String,
287 output: Output,
288 elapsed_ms: u128,
289) -> ProbeStep {
290 let stdout = excerpt(&String::from_utf8_lossy(&output.stdout));
291 let stderr = excerpt(&String::from_utf8_lossy(&output.stderr));
292 ProbeStep {
293 kind,
294 command,
295 success: output.status.success(),
296 exit_code: output.status.code(),
297 elapsed_ms,
298 stdout: if stdout.is_empty() {
299 None
300 } else {
301 Some(stdout)
302 },
303 stderr: if stderr.is_empty() {
304 None
305 } else {
306 Some(stderr)
307 },
308 spawn_error: None,
309 }
310}
311
312pub fn excerpt(text: &str) -> String {
313 if text.len() <= EXCERPT_LIMIT {
314 return text.to_owned();
315 }
316 let mut out = text[..EXCERPT_LIMIT].to_owned();
317 out.push_str("…<truncated>");
318 out
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324
325 #[test]
326 fn missing_docker_skips_probes() {
327 let input = ProbeInput {
328 image: "alpine:3".to_owned(),
329 runner_os: RunnerOs::Linux,
330 tools: vec!["sh".to_owned()],
331 commands: vec!["echo hi".to_owned()],
332 allow_pull: false,
333 docker_bin: Some(Utf8PathBuf::from("/nonexistent/docker")),
334 };
335 let mut subject = probe(&input);
336 subject.finalize();
337 assert!(
340 subject
341 .checks
342 .iter()
343 .any(|c| c.id == "probe.docker_daemon_unreachable"
344 || c.id == "probe.docker_cli_not_found")
345 );
346 }
347
348 #[test]
349 fn excerpt_truncates_long_text() {
350 let big = "a".repeat(EXCERPT_LIMIT + 100);
351 let trimmed = excerpt(&big);
352 assert!(trimmed.ends_with("…<truncated>"));
353 assert!(trimmed.len() < big.len());
354 }
355}