Skip to main content

osdk_core/container/
docker.rs

1//! Read-only Docker Engine discovery.
2//!
3//! The adapter deliberately asks the Docker CLI for public context and daemon
4//! metadata. It never reads Docker's private state or configuration files.
5
6use 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/// The ownership and locality inferred for the selected Docker context.
21#[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/// Secret-safe Docker facts exposed by diagnostic schema v2. Context names
78/// and raw endpoints are deliberately excluded. Mirror order is preserved.
79#[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/// Public fields returned by `docker context inspect` for the selected context.
92///
93/// This type intentionally does not implement `Serialize`: the context name is
94/// useful to an interactive caller but must not accidentally enter the stable
95/// secret-safe diagnostic JSON contract.
96#[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/// Parsed client and daemon versions.
144#[derive(Clone, Debug, Default, PartialEq, Eq)]
145pub struct DockerVersion {
146    pub client: Option<Version>,
147    pub server: Option<Version>,
148}
149
150/// Bounded daemon facts returned by `docker info`.
151#[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/// Complete typed result of a Docker discovery pass.
162#[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/// A structural parse error which never retains or echoes command output.
171#[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/// Read-only Docker Engine runtime adapter.
180#[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        // Docker Engine's mirror setting is Docker Hub-specific, not a
355        // per-registry host mapping mechanism.
356        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}