Skip to main content

osdk_core/container/
containerd.rs

1//! Read-only containerd discovery and registry-host inspection.
2//!
3//! The adapter uses only native read-only commands. Configuration parsers are
4//! also public so callers can inspect already captured text or an explicitly
5//! selected file without granting this module directory-discovery authority.
6
7use std::collections::BTreeSet;
8use std::io;
9use std::path::{Component, Path, PathBuf};
10
11use semver::Version;
12use serde::Serialize;
13
14use super::redact::{CommandPurpose, NativeProgram, RedactedUrl};
15use super::report::{
16    Capability, CapabilityStatus, DiagnosticDetails, DiagnosticEvidence, DiagnosticReport,
17    DiagnosticStatus, Endpoint, EndpointScope, Privilege, RuntimeKind,
18};
19use super::runtime::{ProbeCommand, RuntimeAdapter};
20use crate::process::{CaptureLimits, CommandOutcome, CommandRunner, CommandSpec};
21
22const MINIMUM_CONTAINERD_VERSION: Version = Version::new(1, 6, 0);
23#[cfg(not(windows))]
24const DEFAULT_ADDRESS: &str = "unix:///run/containerd/containerd.sock";
25#[cfg(windows)]
26const DEFAULT_ADDRESS: &str = "npipe:////./pipe/containerd-containerd";
27const DEFAULT_NAMESPACE: &str = "default";
28
29/// Client and server versions reported by the native programs.
30#[derive(Clone, Debug, Default, PartialEq, Eq)]
31pub struct ContainerdVersions {
32    pub containerd: Option<Version>,
33    pub ctr_client: Option<Version>,
34    pub ctr_server: Option<Version>,
35}
36
37/// A typed warning about deprecated inline CRI registry configuration.
38#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
39#[serde(rename_all = "lowercase")]
40pub enum LegacyRegistryWarning {
41    Auths,
42    Configs,
43    Mirrors,
44}
45
46/// Secret-safe containerd facts exposed by diagnostic schema v2. The selected
47/// namespace and the registry configuration path itself never enter output.
48#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
49pub struct ContainerdDiagnosticDetails {
50    pub containerd_version: Option<String>,
51    pub ctr_client_version: Option<String>,
52    pub ctr_server_version: Option<String>,
53    pub config_version: Option<u32>,
54    pub config_path_configured: Option<bool>,
55    pub legacy_registry_settings: BTreeSet<LegacyRegistryWarning>,
56}
57
58/// Relevant fields from `containerd config dump`.
59#[derive(Clone, Debug, Default, PartialEq, Eq)]
60pub struct ContainerdConfig {
61    pub version: Option<u32>,
62    pub registry_config_path: Option<PathBuf>,
63    pub legacy_registry: BTreeSet<LegacyRegistryWarning>,
64}
65
66/// Registry-host capabilities understood by containerd.
67#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
68pub enum RegistryHostCapability {
69    Pull,
70    Resolve,
71    Push,
72    Unknown,
73}
74
75/// TLS-related host settings. Paths are intentionally represented only by
76/// presence/count so reports cannot accidentally disclose local layout.
77#[derive(Clone, Debug, Default, PartialEq, Eq)]
78pub struct RegistryHostTls {
79    pub skip_verify: bool,
80    pub ca_count: usize,
81    pub client_certificate_configured: bool,
82}
83
84/// One `[host."..."]` entry in a containerd `hosts.toml`.
85#[derive(Clone, Debug, PartialEq, Eq)]
86pub struct RegistryHost {
87    pub endpoint: RedactedUrl,
88    pub capabilities: BTreeSet<RegistryHostCapability>,
89    pub override_path: bool,
90    pub tls: RegistryHostTls,
91}
92
93/// Parsed, secret-safe registry namespace host configuration.
94#[derive(Clone, Debug, Default, PartialEq, Eq)]
95pub struct RegistryHosts {
96    pub server: Option<RedactedUrl>,
97    pub hosts: Vec<RegistryHost>,
98}
99
100/// Structural errors that never retain native output or file contents.
101#[derive(Debug, thiserror::Error)]
102pub enum ContainerdParseError {
103    #[error("invalid containerd TOML")]
104    InvalidToml,
105    #[error("invalid containerd endpoint URL")]
106    InvalidEndpoint,
107    #[error("containerd endpoint selectors cannot contain credentials, query, or fragments")]
108    UnsafeEndpoint,
109    #[error("invalid containerd namespace")]
110    InvalidNamespace,
111    #[error("hosts path must explicitly name hosts.toml without parent traversal")]
112    UnsafeHostsPath,
113    #[error("could not read the explicitly selected hosts.toml")]
114    Read(#[source] io::Error),
115}
116
117/// Typed result of one containerd discovery pass.
118#[derive(Clone, Debug, PartialEq, Eq)]
119pub struct ContainerdDiscovery {
120    pub report: DiagnosticReport,
121    pub versions: ContainerdVersions,
122    pub address: RedactedUrl,
123    pub namespace: String,
124    pub config: Option<ContainerdConfig>,
125}
126
127/// Read-only containerd adapter. Selectors are passed directly to `ctr` rather
128/// than inferred from mutable process-global environment variables.
129#[derive(Clone, PartialEq, Eq)]
130pub struct ContainerdAdapter {
131    address: String,
132    namespace: String,
133}
134
135impl std::fmt::Debug for ContainerdAdapter {
136    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137        formatter
138            .debug_struct("ContainerdAdapter")
139            .field("address", &self.address().ok())
140            .field("namespace", &"[redacted]")
141            .finish()
142    }
143}
144
145impl Default for ContainerdAdapter {
146    fn default() -> Self {
147        Self {
148            address: DEFAULT_ADDRESS.to_owned(),
149            namespace: DEFAULT_NAMESPACE.to_owned(),
150        }
151    }
152}
153
154impl ContainerdAdapter {
155    /// Create an adapter with explicit daemon address and namespace selectors.
156    /// The address must use a supported endpoint URL scheme.
157    pub fn new(
158        address: impl Into<String>,
159        namespace: impl Into<String>,
160    ) -> Result<Self, ContainerdParseError> {
161        let address = address.into();
162        let parsed =
163            reqwest::Url::parse(&address).map_err(|_| ContainerdParseError::InvalidEndpoint)?;
164        RedactedUrl::parse(&address).map_err(|_| ContainerdParseError::InvalidEndpoint)?;
165        if !parsed.username().is_empty()
166            || parsed.password().is_some()
167            || parsed.query().is_some()
168            || parsed.fragment().is_some()
169        {
170            return Err(ContainerdParseError::UnsafeEndpoint);
171        }
172        let namespace = namespace.into();
173        if namespace.is_empty()
174            || namespace.starts_with('-')
175            || namespace.len() > 128
176            || !namespace
177                .bytes()
178                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
179        {
180            return Err(ContainerdParseError::InvalidNamespace);
181        }
182        Ok(Self { address, namespace })
183    }
184
185    pub fn address(&self) -> Result<RedactedUrl, ContainerdParseError> {
186        RedactedUrl::parse(&self.address).map_err(|_| ContainerdParseError::InvalidEndpoint)
187    }
188
189    pub fn namespace(&self) -> &str {
190        &self.namespace
191    }
192
193    /// Query the containerd binary, the `ctr` client/server, and the effective
194    /// config. No command writes configuration or daemon state.
195    pub fn inspect(
196        &self,
197        runner: &dyn CommandRunner,
198        limits: CaptureLimits,
199    ) -> ContainerdDiscovery {
200        let address = self.address().expect("validated containerd address");
201        let mut report = DiagnosticReport::new(RuntimeKind::Containerd, DiagnosticStatus::Degraded)
202            .with_privilege(Privilege::CurrentUser);
203        let endpoint = endpoint_from_url(address.clone());
204        report.add_endpoint(endpoint.clone());
205        report.add_evidence(DiagnosticEvidence::Endpoint(endpoint.clone()));
206
207        let daemon_probe = probe(
208            NativeProgram::Containerd,
209            CommandPurpose::Version,
210            CommandSpec::new("containerd").arg("--version"),
211        );
212        report.add_evidence(DiagnosticEvidence::Command(daemon_probe.evidence().clone()));
213        let daemon_outcome = daemon_probe.execute(runner, limits);
214
215        let ctr_probe = probe(
216            NativeProgram::Ctr,
217            CommandPurpose::Version,
218            self.ctr_command().arg("version"),
219        );
220        report.add_evidence(DiagnosticEvidence::Command(ctr_probe.evidence().clone()));
221        let ctr_outcome = ctr_probe.execute(runner, limits);
222
223        let mut versions = ContainerdVersions {
224            containerd: successful_stdout(&daemon_outcome).and_then(parse_first_version),
225            ctr_client: None,
226            ctr_server: None,
227        };
228        if let Some(stdout) = successful_stdout(&ctr_outcome) {
229            let parsed = parse_ctr_versions(stdout);
230            versions.ctr_client = parsed.0;
231            versions.ctr_server = parsed.1;
232        }
233
234        let (config_outcome, config) = if endpoint.scope == EndpointScope::Local {
235            let config_probe = probe(
236                NativeProgram::Containerd,
237                CommandPurpose::RuntimeInfo,
238                CommandSpec::new("containerd").args(["config", "dump"]),
239            );
240            report.add_evidence(DiagnosticEvidence::Command(config_probe.evidence().clone()));
241            let outcome = config_probe.execute(runner, limits);
242            let config =
243                successful_stdout(&outcome).and_then(|bytes| parse_effective_config(bytes).ok());
244            (Some(outcome), config)
245        } else {
246            // A local `containerd config dump` says nothing authoritative
247            // about a daemon reached through a remote endpoint.
248            (None, None)
249        };
250
251        let ctr_installed = !matches!(ctr_outcome, CommandOutcome::NotInstalled);
252        let daemon_installed = !matches!(daemon_outcome, CommandOutcome::NotInstalled);
253        let server_reached = versions.ctr_server.is_some();
254        let unsupported = [
255            &versions.containerd,
256            &versions.ctr_client,
257            &versions.ctr_server,
258        ]
259        .into_iter()
260        .flatten()
261        .any(|version| version < &MINIMUM_CONTAINERD_VERSION);
262        report.status = classify_status(
263            daemon_installed,
264            ctr_installed,
265            server_reached,
266            unsupported,
267            [&daemon_outcome, &ctr_outcome]
268                .into_iter()
269                .chain(config_outcome.as_ref())
270                .collect::<Vec<_>>(),
271        );
272        if report.status == DiagnosticStatus::Healthy
273            && matches!(
274                config_outcome.as_ref(),
275                Some(CommandOutcome::Exited { status, .. }) if status.success()
276            )
277            && config.is_none()
278        {
279            report.status = DiagnosticStatus::Degraded;
280        }
281
282        report.set_capability(
283            Capability::Client,
284            if versions.ctr_client.is_some() {
285                CapabilityStatus::Supported
286            } else {
287                CapabilityStatus::Unavailable
288            },
289        );
290        for capability in [
291            Capability::Daemon,
292            Capability::RuntimeInfo,
293            Capability::Pull,
294        ] {
295            report.set_capability(
296                capability,
297                if server_reached {
298                    CapabilityStatus::Supported
299                } else {
300                    CapabilityStatus::Unavailable
301                },
302            );
303        }
304        // containerd exposes several namespace-dependent content, snapshot,
305        // and CRI views, but no single supported aggregate cache contract.
306        // Keep diagnostics aligned with ContainerdCacheQuery instead of
307        // inferring cache support from daemon reachability.
308        for capability in [Capability::CacheStatus, Capability::CachePrune] {
309            report.set_capability(capability, CapabilityStatus::Unsupported);
310        }
311        report.set_capability(
312            Capability::RegistryHostMapping,
313            if config
314                .as_ref()
315                .and_then(|value| value.registry_config_path.as_ref())
316                .is_some()
317            {
318                CapabilityStatus::Supported
319            } else if server_reached {
320                CapabilityStatus::Unknown
321            } else {
322                CapabilityStatus::Unavailable
323            },
324        );
325        report.set_capability(
326            Capability::RegistryMirrors,
327            *report
328                .capabilities
329                .get(&Capability::RegistryHostMapping)
330                .unwrap_or(&CapabilityStatus::Unknown),
331        );
332        report.set_details(DiagnosticDetails::Containerd(ContainerdDiagnosticDetails {
333            containerd_version: versions.containerd.as_ref().map(ToString::to_string),
334            ctr_client_version: versions.ctr_client.as_ref().map(ToString::to_string),
335            ctr_server_version: versions.ctr_server.as_ref().map(ToString::to_string),
336            config_version: config.as_ref().and_then(|config| config.version),
337            config_path_configured: config
338                .as_ref()
339                .map(|config| config.registry_config_path.is_some()),
340            legacy_registry_settings: config
341                .as_ref()
342                .map(|config| config.legacy_registry.clone())
343                .unwrap_or_default(),
344        }));
345
346        ContainerdDiscovery {
347            report,
348            versions,
349            address,
350            namespace: self.namespace.clone(),
351            config,
352        }
353    }
354
355    fn ctr_command(&self) -> CommandSpec {
356        CommandSpec::new("ctr").args([
357            "--address",
358            self.address.as_str(),
359            "--namespace",
360            self.namespace.as_str(),
361        ])
362    }
363}
364
365impl RuntimeAdapter for ContainerdAdapter {
366    fn kind(&self) -> RuntimeKind {
367        RuntimeKind::Containerd
368    }
369
370    fn diagnose(&self, runner: &dyn CommandRunner, limits: CaptureLimits) -> DiagnosticReport {
371        self.inspect(runner, limits).report
372    }
373}
374
375/// Parse the relevant portions of `containerd config dump` for either the 1.x
376/// or 2.x CRI plugin table. Unknown keys are ignored, never rewritten.
377pub fn parse_effective_config(input: &[u8]) -> Result<ContainerdConfig, ContainerdParseError> {
378    let text = std::str::from_utf8(input).map_err(|_| ContainerdParseError::InvalidToml)?;
379    let root: toml::Value = toml::from_str(text).map_err(|_| ContainerdParseError::InvalidToml)?;
380    let version = root
381        .get("version")
382        .and_then(toml::Value::as_integer)
383        .and_then(|value| u32::try_from(value).ok());
384    let plugins = root.get("plugins").and_then(toml::Value::as_table);
385    let legacy_registry = plugins
386        .and_then(|plugins| plugins.get("io.containerd.grpc.v1.cri"))
387        .and_then(|value| value.get("registry"));
388    let current_registry = plugins
389        .and_then(|plugins| plugins.get("io.containerd.cri.v1.images"))
390        .and_then(|value| value.get("registry"))
391        .or_else(|| {
392            plugins
393                .and_then(|plugins| plugins.get("io.containerd.cri.v1.images"))
394                .and_then(|value| value.get("containerd"))
395                .and_then(|value| value.get("registry"))
396        });
397    let registry_config_path = current_registry
398        .and_then(|value| value.get("config_path"))
399        .and_then(toml::Value::as_str)
400        .filter(|value| !value.trim().is_empty())
401        .or_else(|| {
402            legacy_registry
403                .and_then(|value| value.get("config_path"))
404                .and_then(toml::Value::as_str)
405                .filter(|value| !value.trim().is_empty())
406        })
407        .map(PathBuf::from);
408    let mut legacy_warnings = BTreeSet::new();
409    for (name, warning) in [
410        ("mirrors", LegacyRegistryWarning::Mirrors),
411        ("configs", LegacyRegistryWarning::Configs),
412        ("auths", LegacyRegistryWarning::Auths),
413    ] {
414        if legacy_registry.and_then(|value| value.get(name)).is_some()
415            || current_registry.and_then(|value| value.get(name)).is_some()
416        {
417            legacy_warnings.insert(warning);
418        }
419    }
420    Ok(ContainerdConfig {
421        version,
422        registry_config_path,
423        legacy_registry: legacy_warnings,
424    })
425}
426
427/// Parse one `hosts.toml`, sanitizing every endpoint and reducing TLS paths to
428/// typed presence metadata.
429pub fn parse_hosts_toml(input: &str) -> Result<RegistryHosts, ContainerdParseError> {
430    let root: toml::Value = toml::from_str(input).map_err(|_| ContainerdParseError::InvalidToml)?;
431    let ordered: toml_edit::DocumentMut = input
432        .parse()
433        .map_err(|_| ContainerdParseError::InvalidToml)?;
434    let server = root
435        .get("server")
436        .and_then(toml::Value::as_str)
437        .map(parse_url)
438        .transpose()?;
439    let mut hosts = Vec::new();
440    if let Some(entries) = ordered.get("host").and_then(toml_edit::Item::as_table) {
441        for (raw_endpoint, value) in entries.iter() {
442            let Some(table) = value.as_table_like() else {
443                continue;
444            };
445            let endpoint = parse_url(raw_endpoint)?;
446            let mut capabilities = BTreeSet::new();
447            if let Some(values) = table
448                .get("capabilities")
449                .and_then(toml_edit::Item::as_array)
450            {
451                for value in values.iter().filter_map(toml_edit::Value::as_str) {
452                    capabilities.insert(match value.to_ascii_lowercase().as_str() {
453                        "pull" => RegistryHostCapability::Pull,
454                        "resolve" => RegistryHostCapability::Resolve,
455                        "push" => RegistryHostCapability::Push,
456                        _ => RegistryHostCapability::Unknown,
457                    });
458                }
459            }
460            let tls = RegistryHostTls {
461                skip_verify: table
462                    .get("skip_verify")
463                    .and_then(toml_edit::Item::as_bool)
464                    .unwrap_or(false),
465                ca_count: path_value_count(table.get("ca")),
466                client_certificate_configured: path_value_count(table.get("client")) > 0,
467            };
468            hosts.push(RegistryHost {
469                endpoint,
470                capabilities,
471                override_path: table
472                    .get("override_path")
473                    .and_then(toml_edit::Item::as_bool)
474                    .unwrap_or(false),
475                tls,
476            });
477        }
478    }
479    Ok(RegistryHosts { server, hosts })
480}
481
482/// Read exactly the caller-selected `hosts.toml`. Parent components are
483/// rejected to prevent this convenience helper from becoming a traversal API.
484pub fn read_hosts_toml(path: &Path) -> Result<RegistryHosts, ContainerdParseError> {
485    if path.file_name().and_then(|name| name.to_str()) != Some("hosts.toml")
486        || path
487            .components()
488            .any(|component| component == Component::ParentDir)
489    {
490        return Err(ContainerdParseError::UnsafeHostsPath);
491    }
492    let text = std::fs::read_to_string(path).map_err(ContainerdParseError::Read)?;
493    parse_hosts_toml(&text)
494}
495
496fn probe(program: NativeProgram, purpose: CommandPurpose, command: CommandSpec) -> ProbeCommand {
497    ProbeCommand::new(program, purpose, command)
498}
499
500fn endpoint_from_url(address: RedactedUrl) -> Endpoint {
501    let (transport, scope) = super::classify_endpoint(address.as_str());
502    Endpoint::new(transport, scope, address)
503}
504
505fn successful_stdout(outcome: &CommandOutcome) -> Option<&[u8]> {
506    match outcome {
507        CommandOutcome::Exited { status, output }
508            if status.success() && !output.stdout_truncated =>
509        {
510            Some(&output.stdout)
511        }
512        _ => None,
513    }
514}
515
516fn parse_first_version(input: &[u8]) -> Option<Version> {
517    let text = std::str::from_utf8(input).ok()?;
518    text.split(|character: char| character.is_whitespace() || character == ',' || character == ':')
519        .find_map(super::parse_vendor_version)
520}
521
522fn parse_ctr_versions(input: &[u8]) -> (Option<Version>, Option<Version>) {
523    let Ok(text) = std::str::from_utf8(input) else {
524        return (None, None);
525    };
526    let mut section = None;
527    let mut client = None;
528    let mut server = None;
529    for line in text.lines() {
530        match line
531            .trim()
532            .trim_end_matches(':')
533            .to_ascii_lowercase()
534            .as_str()
535        {
536            "client" => section = Some(false),
537            "server" => section = Some(true),
538            _ if line
539                .trim_start()
540                .to_ascii_lowercase()
541                .starts_with("version:") =>
542            {
543                let version = line
544                    .split_once(':')
545                    .and_then(|pair| super::parse_vendor_version(pair.1.trim()));
546                match section {
547                    Some(false) => client = version,
548                    Some(true) => server = version,
549                    None => {}
550                }
551            }
552            _ => {}
553        }
554    }
555    (client, server)
556}
557
558fn parse_url(raw: &str) -> Result<RedactedUrl, ContainerdParseError> {
559    RedactedUrl::parse(raw).map_err(|_| ContainerdParseError::InvalidEndpoint)
560}
561
562fn path_value_count(value: Option<&toml_edit::Item>) -> usize {
563    match value {
564        Some(value) if value.as_str().is_some() => usize::from(!value.as_str().unwrap().is_empty()),
565        Some(value) if value.as_array().is_some() => value.as_array().unwrap().len(),
566        _ => 0,
567    }
568}
569
570fn classify_status(
571    daemon_installed: bool,
572    ctr_installed: bool,
573    server_reached: bool,
574    unsupported: bool,
575    outcomes: Vec<&CommandOutcome>,
576) -> DiagnosticStatus {
577    if outcomes
578        .iter()
579        .any(|outcome| matches!(outcome, CommandOutcome::PermissionDenied))
580        || outcomes
581            .iter()
582            .any(|outcome| output_contains(outcome, &["permission denied", "access is denied"]))
583    {
584        DiagnosticStatus::PermissionDenied
585    } else if !daemon_installed && !ctr_installed {
586        DiagnosticStatus::NotInstalled
587    } else if unsupported {
588        DiagnosticStatus::UnsupportedVersion
589    } else if outcomes.iter().any(|outcome| {
590        matches!(outcome, CommandOutcome::TimedOut { .. })
591            || output_contains(
592                outcome,
593                &[
594                    "connection refused",
595                    "deadline exceeded",
596                    "transport is closing",
597                    "failed to dial",
598                    "context deadline",
599                ],
600            )
601    }) {
602        DiagnosticStatus::Unreachable
603    } else if server_reached {
604        DiagnosticStatus::Healthy
605    } else if ctr_installed {
606        DiagnosticStatus::ClientOnly
607    } else {
608        DiagnosticStatus::Degraded
609    }
610}
611
612fn output_contains(outcome: &CommandOutcome, needles: &[&str]) -> bool {
613    let Some(output) = outcome.output() else {
614        return false;
615    };
616    let stdout = String::from_utf8_lossy(&output.stdout).to_ascii_lowercase();
617    let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase();
618    needles
619        .iter()
620        .any(|needle| stdout.contains(needle) || stderr.contains(needle))
621}
622
623#[cfg(test)]
624mod tests {
625    use std::collections::VecDeque;
626    use std::process::ExitStatus;
627    use std::sync::Mutex;
628
629    use crate::process::CapturedOutput;
630
631    use super::*;
632
633    struct FakeRunner {
634        outcomes: Mutex<VecDeque<CommandOutcome>>,
635        commands: Mutex<Vec<Vec<String>>>,
636    }
637
638    impl FakeRunner {
639        fn new(outcomes: Vec<CommandOutcome>) -> Self {
640            Self {
641                outcomes: Mutex::new(outcomes.into()),
642                commands: Mutex::new(Vec::new()),
643            }
644        }
645    }
646
647    impl CommandRunner for FakeRunner {
648        fn run_captured(&self, command: &CommandSpec, _limits: CaptureLimits) -> CommandOutcome {
649            self.commands.lock().unwrap().push(
650                std::iter::once(command.program().to_string_lossy().into_owned())
651                    .chain(
652                        command
653                            .arguments()
654                            .iter()
655                            .map(|value| value.to_string_lossy().into_owned()),
656                    )
657                    .collect(),
658            );
659            self.outcomes.lock().unwrap().pop_front().unwrap()
660        }
661
662        fn run_foreground(&self, _command: &CommandSpec) -> io::Result<ExitStatus> {
663            panic!("discovery must not run foreground commands")
664        }
665    }
666
667    fn success(stdout: &str) -> CommandOutcome {
668        CommandOutcome::Exited {
669            status: success_status(),
670            output: CapturedOutput {
671                stdout: stdout.as_bytes().to_vec(),
672                ..CapturedOutput::default()
673            },
674        }
675    }
676
677    #[cfg(unix)]
678    fn success_status() -> ExitStatus {
679        use std::os::unix::process::ExitStatusExt;
680        ExitStatus::from_raw(0)
681    }
682
683    #[cfg(windows)]
684    fn success_status() -> ExitStatus {
685        use std::os::windows::process::ExitStatusExt;
686        ExitStatus::from_raw(0)
687    }
688
689    #[test]
690    fn parses_v1_and_v2_cri_config_paths_and_legacy_keys() {
691        let v1 = parse_effective_config(
692            br#"
693version = 2
694[plugins."io.containerd.grpc.v1.cri".registry]
695  config_path = "/etc/containerd/certs.d"
696  [plugins."io.containerd.grpc.v1.cri".registry.mirrors]
697"#,
698        )
699        .unwrap();
700        assert_eq!(v1.version, Some(2));
701        assert_eq!(
702            v1.registry_config_path,
703            Some(PathBuf::from("/etc/containerd/certs.d"))
704        );
705        assert!(v1.legacy_registry.contains(&LegacyRegistryWarning::Mirrors));
706
707        let v2 = parse_effective_config(
708            br#"
709version = 3
710[plugins."io.containerd.cri.v1.images".registry]
711config_path = "/etc/containerd/certs-v2.d"
712"#,
713        )
714        .unwrap();
715        assert_eq!(
716            v2.registry_config_path,
717            Some(PathBuf::from("/etc/containerd/certs-v2.d"))
718        );
719    }
720
721    #[test]
722    fn current_config_path_wins_while_legacy_warnings_are_unioned() {
723        let config = parse_effective_config(
724            br#"
725version = 3
726[plugins."io.containerd.grpc.v1.cri".registry]
727config_path = "/legacy"
728[plugins."io.containerd.grpc.v1.cri".registry.mirrors]
729[plugins."io.containerd.cri.v1.images".registry]
730config_path = "/current"
731[plugins."io.containerd.cri.v1.images".registry.configs]
732"#,
733        )
734        .unwrap();
735
736        assert_eq!(config.registry_config_path, Some(PathBuf::from("/current")));
737        assert_eq!(
738            config.legacy_registry,
739            BTreeSet::from([
740                LegacyRegistryWarning::Mirrors,
741                LegacyRegistryWarning::Configs,
742            ])
743        );
744    }
745
746    #[test]
747    fn parses_hosts_without_retaining_tls_paths_or_endpoint_secrets() {
748        let parsed = parse_hosts_toml(
749            r#"
750server = "https://user:password@registry.example/private?token=secret"
751[host."https://mirror.example/prefix?key=secret"]
752capabilities = ["pull", "resolve"]
753override_path = true
754skip_verify = true
755ca = ["/secret/ca.pem", "/other/ca.pem"]
756client = [["/secret/cert.pem", "/secret/key.pem"]]
757"#,
758        )
759        .unwrap();
760        assert_eq!(
761            parsed.server.as_ref().unwrap().as_str(),
762            "https://registry.example/[redacted]?redacted"
763        );
764        assert_eq!(parsed.hosts.len(), 1);
765        let host = &parsed.hosts[0];
766        assert!(host.capabilities.contains(&RegistryHostCapability::Pull));
767        assert!(host.capabilities.contains(&RegistryHostCapability::Resolve));
768        assert!(host.override_path);
769        assert!(host.tls.skip_verify);
770        assert_eq!(host.tls.ca_count, 2);
771        assert!(host.tls.client_certificate_configured);
772        assert!(!format!("{parsed:?}").contains("secret"));
773    }
774
775    #[test]
776    fn hosts_toml_preserves_declared_host_priority() {
777        let parsed = parse_hosts_toml(
778            r#"
779[host."https://z-first.example"]
780capabilities = ["pull"]
781[host."https://a-second.example"]
782capabilities = ["pull"]
783"#,
784        )
785        .unwrap();
786
787        assert_eq!(
788            parsed
789                .hosts
790                .iter()
791                .map(|host| host.endpoint.as_str())
792                .collect::<Vec<_>>(),
793            ["https://z-first.example/", "https://a-second.example/"]
794        );
795    }
796
797    #[test]
798    fn discovery_uses_explicit_selectors_and_reports_healthy_server() {
799        let runner = FakeRunner::new(vec![
800            success("containerd github.com/containerd/containerd v1.7.22 abc"),
801            success("Client:\n  Version: v1.7.22\nServer:\n  Version: v1.7.22\n"),
802            success("version = 2\n[plugins.\"io.containerd.grpc.v1.cri\".registry]\nconfig_path = \"/etc/containerd/certs.d\"\n"),
803        ]);
804        let adapter = ContainerdAdapter::new("unix:///custom/containerd.sock", "k8s.io").unwrap();
805        let discovery = adapter.inspect(&runner, CaptureLimits::default());
806        assert_eq!(discovery.report.status, DiagnosticStatus::Healthy);
807        assert_eq!(discovery.namespace, "k8s.io");
808        assert_eq!(discovery.versions.ctr_server, Some(Version::new(1, 7, 22)));
809        assert_eq!(
810            discovery.report.capabilities.get(&Capability::CacheStatus),
811            Some(&CapabilityStatus::Unsupported)
812        );
813        assert_eq!(
814            discovery.report.capabilities.get(&Capability::CachePrune),
815            Some(&CapabilityStatus::Unsupported)
816        );
817        let commands = runner.commands.lock().unwrap();
818        assert_eq!(
819            commands[1],
820            [
821                "ctr",
822                "--address",
823                "unix:///custom/containerd.sock",
824                "--namespace",
825                "k8s.io",
826                "version"
827            ]
828        );
829    }
830
831    #[test]
832    fn remote_address_does_not_probe_or_associate_local_config() {
833        let runner = FakeRunner::new(vec![
834            success("containerd github.com/containerd/containerd v1.7.22 abc"),
835            success("Client:\n  Version: v1.7.22\nServer:\n  Version: v1.7.22\n"),
836        ]);
837        let adapter = ContainerdAdapter::new("tcp://192.0.2.10:1234", "default").unwrap();
838
839        let discovery = adapter.inspect(&runner, CaptureLimits::default());
840
841        assert_eq!(discovery.report.status, DiagnosticStatus::Healthy);
842        assert!(discovery.config.is_none());
843        assert_eq!(
844            discovery
845                .report
846                .capabilities
847                .get(&Capability::RegistryHostMapping),
848            Some(&CapabilityStatus::Unknown)
849        );
850        assert_eq!(runner.commands.lock().unwrap().len(), 2);
851    }
852
853    #[test]
854    fn loopback_tcp_address_is_local_and_may_probe_local_config() {
855        let runner = FakeRunner::new(vec![
856            success("containerd github.com/containerd/containerd v1.7.22 abc"),
857            success("Client:\n  Version: v1.7.22\nServer:\n  Version: v1.7.22\n"),
858            success("version = 2"),
859        ]);
860        let adapter = ContainerdAdapter::new("tcp://127.0.0.1:1234", "default").unwrap();
861
862        let discovery = adapter.inspect(&runner, CaptureLimits::default());
863
864        assert_eq!(
865            discovery.report.endpoints.iter().next().unwrap().scope,
866            EndpointScope::Local
867        );
868        assert!(discovery.config.is_some());
869        assert_eq!(runner.commands.lock().unwrap().len(), 3);
870    }
871
872    #[test]
873    fn failed_ctr_stdout_does_not_establish_server_reachability() {
874        let runner = FakeRunner::new(vec![
875            success("containerd github.com/containerd/containerd v1.7.22 abc"),
876            CommandOutcome::Exited {
877                status: failure_status(),
878                output: CapturedOutput {
879                    stdout: b"Client:\n  Version: v1.7.22\nServer:\n  Version: v1.7.22\n".to_vec(),
880                    stderr: b"failed to dial: connection refused".to_vec(),
881                    ..CapturedOutput::default()
882                },
883            },
884            success("version = 2"),
885        ]);
886
887        let discovery = ContainerdAdapter::default().inspect(&runner, CaptureLimits::default());
888
889        assert_eq!(discovery.report.status, DiagnosticStatus::Unreachable);
890        assert!(discovery.versions.ctr_client.is_none());
891        assert!(discovery.versions.ctr_server.is_none());
892        assert_eq!(
893            discovery.report.capabilities.get(&Capability::Daemon),
894            Some(&CapabilityStatus::Unavailable)
895        );
896    }
897
898    #[test]
899    fn containerd_versions_use_shared_vendor_parser() {
900        assert_eq!(
901            parse_first_version(b"containerd V1.7.22~ds1-1 abc"),
902            Some(Version::new(1, 7, 22))
903        );
904        assert_eq!(parse_first_version(b"containerd 1.2.3.4 abc"), None);
905        assert_eq!(
906            parse_ctr_versions(b"Client:\n Version: V1.7\nServer:\n Version: v1.7.22~ds1-1\n"),
907            (Some(Version::new(1, 7, 0)), Some(Version::new(1, 7, 22)))
908        );
909    }
910
911    #[test]
912    fn platform_default_address_uses_native_transport() {
913        let adapter = ContainerdAdapter::default();
914        #[cfg(windows)]
915        assert_eq!(adapter.address, "npipe:////./pipe/containerd-containerd");
916        #[cfg(not(windows))]
917        assert_eq!(adapter.address, "unix:///run/containerd/containerd.sock");
918    }
919
920    #[test]
921    fn windows_named_pipe_is_a_valid_direct_ctr_selector() {
922        let adapter =
923            ContainerdAdapter::new("npipe:////./pipe/containerd-containerd", "default").unwrap();
924        let command = adapter.ctr_command().arg("version");
925        let arguments = command
926            .arguments()
927            .iter()
928            .map(|value| value.to_string_lossy().into_owned())
929            .collect::<Vec<_>>();
930
931        assert_eq!(
932            arguments,
933            [
934                "--address",
935                "npipe:////./pipe/containerd-containerd",
936                "--namespace",
937                "default",
938                "version",
939            ]
940        );
941        assert_eq!(adapter.address().unwrap().as_str(), "npipe:///[redacted]");
942    }
943
944    #[test]
945    fn rejects_namespace_values_that_could_be_cli_options() {
946        assert!(matches!(
947            ContainerdAdapter::new("unix:///run/containerd/containerd.sock", "--help"),
948            Err(ContainerdParseError::InvalidNamespace)
949        ));
950        assert!(ContainerdAdapter::new("unix:///run/containerd/containerd.sock", "k8s.io").is_ok());
951        assert!(matches!(
952            ContainerdAdapter::new(
953                "tcp://user:secret@host.example:1234/socket?token=x",
954                "default"
955            ),
956            Err(ContainerdParseError::UnsafeEndpoint)
957        ));
958    }
959
960    #[test]
961    fn missing_tools_and_unreachable_daemon_are_distinct() {
962        let missing = FakeRunner::new(vec![
963            CommandOutcome::NotInstalled,
964            CommandOutcome::NotInstalled,
965            CommandOutcome::NotInstalled,
966        ]);
967        assert_eq!(
968            ContainerdAdapter::default()
969                .inspect(&missing, CaptureLimits::default())
970                .report
971                .status,
972            DiagnosticStatus::NotInstalled
973        );
974
975        let unreachable = FakeRunner::new(vec![
976            success("containerd github.com/containerd/containerd v1.7.22 abc"),
977            CommandOutcome::Exited {
978                status: failure_status(),
979                output: CapturedOutput {
980                    stderr: b"failed to dial: connection refused".to_vec(),
981                    ..CapturedOutput::default()
982                },
983            },
984            success("version = 2"),
985        ]);
986        assert_eq!(
987            ContainerdAdapter::default()
988                .inspect(&unreachable, CaptureLimits::default())
989                .report
990                .status,
991            DiagnosticStatus::Unreachable
992        );
993
994        let timed_out = FakeRunner::new(vec![
995            success("containerd github.com/containerd/containerd v1.7.22 abc"),
996            CommandOutcome::TimedOut {
997                output: CapturedOutput::default(),
998                termination: crate::process::TerminationStatus::Requested,
999            },
1000            success("version = 2"),
1001        ]);
1002        assert_eq!(
1003            ContainerdAdapter::default()
1004                .inspect(&timed_out, CaptureLimits::default())
1005                .report
1006                .status,
1007            DiagnosticStatus::Unreachable
1008        );
1009    }
1010
1011    #[cfg(unix)]
1012    fn failure_status() -> ExitStatus {
1013        use std::os::unix::process::ExitStatusExt;
1014        ExitStatus::from_raw(256)
1015    }
1016
1017    #[cfg(windows)]
1018    fn failure_status() -> ExitStatus {
1019        use std::os::windows::process::ExitStatusExt;
1020        ExitStatus::from_raw(1)
1021    }
1022}