1use semver::Version;
7use serde::Serialize;
8use serde_json::Value;
9
10use super::redact::{CommandPurpose, NativeProgram, RedactedOrigin, RedactedUrl};
11use super::report::{
12 Capability, CapabilityStatus, DiagnosticDetails, DiagnosticEvidence, DiagnosticReport,
13 DiagnosticStatus, Endpoint, EndpointScope, EndpointTransport, Privilege, RuntimeKind,
14};
15use super::runtime::{ProbeCommand, RuntimeAdapter};
16use crate::process::{CaptureLimits, CapturedOutput, CommandOutcome, CommandRunner, CommandSpec};
17
18const MINIMUM_DOCKER_VERSION: Version = Version::new(19, 3, 0);
19
20#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
22#[serde(rename_all = "kebab-case")]
23pub enum DockerContextKind {
24 Local,
25 Remote,
26 Rootless,
27 Desktop,
28 Unknown,
29}
30
31#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
32#[serde(rename_all = "lowercase")]
33pub enum DockerDaemonOs {
34 Linux,
35 Windows,
36 Unknown,
37}
38
39impl DockerDaemonOs {
40 fn from_native(value: &str) -> Self {
41 match value.trim().to_ascii_lowercase().as_str() {
42 "linux" => Self::Linux,
43 "windows" => Self::Windows,
44 _ => Self::Unknown,
45 }
46 }
47}
48
49#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
50#[serde(rename_all = "kebab-case")]
51pub enum DockerDaemonArchitecture {
52 Amd64,
53 Arm64,
54 Arm,
55 I386,
56 Ppc64le,
57 S390x,
58 Riscv64,
59 Unknown,
60}
61
62impl DockerDaemonArchitecture {
63 fn from_native(value: &str) -> Self {
64 match value.trim().to_ascii_lowercase().as_str() {
65 "amd64" | "x86_64" | "x86-64" => Self::Amd64,
66 "arm64" | "aarch64" => Self::Arm64,
67 value if value == "arm" || value.starts_with("armv") => Self::Arm,
68 "386" | "i386" | "x86" => Self::I386,
69 "ppc64le" => Self::Ppc64le,
70 "s390x" => Self::S390x,
71 "riscv64" => Self::Riscv64,
72 _ => Self::Unknown,
73 }
74 }
75}
76
77#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
80pub struct DockerDiagnosticDetails {
81 pub client_version: Option<String>,
82 pub server_version: Option<String>,
83 pub context_kind: Option<DockerContextKind>,
84 pub daemon_os: Option<DockerDaemonOs>,
85 pub daemon_architecture: Option<DockerDaemonArchitecture>,
86 pub rootless: Option<bool>,
87 pub desktop: Option<bool>,
88 pub registry_mirror_origins: Vec<RedactedOrigin>,
89}
90
91#[derive(Clone, PartialEq, Eq)]
97pub struct DockerContext {
98 pub name: Option<String>,
99 pub endpoint: Option<Endpoint>,
100 pub kind: DockerContextKind,
101 pub skip_tls_verify: bool,
102 pub has_tls_material: bool,
103 pub(crate) raw_endpoint: Option<String>,
104}
105
106impl std::fmt::Debug for DockerContext {
107 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108 formatter
109 .debug_struct("DockerContext")
110 .field("name", &self.name)
111 .field("endpoint", &self.endpoint)
112 .field("kind", &self.kind)
113 .field("skip_tls_verify", &self.skip_tls_verify)
114 .field("has_tls_material", &self.has_tls_material)
115 .field("raw_endpoint", &"[redacted]")
116 .finish()
117 }
118}
119
120impl DockerContext {
121 pub(crate) fn into_prune_parts(self) -> Option<DockerPruneParts> {
122 let endpoint = self.endpoint?;
123 Some(DockerPruneParts {
124 name: self.name?,
125 raw_endpoint: self.raw_endpoint?,
126 transport: endpoint.transport,
127 scope: endpoint.scope,
128 skip_tls_verify: self.skip_tls_verify,
129 has_tls_material: self.has_tls_material,
130 })
131 }
132}
133
134pub(crate) struct DockerPruneParts {
135 pub name: String,
136 pub raw_endpoint: String,
137 pub transport: EndpointTransport,
138 pub scope: EndpointScope,
139 pub skip_tls_verify: bool,
140 pub has_tls_material: bool,
141}
142
143#[derive(Clone, Debug, Default, PartialEq, Eq)]
145pub struct DockerVersion {
146 pub client: Option<Version>,
147 pub server: Option<Version>,
148}
149
150#[derive(Clone, Debug, Default, PartialEq, Eq)]
152pub struct DockerInfo {
153 pub operating_system: Option<String>,
154 pub os_type: Option<String>,
155 pub architecture: Option<String>,
156 pub rootless: Option<bool>,
157 pub desktop: Option<bool>,
158 pub registry_mirrors: Vec<RedactedUrl>,
159}
160
161#[derive(Clone, Debug, PartialEq, Eq)]
163pub struct DockerDiscovery {
164 pub status: DiagnosticStatus,
165 pub context: Option<DockerContext>,
166 pub version: Option<DockerVersion>,
167 pub info: Option<DockerInfo>,
168}
169
170#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
172pub enum DockerParseError {
173 #[error("invalid Docker JSON output")]
174 InvalidJson,
175 #[error("Docker output did not contain the expected object")]
176 MissingObject,
177}
178
179#[derive(Clone, Copy, Debug, Default)]
181pub struct DockerAdapter;
182
183impl DockerAdapter {
184 pub fn discover(&self, runner: &dyn CommandRunner, limits: CaptureLimits) -> DockerDiscovery {
185 let version_probe = ProbeCommand::new(
186 NativeProgram::Docker,
187 CommandPurpose::Version,
188 CommandSpec::new("docker").args(["version", "--format", "{{json .}}"]),
189 );
190 let version_outcome = version_probe.execute(runner, limits);
191
192 if let Some(status) = spawn_status(&version_outcome) {
193 return DockerDiscovery {
194 status,
195 context: None,
196 version: None,
197 info: None,
198 };
199 }
200
201 let version = successful_output(&version_outcome)
202 .and_then(|output| parse_docker_version(&output.stdout).ok());
203 let version_failure = classify_outcome(&version_outcome);
204 if version.as_ref().is_some_and(|version| {
205 [&version.client, &version.server]
206 .into_iter()
207 .flatten()
208 .any(|version| version < &MINIMUM_DOCKER_VERSION)
209 }) || output_contains(&version_outcome, &unsupported_version_markers())
210 {
211 return DockerDiscovery {
212 status: DiagnosticStatus::UnsupportedVersion,
213 context: None,
214 version,
215 info: None,
216 };
217 }
218
219 let context_outcome = ProbeCommand::new(
220 NativeProgram::Docker,
221 CommandPurpose::ContextInspect,
222 CommandSpec::new("docker").args(["context", "inspect"]),
223 )
224 .execute(runner, limits);
225 let mut context = successful_output(&context_outcome)
226 .and_then(|output| parse_docker_context(&output.stdout).ok());
227
228 let info_outcome = ProbeCommand::new(
229 NativeProgram::Docker,
230 CommandPurpose::RuntimeInfo,
231 CommandSpec::new("docker").args(["info", "--format", "{{json .}}"]),
232 )
233 .execute(runner, limits);
234 let info = successful_output(&info_outcome)
235 .and_then(|output| parse_docker_info(&output.stdout).ok());
236
237 if let Some(context) = context.as_mut() {
238 refine_context_kind(context, info.as_ref());
239 }
240
241 let has_client = version
242 .as_ref()
243 .is_some_and(|version| version.client.is_some());
244 let has_daemon = version
245 .as_ref()
246 .is_some_and(|version| version.server.is_some())
247 || info.is_some();
248 let failures = [
249 version_failure,
250 classify_outcome(&context_outcome),
251 classify_outcome(&info_outcome),
252 ];
253 let status = if failures.contains(&Some(DiagnosticStatus::PermissionDenied)) {
254 DiagnosticStatus::PermissionDenied
255 } else if !has_daemon && failures.contains(&Some(DiagnosticStatus::UnsupportedVersion)) {
256 DiagnosticStatus::UnsupportedVersion
257 } else if !has_daemon && failures.contains(&Some(DiagnosticStatus::Unreachable)) {
258 DiagnosticStatus::Unreachable
259 } else if has_daemon && info.is_some() {
260 if has_client && context.is_some() {
261 DiagnosticStatus::Healthy
262 } else {
263 DiagnosticStatus::Degraded
264 }
265 } else if has_daemon {
266 DiagnosticStatus::Degraded
267 } else if has_client {
268 DiagnosticStatus::ClientOnly
269 } else if failures.contains(&Some(DiagnosticStatus::Unreachable)) {
270 DiagnosticStatus::Unreachable
271 } else {
272 DiagnosticStatus::Degraded
273 };
274
275 DockerDiscovery {
276 status,
277 context,
278 version,
279 info,
280 }
281 }
282
283 fn report(&self, discovery: &DockerDiscovery) -> DiagnosticReport {
284 let mut report = DiagnosticReport::new(RuntimeKind::Docker, discovery.status);
285 report.set_details(DiagnosticDetails::Docker(DockerDiagnosticDetails {
286 client_version: discovery
287 .version
288 .as_ref()
289 .and_then(|version| version.client.as_ref().map(ToString::to_string)),
290 server_version: discovery
291 .version
292 .as_ref()
293 .and_then(|version| version.server.as_ref().map(ToString::to_string)),
294 context_kind: discovery.context.as_ref().map(|context| context.kind),
295 daemon_os: discovery
296 .info
297 .as_ref()
298 .and_then(|info| info.os_type.as_deref())
299 .map(DockerDaemonOs::from_native),
300 daemon_architecture: discovery
301 .info
302 .as_ref()
303 .and_then(|info| info.architecture.as_deref())
304 .map(DockerDaemonArchitecture::from_native),
305 rootless: discovery.info.as_ref().and_then(|info| info.rootless),
306 desktop: discovery.info.as_ref().and_then(|info| info.desktop),
307 registry_mirror_origins: discovery
308 .info
309 .as_ref()
310 .map(|info| {
311 info.registry_mirrors
312 .iter()
313 .cloned()
314 .map(RedactedOrigin::from)
315 .collect()
316 })
317 .unwrap_or_default(),
318 }));
319 let client = discovery
320 .version
321 .as_ref()
322 .is_some_and(|version| version.client.is_some());
323 let daemon = discovery
324 .version
325 .as_ref()
326 .is_some_and(|version| version.server.is_some())
327 || discovery.info.is_some();
328
329 report.set_capability(
330 Capability::Client,
331 if client {
332 CapabilityStatus::Supported
333 } else {
334 CapabilityStatus::Unavailable
335 },
336 );
337 for capability in [
338 Capability::Daemon,
339 Capability::RuntimeInfo,
340 Capability::Pull,
341 Capability::CacheStatus,
342 Capability::CachePrune,
343 Capability::RegistryMirrors,
344 ] {
345 report.set_capability(
346 capability,
347 if daemon {
348 CapabilityStatus::Supported
349 } else {
350 CapabilityStatus::Unavailable
351 },
352 );
353 }
354 report.set_capability(
357 Capability::RegistryHostMapping,
358 CapabilityStatus::Unsupported,
359 );
360
361 if let Some(context) = &discovery.context {
362 report.privilege = match context.kind {
363 DockerContextKind::Local
364 | DockerContextKind::Rootless
365 | DockerContextKind::Desktop => Privilege::CurrentUser,
366 DockerContextKind::Remote => Privilege::RemoteAdministrator,
367 DockerContextKind::Unknown => Privilege::Unknown,
368 };
369 if let Some(endpoint) = &context.endpoint {
370 report.add_endpoint(endpoint.clone());
371 }
372 }
373 report
374 }
375}
376
377impl RuntimeAdapter for DockerAdapter {
378 fn kind(&self) -> RuntimeKind {
379 RuntimeKind::Docker
380 }
381
382 fn diagnose(&self, runner: &dyn CommandRunner, limits: CaptureLimits) -> DiagnosticReport {
383 let discovery = self.discover(runner, limits);
384 let mut report = self.report(&discovery);
385 for (purpose, arguments) in [
386 (
387 CommandPurpose::Version,
388 vec!["version", "--format", "{{json .}}"],
389 ),
390 (CommandPurpose::ContextInspect, vec!["context", "inspect"]),
391 (
392 CommandPurpose::RuntimeInfo,
393 vec!["info", "--format", "{{json .}}"],
394 ),
395 ] {
396 if discovery.context.is_none()
397 && discovery.info.is_none()
398 && matches!(
399 discovery.status,
400 DiagnosticStatus::NotInstalled
401 | DiagnosticStatus::PermissionDenied
402 | DiagnosticStatus::UnsupportedVersion
403 )
404 && purpose != CommandPurpose::Version
405 {
406 continue;
407 }
408 let command = CommandSpec::new("docker").args(arguments);
409 let evidence =
410 super::redact::RedactedCommand::from_spec(NativeProgram::Docker, purpose, &command);
411 report.add_evidence(DiagnosticEvidence::Command(evidence));
412 }
413 report
414 }
415}
416
417pub fn parse_docker_version(output: &[u8]) -> Result<DockerVersion, DockerParseError> {
418 let value: Value = serde_json::from_slice(output).map_err(|_| DockerParseError::InvalidJson)?;
419 let object = value.as_object().ok_or(DockerParseError::MissingObject)?;
420 let client = object
421 .get("Client")
422 .and_then(|value| value.get("Version"))
423 .and_then(Value::as_str)
424 .and_then(super::parse_vendor_version);
425 let server = object
426 .get("Server")
427 .and_then(|value| value.get("Version"))
428 .and_then(Value::as_str)
429 .and_then(super::parse_vendor_version);
430 if client.is_none() && server.is_none() {
431 return Err(DockerParseError::MissingObject);
432 }
433 Ok(DockerVersion { client, server })
434}
435
436pub fn parse_docker_context(output: &[u8]) -> Result<DockerContext, DockerParseError> {
437 let value: Value = serde_json::from_slice(output).map_err(|_| DockerParseError::InvalidJson)?;
438 let object = match &value {
439 Value::Array(contexts) => contexts.first().and_then(Value::as_object),
440 Value::Object(object) => Some(object),
441 _ => None,
442 }
443 .ok_or(DockerParseError::MissingObject)?;
444
445 let name = object
446 .get("Name")
447 .and_then(Value::as_str)
448 .map(str::to_owned);
449 let metadata_desktop = object
450 .get("Metadata")
451 .and_then(Value::as_object)
452 .is_some_and(|metadata| {
453 metadata.values().any(|value| {
454 value
455 .as_str()
456 .is_some_and(|value| value.to_ascii_lowercase().contains("docker desktop"))
457 })
458 });
459 let docker_endpoint = object
460 .get("Endpoints")
461 .and_then(|value| value.get("docker"))
462 .and_then(Value::as_object);
463 let raw_endpoint = docker_endpoint
464 .and_then(|endpoint| endpoint.get("Host"))
465 .and_then(Value::as_str);
466 let skip_tls_verify = docker_endpoint
467 .and_then(|endpoint| endpoint.get("SkipTLSVerify"))
468 .and_then(Value::as_bool)
469 .unwrap_or(false);
470 let has_tls_material = object.get("TLSMaterial").is_some_and(|value| match value {
471 Value::Null => false,
472 Value::Object(values) => !values.is_empty(),
473 Value::Array(values) => !values.is_empty(),
474 Value::String(value) => !value.is_empty(),
475 _ => true,
476 });
477 let name_lower = name.as_deref().unwrap_or_default().to_ascii_lowercase();
478 let endpoint_lower = raw_endpoint.unwrap_or_default().to_ascii_lowercase();
479 let desktop = metadata_desktop
480 || name_lower == "desktop-linux"
481 || name_lower == "docker-desktop"
482 || endpoint_lower.contains("dockerdesktop");
483 let rootless = endpoint_lower.starts_with("unix:///run/user/")
484 || endpoint_lower.contains("/.docker/run/docker.sock");
485 let remote = raw_endpoint.is_some_and(is_remote_endpoint);
486 let kind = if desktop {
487 DockerContextKind::Desktop
488 } else if remote {
489 DockerContextKind::Remote
490 } else if rootless {
491 DockerContextKind::Rootless
492 } else if raw_endpoint.is_some() {
493 DockerContextKind::Local
494 } else {
495 DockerContextKind::Unknown
496 };
497 let endpoint = raw_endpoint.and_then(|raw| docker_endpoint_from_raw(raw, kind).ok());
498 Ok(DockerContext {
499 name,
500 endpoint,
501 kind,
502 skip_tls_verify,
503 has_tls_material,
504 raw_endpoint: raw_endpoint.map(str::to_owned),
505 })
506}
507
508pub fn parse_docker_info(output: &[u8]) -> Result<DockerInfo, DockerParseError> {
509 let value: Value = serde_json::from_slice(output).map_err(|_| DockerParseError::InvalidJson)?;
510 let object = value.as_object().ok_or(DockerParseError::MissingObject)?;
511 if ![
512 "OperatingSystem",
513 "OSType",
514 "Architecture",
515 "Name",
516 "ServerVersion",
517 "SecurityOptions",
518 "RegistryConfig",
519 ]
520 .iter()
521 .any(|key| object.contains_key(*key))
522 {
523 return Err(DockerParseError::MissingObject);
524 }
525 let operating_system = string_field(object, "OperatingSystem");
526 let os_type = string_field(object, "OSType");
527 let architecture = string_field(object, "Architecture");
528 let name = string_field(object, "Name");
529 let rootless = object
530 .get("SecurityOptions")
531 .and_then(Value::as_array)
532 .map(|options| options.iter().any(value_mentions_rootless));
533 let desktop = (operating_system.is_some() || name.is_some()).then(|| {
534 operating_system
535 .as_deref()
536 .is_some_and(|value| value.to_ascii_lowercase().contains("docker desktop"))
537 || name
538 .as_deref()
539 .is_some_and(|value| value.eq_ignore_ascii_case("docker-desktop"))
540 });
541 let registry_mirrors = object
542 .get("RegistryConfig")
543 .and_then(|value| value.get("Mirrors"))
544 .and_then(Value::as_array)
545 .into_iter()
546 .flatten()
547 .filter_map(Value::as_str)
548 .filter_map(|value| RedactedUrl::parse(value).ok())
549 .collect();
550
551 Ok(DockerInfo {
552 operating_system,
553 os_type,
554 architecture,
555 rootless,
556 desktop,
557 registry_mirrors,
558 })
559}
560
561fn string_field(object: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
562 object.get(key).and_then(Value::as_str).and_then(|value| {
563 let value = value.trim();
564 (!value.is_empty()).then(|| value.to_owned())
565 })
566}
567
568fn value_mentions_rootless(value: &Value) -> bool {
569 match value {
570 Value::String(value) => value.to_ascii_lowercase().contains("rootless"),
571 Value::Object(object) => object.iter().any(|(key, value)| {
572 key.to_ascii_lowercase().contains("rootless") || value_mentions_rootless(value)
573 }),
574 Value::Array(values) => values.iter().any(value_mentions_rootless),
575 _ => false,
576 }
577}
578
579fn refine_context_kind(context: &mut DockerContext, info: Option<&DockerInfo>) {
580 let Some(info) = info else {
581 return;
582 };
583 if info.desktop == Some(true) {
584 context.kind = DockerContextKind::Desktop;
585 } else if info.rootless == Some(true) && context.kind != DockerContextKind::Remote {
586 context.kind = DockerContextKind::Rootless;
587 }
588 if context.kind == DockerContextKind::Desktop {
589 if let Some(endpoint) = context.endpoint.as_mut() {
590 endpoint.transport = EndpointTransport::DockerDesktop;
591 endpoint.scope = EndpointScope::ManagedDesktop;
592 }
593 }
594}
595
596fn docker_endpoint_from_raw(
597 raw: &str,
598 kind: DockerContextKind,
599) -> Result<Endpoint, super::redact::RedactedUrlError> {
600 let (transport, mut scope) = super::classify_endpoint(raw);
601 let transport = if kind == DockerContextKind::Desktop {
602 scope = EndpointScope::ManagedDesktop;
603 EndpointTransport::DockerDesktop
604 } else {
605 transport
606 };
607 Ok(Endpoint::new(transport, scope, RedactedUrl::parse(raw)?))
608}
609
610fn is_remote_endpoint(raw: &str) -> bool {
611 super::classify_endpoint(raw).1 == EndpointScope::Remote
612}
613
614fn successful_output(outcome: &CommandOutcome) -> Option<&CapturedOutput> {
615 match outcome {
616 CommandOutcome::Exited { status, output }
617 if status.success() && !output.stdout_truncated =>
618 {
619 Some(output)
620 }
621 _ => None,
622 }
623}
624
625fn spawn_status(outcome: &CommandOutcome) -> Option<DiagnosticStatus> {
626 match outcome {
627 CommandOutcome::NotInstalled => Some(DiagnosticStatus::NotInstalled),
628 CommandOutcome::PermissionDenied => Some(DiagnosticStatus::PermissionDenied),
629 _ => None,
630 }
631}
632
633fn classify_outcome(outcome: &CommandOutcome) -> Option<DiagnosticStatus> {
634 if let Some(status) = spawn_status(outcome) {
635 return Some(status);
636 }
637 if matches!(outcome, CommandOutcome::TimedOut { .. }) {
638 return Some(DiagnosticStatus::Unreachable);
639 }
640 if output_contains(outcome, &permission_markers()) {
641 return Some(DiagnosticStatus::PermissionDenied);
642 }
643 if output_contains(outcome, &unsupported_version_markers()) {
644 return Some(DiagnosticStatus::UnsupportedVersion);
645 }
646 if output_contains(outcome, &unreachable_markers()) {
647 return Some(DiagnosticStatus::Unreachable);
648 }
649 match outcome {
650 CommandOutcome::Exited { status, .. } if !status.success() => {
651 Some(DiagnosticStatus::Degraded)
652 }
653 CommandOutcome::ExecutionFailed { .. } | CommandOutcome::SpawnFailed { .. } => {
654 Some(DiagnosticStatus::Degraded)
655 }
656 _ => None,
657 }
658}
659
660fn output_contains(outcome: &CommandOutcome, markers: &[&str]) -> bool {
661 let Some(output) = outcome.output() else {
662 return false;
663 };
664 [&output.stdout[..], &output.stderr[..]]
665 .into_iter()
666 .any(|bytes| {
667 let text = String::from_utf8_lossy(bytes).to_ascii_lowercase();
668 markers.iter().any(|marker| text.contains(marker))
669 })
670}
671
672fn permission_markers() -> [&'static str; 4] {
673 [
674 "permission denied",
675 "access is denied",
676 "operation not permitted",
677 "authorization denied",
678 ]
679}
680
681fn unreachable_markers() -> [&'static str; 8] {
682 [
683 "cannot connect",
684 "connection refused",
685 "is the docker daemon running",
686 "error during connect",
687 "context deadline exceeded",
688 "i/o timeout",
689 "no such host",
690 "daemon is not running",
691 ]
692}
693
694fn unsupported_version_markers() -> [&'static str; 4] {
695 [
696 "client version is too old",
697 "server version is too old",
698 "unsupported api version",
699 "requires docker engine",
700 ]
701}
702
703#[cfg(test)]
704mod tests {
705 use std::collections::VecDeque;
706 use std::io;
707 use std::process::ExitStatus;
708 use std::sync::Mutex;
709
710 use super::*;
711 use crate::process::TerminationStatus;
712
713 #[derive(Default)]
714 struct FakeRunner {
715 outcomes: Mutex<VecDeque<CommandOutcome>>,
716 calls: Mutex<Vec<Vec<String>>>,
717 }
718
719 impl FakeRunner {
720 fn with_outcomes(outcomes: impl IntoIterator<Item = CommandOutcome>) -> Self {
721 Self {
722 outcomes: Mutex::new(outcomes.into_iter().collect()),
723 calls: Mutex::new(Vec::new()),
724 }
725 }
726 }
727
728 impl CommandRunner for FakeRunner {
729 fn run_captured(&self, command: &CommandSpec, _limits: CaptureLimits) -> CommandOutcome {
730 self.calls.lock().unwrap().push(
731 command
732 .arguments()
733 .iter()
734 .map(|argument| argument.to_string_lossy().into_owned())
735 .collect(),
736 );
737 self.outcomes.lock().unwrap().pop_front().unwrap()
738 }
739
740 fn run_foreground(&self, _command: &CommandSpec) -> io::Result<ExitStatus> {
741 panic!("Docker discovery must not execute foreground commands")
742 }
743 }
744
745 fn success(stdout: &str) -> CommandOutcome {
746 exited(0, stdout, "")
747 }
748
749 fn failure(stdout: &str, stderr: &str) -> CommandOutcome {
750 exited(1, stdout, stderr)
751 }
752
753 fn exited(code: i32, stdout: &str, stderr: &str) -> CommandOutcome {
754 CommandOutcome::Exited {
755 status: exit_status(code),
756 output: CapturedOutput {
757 stdout: stdout.as_bytes().to_vec(),
758 stderr: stderr.as_bytes().to_vec(),
759 ..CapturedOutput::default()
760 },
761 }
762 }
763
764 #[cfg(unix)]
765 fn exit_status(code: i32) -> ExitStatus {
766 use std::os::unix::process::ExitStatusExt;
767 ExitStatus::from_raw(code << 8)
768 }
769
770 #[cfg(windows)]
771 fn exit_status(code: i32) -> ExitStatus {
772 use std::os::windows::process::ExitStatusExt;
773 ExitStatus::from_raw(code as u32)
774 }
775
776 #[test]
777 fn parses_local_remote_rootless_and_desktop_contexts() {
778 let local = parse_docker_context(
779 br#"[{"Name":"default","Endpoints":{"docker":{"Host":"unix:///var/run/docker.sock","SkipTLSVerify":false}}}]"#,
780 )
781 .unwrap();
782 assert_eq!(local.kind, DockerContextKind::Local);
783 assert_eq!(
784 local.endpoint.unwrap().transport,
785 EndpointTransport::LocalSocket
786 );
787
788 let remote = parse_docker_context(
789 br#"[{"Name":"prod","Endpoints":{"docker":{"Host":"ssh://alice:secret@host.example/private?token=x"}}}]"#,
790 )
791 .unwrap();
792 assert_eq!(remote.kind, DockerContextKind::Remote);
793 let endpoint = remote.endpoint.unwrap();
794 assert_eq!(endpoint.transport, EndpointTransport::Ssh);
795 assert_eq!(endpoint.scope, EndpointScope::Remote);
796 let json = serde_json::to_string(&endpoint).unwrap();
797 for secret in ["alice", "secret", "private", "token"] {
798 assert!(!json.contains(secret), "leaked {secret}: {json}");
799 }
800
801 let rootless = parse_docker_context(
802 br#"[{"Name":"rootless","Endpoints":{"docker":{"Host":"unix:///run/user/1000/docker.sock"}}}]"#,
803 )
804 .unwrap();
805 assert_eq!(rootless.kind, DockerContextKind::Rootless);
806
807 let desktop = parse_docker_context(
808 br#"[{"Name":"desktop-linux","Metadata":{"Description":"Docker Desktop"},"Endpoints":{"docker":{"Host":"npipe:////./pipe/dockerDesktopLinuxEngine"}}}]"#,
809 )
810 .unwrap();
811 assert_eq!(desktop.kind, DockerContextKind::Desktop);
812 assert_eq!(
813 desktop.endpoint.unwrap().transport,
814 EndpointTransport::DockerDesktop
815 );
816 }
817
818 #[test]
819 fn context_keeps_raw_endpoint_private_and_redacts_debug_output() {
820 let first = parse_docker_context(
821 br#"[{"Name":"prod","Endpoints":{"docker":{"Host":"ssh://alice:secret@host.example/private-a?token=one"}}}]"#,
822 )
823 .unwrap();
824 assert_eq!(
825 first.raw_endpoint.as_deref(),
826 Some("ssh://alice:secret@host.example/private-a?token=one")
827 );
828 let debug = format!("{first:?}");
829 for secret in ["alice", "secret", "private-a", "token", "one"] {
830 assert!(!debug.contains(secret), "leaked {secret}: {debug}");
831 }
832 assert!(debug.contains("[redacted]"));
833 }
834
835 #[test]
836 fn loopback_network_contexts_are_local() {
837 for host in [
838 "tcp://127.0.0.1:2375",
839 "http://localhost:2375",
840 "https://[::1]:2376",
841 ] {
842 let output =
843 format!(r#"[{{"Name":"loopback","Endpoints":{{"docker":{{"Host":{host:?}}}}}}}]"#);
844 let context = parse_docker_context(output.as_bytes()).unwrap();
845 assert_eq!(context.kind, DockerContextKind::Local, "{host}");
846 assert_eq!(context.endpoint.unwrap().scope, EndpointScope::Local);
847 }
848 }
849
850 #[test]
851 fn parses_version_info_rootless_desktop_and_sanitized_mirrors() {
852 let version = parse_docker_version(
853 br#"{"Client":{"Version":"29.0.1"},"Server":{"Version":"28.5.2"}}"#,
854 )
855 .unwrap();
856 assert_eq!(version.client.unwrap(), Version::new(29, 0, 1));
857 assert_eq!(version.server.unwrap(), Version::new(28, 5, 2));
858
859 let info = parse_docker_info(
860 br#"{"Name":"docker-desktop","OperatingSystem":"Docker Desktop","OSType":"linux","Architecture":"aarch64","SecurityOptions":["name=rootless"],"RegistryConfig":{"Mirrors":["https://alice:secret@mirror.example/private?token=x"]}}"#,
861 )
862 .unwrap();
863 assert_eq!(info.rootless, Some(true));
864 assert_eq!(info.desktop, Some(true));
865 assert_eq!(info.architecture.as_deref(), Some("aarch64"));
866 let mirror = serde_json::to_string(&info.registry_mirrors).unwrap();
867 assert_eq!(mirror, r#"["https://mirror.example/[redacted]?redacted"]"#);
868 }
869
870 #[test]
871 fn partial_info_preserves_unknown_rootless_and_desktop_facts() {
872 let info = parse_docker_info(br#"{"ServerVersion":"29.0.1"}"#).unwrap();
873
874 assert_eq!(info.rootless, None);
875 assert_eq!(info.desktop, None);
876 }
877
878 #[test]
879 fn discovery_uses_only_injected_read_only_commands() {
880 let runner = FakeRunner::with_outcomes([
881 success(r#"{"Client":{"Version":"29.0.1"},"Server":{"Version":"29.0.1"}}"#),
882 success(
883 r#"[{"Name":"default","Endpoints":{"docker":{"Host":"unix:///var/run/docker.sock"}}}]"#,
884 ),
885 success(
886 r#"{"OperatingSystem":"Linux","OSType":"linux","Architecture":"x86_64","RegistryConfig":{"Mirrors":[]}}"#,
887 ),
888 ]);
889
890 let discovery = DockerAdapter.discover(&runner, CaptureLimits::default());
891 assert_eq!(discovery.status, DiagnosticStatus::Healthy);
892 assert_eq!(
893 *runner.calls.lock().unwrap(),
894 vec![
895 vec!["version", "--format", "{{json .}}"],
896 vec!["context", "inspect"],
897 vec!["info", "--format", "{{json .}}"],
898 ]
899 );
900 }
901
902 #[test]
903 fn classifies_not_installed_without_extra_probes() {
904 let runner = FakeRunner::with_outcomes([CommandOutcome::NotInstalled]);
905 let discovery = DockerAdapter.discover(&runner, CaptureLimits::default());
906 assert_eq!(discovery.status, DiagnosticStatus::NotInstalled);
907 assert_eq!(runner.calls.lock().unwrap().len(), 1);
908 }
909
910 #[test]
911 fn classifies_client_only_unreachable_permission_and_unsupported() {
912 let client = r#"{"Client":{"Version":"29.0.1"},"Server":null}"#;
913 let client_only = FakeRunner::with_outcomes([
914 success(client),
915 success(
916 r#"[{"Name":"default","Endpoints":{"docker":{"Host":"unix:///var/run/docker.sock"}}}]"#,
917 ),
918 success("{}"),
919 ]);
920 assert_eq!(
921 DockerAdapter
922 .discover(&client_only, CaptureLimits::default())
923 .status,
924 DiagnosticStatus::ClientOnly
925 );
926
927 let unreachable = FakeRunner::with_outcomes([
928 failure(client, "Cannot connect to the Docker daemon"),
929 success(
930 r#"[{"Name":"remote","Endpoints":{"docker":{"Host":"tcp://host.example:2376"}}}]"#,
931 ),
932 failure("{}", "connection refused"),
933 ]);
934 assert_eq!(
935 DockerAdapter
936 .discover(&unreachable, CaptureLimits::default())
937 .status,
938 DiagnosticStatus::Unreachable
939 );
940
941 let denied = FakeRunner::with_outcomes([
942 failure(client, "permission denied while trying to connect"),
943 success(
944 r#"[{"Name":"default","Endpoints":{"docker":{"Host":"unix:///var/run/docker.sock"}}}]"#,
945 ),
946 failure("{}", "permission denied"),
947 ]);
948 assert_eq!(
949 DockerAdapter
950 .discover(&denied, CaptureLimits::default())
951 .status,
952 DiagnosticStatus::PermissionDenied
953 );
954
955 let unsupported = FakeRunner::with_outcomes([success(
956 r#"{"Client":{"Version":"18.09.9"},"Server":null}"#,
957 )]);
958 assert_eq!(
959 DockerAdapter
960 .discover(&unsupported, CaptureLimits::default())
961 .status,
962 DiagnosticStatus::UnsupportedVersion
963 );
964 }
965
966 #[test]
967 fn truncated_output_is_not_parsed_as_authoritative() {
968 let runner = FakeRunner::with_outcomes([
969 CommandOutcome::Exited {
970 status: exit_status(0),
971 output: CapturedOutput {
972 stdout: br#"{"Client":{"Version":"29.0.1"}}"#.to_vec(),
973 stdout_truncated: true,
974 ..CapturedOutput::default()
975 },
976 },
977 success("[]"),
978 success("{}"),
979 ]);
980 assert_eq!(
981 DockerAdapter
982 .discover(&runner, CaptureLimits::default())
983 .status,
984 DiagnosticStatus::Degraded
985 );
986 }
987
988 #[test]
989 fn failed_parseable_stdout_never_establishes_discovery_facts() {
990 let version = r#"{"Client":{"Version":"29.0.1"},"Server":{"Version":"29.0.1"}}"#;
991 let context =
992 r#"[{"Name":"stale","Endpoints":{"docker":{"Host":"tcp://host.example:2375"}}}]"#;
993 let info = r#"{"OperatingSystem":"stale","ServerVersion":"29.0.1"}"#;
994 let runner = FakeRunner::with_outcomes([
995 failure(version, "cannot connect to the Docker daemon"),
996 failure(context, "cannot connect"),
997 failure(info, "connection refused"),
998 ]);
999
1000 let discovery = DockerAdapter.discover(&runner, CaptureLimits::default());
1001
1002 assert_eq!(discovery.status, DiagnosticStatus::Unreachable);
1003 assert!(discovery.version.is_none());
1004 assert!(discovery.context.is_none());
1005 assert!(discovery.info.is_none());
1006 }
1007
1008 #[test]
1009 fn docker_versions_use_shared_vendor_parser_without_truncation() {
1010 let parsed = parse_docker_version(
1011 br#"{"Client":{"Version":"V29.1"},"Server":{"Version":"1.2.3.4"}}"#,
1012 )
1013 .unwrap();
1014 assert_eq!(parsed.client, Some(Version::new(29, 1, 0)));
1015 assert_eq!(parsed.server, None);
1016 }
1017
1018 #[test]
1019 fn diagnostic_details_are_closed_and_mirror_origins_preserve_order() {
1020 let runner = FakeRunner::with_outcomes([
1021 success(r#"{"Client":{"Version":"29.0.1"},"Server":{"Version":"29.0.1"}}"#),
1022 success(
1023 r#"[{"Name":"secret-context","Endpoints":{"docker":{"Host":"unix:///var/run/docker.sock"}}}]"#,
1024 ),
1025 success(
1026 r#"{"OSType":"token-secret-os","Architecture":"token-secret-arch","RegistryConfig":{"Mirrors":["https://first.example/private?token=secret","https://second.example/cache"]}}"#,
1027 ),
1028 ]);
1029
1030 let report = DockerAdapter.diagnose(&runner, CaptureLimits::default());
1031 let json = serde_json::to_string(&report).unwrap();
1032
1033 assert!(json.contains("\"daemon_os\":\"unknown\""));
1034 assert!(json.contains("\"daemon_architecture\":\"unknown\""));
1035 assert!(json.contains(
1036 "\"registry_mirror_origins\":[\"https://first.example\",\"https://second.example\"]"
1037 ));
1038 for secret in [
1039 "secret-context",
1040 "token-secret-os",
1041 "token-secret-arch",
1042 "private",
1043 "token",
1044 ] {
1045 assert!(!json.contains(secret), "leaked {secret}: {json}");
1046 }
1047 }
1048
1049 #[test]
1050 fn timeout_is_unreachable_and_does_not_require_a_daemon() {
1051 let runner = FakeRunner::with_outcomes([
1052 success(r#"{"Client":{"Version":"29.0.1"},"Server":null}"#),
1053 success(
1054 r#"[{"Name":"default","Endpoints":{"docker":{"Host":"unix:///var/run/docker.sock"}}}]"#,
1055 ),
1056 CommandOutcome::TimedOut {
1057 output: CapturedOutput::default(),
1058 termination: TerminationStatus::Requested,
1059 },
1060 ]);
1061 assert_eq!(
1062 DockerAdapter
1063 .discover(&runner, CaptureLimits::default())
1064 .status,
1065 DiagnosticStatus::Unreachable
1066 );
1067 }
1068
1069 #[test]
1070 fn diagnostic_evidence_matches_only_commands_that_ran() {
1071 let runner = FakeRunner::with_outcomes([CommandOutcome::PermissionDenied]);
1072 let report = DockerAdapter.diagnose(&runner, CaptureLimits::default());
1073
1074 assert_eq!(report.status, DiagnosticStatus::PermissionDenied);
1075 assert_eq!(report.evidence.len(), 1);
1076 assert_eq!(runner.calls.lock().unwrap().len(), 1);
1077 }
1078}