Skip to main content

osdk_core/container/
operations.rs

1//! Safe construction of exactly-once native pull and prune operations.
2//!
3//! This module only delegates to documented native CLI surfaces. It never
4//! reads credentials, inspects a native private store, invokes a shell, or
5//! retries a command. Pull and executable prune plans produce a
6//! [`ForegroundCommand`], which preserves the native process streams and exit
7//! status. Prune preview is data-only and cannot start a process.
8
9use serde::Serialize;
10
11use super::buildkit::BuildxBuilderSelector;
12use super::cache::NativeCacheOwner;
13use super::containerd::{ContainerdAdapter, ContainerdParseError};
14use super::docker::DockerContext;
15use super::plan::Fingerprint;
16use super::redact::{CommandPurpose, NativeProgram};
17use super::reference::{ImageReference, OciPlatform};
18use super::report::{EndpointScope, EndpointTransport};
19use super::runtime::ForegroundCommand;
20use crate::process::CommandSpec;
21
22/// A Docker Engine image pull built from canonical OCI input.
23#[derive(Clone)]
24pub struct DockerPull {
25    image: ImageReference,
26    platform: Option<OciPlatform>,
27}
28
29impl std::fmt::Debug for DockerPull {
30    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        formatter
32            .debug_struct("DockerPull")
33            .field("image", &"[redacted]")
34            .field("has_platform", &self.platform.is_some())
35            .finish()
36    }
37}
38
39impl DockerPull {
40    pub fn new(image: ImageReference) -> Self {
41        Self {
42            image,
43            platform: None,
44        }
45    }
46
47    pub fn with_platform(mut self, platform: OciPlatform) -> Self {
48        self.platform = Some(platform);
49        self
50    }
51
52    /// Consume this request and build one direct foreground operation.
53    pub fn into_command(self) -> ForegroundCommand {
54        let mut command = CommandSpec::new("docker").args(["image", "pull"]);
55        if let Some(platform) = self.platform {
56            command = command.args(["--platform".to_owned(), platform.to_string()]);
57        }
58        command = command.arg(self.image.to_string());
59        ForegroundCommand::new(NativeProgram::Docker, CommandPurpose::Pull, command)
60    }
61}
62
63/// A validated explicit containerd endpoint and namespace.
64///
65/// The raw values are retained only after [`ContainerdAdapter`] has validated
66/// them. They are needed for the actual `ctr` invocation because the adapter's
67/// public endpoint getter intentionally returns a path-redacted diagnostic
68/// value. This type has no raw `Debug` or serialization surface.
69#[derive(Clone, PartialEq, Eq)]
70struct ContainerdSelectors {
71    address: String,
72    namespace: String,
73}
74
75impl ContainerdSelectors {
76    fn new(
77        address: impl Into<String>,
78        namespace: impl Into<String>,
79    ) -> Result<Self, ContainerdParseError> {
80        let address = address.into();
81        let namespace = namespace.into();
82        // Reject option-like values before they can become process arguments.
83        // ContainerdAdapter remains the single source of truth for the full
84        // endpoint and namespace grammar.
85        if address.starts_with('-') {
86            return Err(ContainerdParseError::InvalidEndpoint);
87        }
88        let endpoint =
89            reqwest::Url::parse(&address).map_err(|_| ContainerdParseError::InvalidEndpoint)?;
90        let supported_scheme = match endpoint.scheme() {
91            "tcp" => true,
92            #[cfg(not(windows))]
93            "unix" => true,
94            #[cfg(windows)]
95            "npipe" => true,
96            _ => false,
97        };
98        if !supported_scheme {
99            return Err(ContainerdParseError::InvalidEndpoint);
100        }
101        ContainerdAdapter::new(address.clone(), namespace.clone())?;
102        Ok(Self { address, namespace })
103    }
104}
105
106impl std::fmt::Debug for ContainerdSelectors {
107    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        formatter
109            .debug_struct("ContainerdSelectors")
110            .field("address", &"[redacted]")
111            .field("namespace", &"[redacted]")
112            .finish()
113    }
114}
115
116/// A containerd image pull bound to an explicit validated daemon and
117/// namespace.
118#[derive(Clone)]
119pub struct ContainerdPull {
120    selectors: ContainerdSelectors,
121    image: ImageReference,
122    platform: Option<OciPlatform>,
123}
124
125impl std::fmt::Debug for ContainerdPull {
126    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127        formatter
128            .debug_struct("ContainerdPull")
129            .field("selectors", &self.selectors)
130            .field("image", &"[redacted]")
131            .field("has_platform", &self.platform.is_some())
132            .finish()
133    }
134}
135
136impl ContainerdPull {
137    pub fn new(
138        address: impl Into<String>,
139        namespace: impl Into<String>,
140        image: ImageReference,
141    ) -> Result<Self, ContainerdParseError> {
142        Ok(Self {
143            selectors: ContainerdSelectors::new(address, namespace)?,
144            image,
145            platform: None,
146        })
147    }
148
149    pub fn with_platform(mut self, platform: OciPlatform) -> Self {
150        self.platform = Some(platform);
151        self
152    }
153
154    /// Consume this request and build one direct foreground operation.
155    pub fn into_command(self) -> ForegroundCommand {
156        let mut command = CommandSpec::new("ctr").args([
157            "--address",
158            self.selectors.address.as_str(),
159            "--namespace",
160            self.selectors.namespace.as_str(),
161            "images",
162            "pull",
163        ]);
164        if let Some(platform) = self.platform {
165            command = command.args(["--platform".to_owned(), platform.to_string()]);
166        }
167        command = command.arg(self.image.to_string());
168        ForegroundCommand::new(NativeProgram::Ctr, CommandPurpose::Pull, command)
169    }
170}
171
172/// Version of the stable native-prune preview contract.
173pub const NATIVE_PRUNE_PREVIEW_SCHEMA_VERSION: u32 = 2;
174
175/// The exact native state category a prune plan is allowed to affect.
176#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
177#[serde(rename_all = "kebab-case")]
178pub enum NativePruneScope {
179    /// Only dangling Docker Engine images. This never expands to all images or
180    /// to containers, volumes, networks, or system-wide state.
181    DanglingImages,
182    /// Unused cache owned by one selected BuildKit builder.
183    BuildCache,
184}
185
186/// Typed warning attached to every supported native prune preview.
187#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
188#[serde(rename_all = "kebab-case")]
189pub enum NativePruneWarning {
190    MayRemoveStateCreatedOutsideOsdk,
191}
192
193/// The exact native target bound into a prune preview and confirmation.
194#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
195#[serde(tag = "kind", rename_all = "kebab-case")]
196pub enum NativePruneTarget {
197    /// The Docker daemon selected by an explicit CLI context name. The
198    /// fingerprint binds the complete discovered endpoint without exposing it.
199    DockerContext {
200        name: String,
201        endpoint_fingerprint: Fingerprint,
202    },
203    /// One explicitly named Buildx builder. The fingerprint binds its driver
204    /// and complete node endpoint topology without exposing raw endpoints.
205    BuildxBuilder {
206        name: String,
207        topology_fingerprint: Fingerprint,
208    },
209}
210
211/// A non-executable description of a narrowly scoped native prune.
212#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
213pub struct PrunePreview {
214    pub schema_version: u32,
215    pub preview_id: Fingerprint,
216    pub owner: NativeCacheOwner,
217    pub scope: NativePruneScope,
218    pub target: NativePruneTarget,
219    pub warning: NativePruneWarning,
220}
221
222/// An explicit caller acknowledgement bound to one exact preview identity.
223///
224/// The CLI should create this only after displaying and confirming the preview.
225/// Core still independently rebuilds the preview and rejects a stale or
226/// mismatched identity before it constructs a mutating command.
227#[derive(Debug, PartialEq, Eq)]
228pub struct PruneConfirmation {
229    accepted_preview_id: Fingerprint,
230}
231
232impl PruneConfirmation {
233    /// Accept the exact immutable identity of a preview already available to
234    /// the caller. Core cannot prove it was displayed, but it does require a
235    /// real preview value rather than a free-standing boolean.
236    pub fn accept(preview: &PrunePreview) -> Result<Self, PrunePlanError> {
237        if preview.semantic_id()? != preview.preview_id {
238            return Err(PrunePlanError::PreviewNotAccepted);
239        }
240        Ok(Self {
241            accepted_preview_id: preview.preview_id.clone(),
242        })
243    }
244
245    pub fn accepted_preview_id(&self) -> &Fingerprint {
246        &self.accepted_preview_id
247    }
248}
249
250impl PrunePreview {
251    fn semantic_id(&self) -> Result<Fingerprint, PrunePlanError> {
252        Fingerprint::for_canonical(&UnsignedPrunePreview {
253            schema_version: self.schema_version,
254            owner: self.owner,
255            scope: self.scope,
256            target: &self.target,
257            warning: self.warning,
258        })
259        .map_err(|_| PrunePlanError::PreviewIdentity)
260    }
261}
262
263/// Why a native owner has no executable prune plan.
264#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
265#[serde(rename_all = "kebab-case")]
266pub enum PruneUnsupportedReason {
267    NoStableAggregateContainerdPrune,
268    NoImmutableBuildxExecutionTarget,
269}
270
271/// Typed, non-executable unsupported result.
272#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
273pub struct UnsupportedPrune {
274    pub owner: NativeCacheOwner,
275    pub reason: PruneUnsupportedReason,
276}
277
278/// A structural prune-plan error that never retains native selectors or
279/// command output.
280#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
281pub enum PrunePlanError {
282    #[error("invalid Docker context selector")]
283    InvalidDockerContext,
284    #[error("Buildx prune requires an explicit validated builder")]
285    ExplicitBuildxBuilderRequired,
286    #[error("invalid Buildx builder selector")]
287    InvalidBuildxBuilder,
288    #[error("native prune target identity is unavailable")]
289    TargetIdentityUnavailable,
290    #[error("Docker prune requires a direct local unix endpoint without context TLS material")]
291    UnsupportedDockerEndpoint,
292    #[error("Buildx prune execution is unsupported because builder names are mutable")]
293    BuildxExecutionUnsupported,
294    #[error("prune preview identity could not be generated")]
295    PreviewIdentity,
296    #[error("accepted prune preview does not match the current request")]
297    PreviewNotAccepted,
298}
299
300#[derive(Serialize)]
301struct UnsignedPrunePreview<'a> {
302    schema_version: u32,
303    owner: NativeCacheOwner,
304    scope: NativePruneScope,
305    target: &'a NativePruneTarget,
306    warning: NativePruneWarning,
307}
308
309fn prune_preview(
310    owner: NativeCacheOwner,
311    scope: NativePruneScope,
312    target: NativePruneTarget,
313) -> Result<PrunePreview, PrunePlanError> {
314    let unsigned = UnsignedPrunePreview {
315        schema_version: NATIVE_PRUNE_PREVIEW_SCHEMA_VERSION,
316        owner,
317        scope,
318        target: &target,
319        warning: NativePruneWarning::MayRemoveStateCreatedOutsideOsdk,
320    };
321    let preview_id =
322        Fingerprint::for_canonical(&unsigned).map_err(|_| PrunePlanError::PreviewIdentity)?;
323    Ok(PrunePreview {
324        schema_version: NATIVE_PRUNE_PREVIEW_SCHEMA_VERSION,
325        preview_id,
326        owner,
327        scope,
328        target,
329        warning: NativePruneWarning::MayRemoveStateCreatedOutsideOsdk,
330    })
331}
332
333fn validate_confirmation(
334    preview: &PrunePreview,
335    confirmation: &PruneConfirmation,
336) -> Result<(), PrunePlanError> {
337    if preview.semantic_id()? != preview.preview_id
338        || confirmation.accepted_preview_id() != &preview.preview_id
339    {
340        return Err(PrunePlanError::PreviewNotAccepted);
341    }
342    Ok(())
343}
344
345fn is_safe_target_name(value: &str) -> bool {
346    value.len() <= 128
347        && value
348            .bytes()
349            .next()
350            .is_some_and(|byte| byte.is_ascii_alphanumeric())
351        && value
352            .bytes()
353            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
354}
355
356/// Narrow Docker Engine image pruning.
357///
358/// Execution delegates only to `docker image prune --force`, whose default
359/// scope is dangling images. There is intentionally no `--all`, `system
360/// prune`, or access to containers, volumes, or networks.
361#[derive(Clone)]
362pub struct DockerImagePrune {
363    context: String,
364    endpoint: String,
365    endpoint_fingerprint: Fingerprint,
366}
367
368impl std::fmt::Debug for DockerImagePrune {
369    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
370        formatter
371            .debug_struct("DockerImagePrune")
372            .field("context", &"explicit")
373            .field("endpoint", &"[redacted]")
374            .finish()
375    }
376}
377
378impl DockerImagePrune {
379    /// Construct an executable prune from one discovered Docker context. The
380    /// raw endpoint stays private and only local socket transports without
381    /// context-held TLS behavior are accepted.
382    pub fn from_context(context: DockerContext) -> Result<Self, PrunePlanError> {
383        let parts = context
384            .into_prune_parts()
385            .ok_or(PrunePlanError::TargetIdentityUnavailable)?;
386        Self::new(
387            parts.name,
388            parts.raw_endpoint,
389            parts.transport,
390            parts.scope,
391            parts.skip_tls_verify,
392            parts.has_tls_material,
393        )
394    }
395
396    fn new(
397        context: impl Into<String>,
398        endpoint: impl Into<String>,
399        transport: EndpointTransport,
400        scope: EndpointScope,
401        skip_tls_verify: bool,
402        has_tls_material: bool,
403    ) -> Result<Self, PrunePlanError> {
404        let context = context.into();
405        if !is_safe_target_name(&context) {
406            return Err(PrunePlanError::InvalidDockerContext);
407        }
408        if scope != EndpointScope::Local
409            || skip_tls_verify
410            || has_tls_material
411            || !matches!(
412                transport,
413                EndpointTransport::LocalSocket | EndpointTransport::NamedPipe
414            )
415        {
416            return Err(PrunePlanError::UnsupportedDockerEndpoint);
417        }
418        let endpoint = endpoint.into();
419        let parsed = reqwest::Url::parse(&endpoint)
420            .map_err(|_| PrunePlanError::UnsupportedDockerEndpoint)?;
421        let valid_scheme = match transport {
422            EndpointTransport::LocalSocket => {
423                parsed.scheme() == "unix"
424                    && parsed.host_str().is_none()
425                    && parsed.path().starts_with('/')
426                    && parsed.path() != "/"
427            }
428            EndpointTransport::NamedPipe => is_canonical_local_named_pipe(&endpoint),
429            _ => false,
430        };
431        if !valid_scheme
432            || !parsed.username().is_empty()
433            || parsed.password().is_some()
434            || parsed.query().is_some()
435            || parsed.fragment().is_some()
436        {
437            return Err(PrunePlanError::UnsupportedDockerEndpoint);
438        }
439        let mut identity = b"osdk-docker-prune-endpoint-v1\0".to_vec();
440        identity.extend_from_slice(endpoint.as_bytes());
441        Ok(Self {
442            context,
443            endpoint,
444            endpoint_fingerprint: Fingerprint::for_bytes(&identity),
445        })
446    }
447
448    pub fn preview(&self) -> Result<PrunePreview, PrunePlanError> {
449        prune_preview(
450            NativeCacheOwner::DockerEngine,
451            NativePruneScope::DanglingImages,
452            NativePruneTarget::DockerContext {
453                name: self.context.clone(),
454                endpoint_fingerprint: self.endpoint_fingerprint.clone(),
455            },
456        )
457    }
458
459    pub fn execute(
460        &self,
461        confirmation: PruneConfirmation,
462    ) -> Result<ForegroundCommand, PrunePlanError> {
463        let preview = self.preview()?;
464        validate_confirmation(&preview, &confirmation)?;
465        Ok(ForegroundCommand::new(
466            NativeProgram::Docker,
467            CommandPurpose::Prune,
468            CommandSpec::new("docker").args([
469                "--host",
470                self.endpoint.as_str(),
471                "image",
472                "prune",
473                "--force",
474            ]),
475        ))
476    }
477}
478
479fn is_canonical_local_named_pipe(endpoint: &str) -> bool {
480    let Some(name) = endpoint.strip_prefix("npipe:////./pipe/") else {
481        return false;
482    };
483    !name.is_empty()
484        && name.len() <= 128
485        && name
486            .bytes()
487            .next()
488            .is_some_and(|byte| byte.is_ascii_alphanumeric())
489        && name
490            .bytes()
491            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
492}
493
494/// Narrow BuildKit cache pruning through a validated Buildx selector.
495#[derive(Clone)]
496pub struct BuildxPrune {
497    selector: BuildxBuilderSelector,
498    topology_fingerprint: Fingerprint,
499}
500
501impl std::fmt::Debug for BuildxPrune {
502    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
503        formatter
504            .debug_struct("BuildxPrune")
505            .field(
506                "selector",
507                &self.selector.as_name().map(|_| "named").unwrap_or("auto"),
508            )
509            .finish()
510    }
511}
512
513impl BuildxPrune {
514    pub fn new(
515        selector: BuildxBuilderSelector,
516        topology_fingerprint: Fingerprint,
517    ) -> Result<Self, PrunePlanError> {
518        let name = selector
519            .as_name()
520            .ok_or(PrunePlanError::ExplicitBuildxBuilderRequired)?;
521        // `Named(String)` is a public compatibility variant, so reconstruct it
522        // through the validating constructor rather than trusting provenance.
523        let selector = BuildxBuilderSelector::named(name.to_owned())
524            .map_err(|_| PrunePlanError::InvalidBuildxBuilder)?;
525        Ok(Self {
526            selector,
527            topology_fingerprint,
528        })
529    }
530
531    pub fn selector(&self) -> &BuildxBuilderSelector {
532        &self.selector
533    }
534
535    pub fn preview(&self) -> Result<PrunePreview, PrunePlanError> {
536        prune_preview(
537            NativeCacheOwner::BuildkitBuilder,
538            NativePruneScope::BuildCache,
539            NativePruneTarget::BuildxBuilder {
540                name: self
541                    .selector
542                    .as_name()
543                    .expect("BuildxPrune always has an explicit builder")
544                    .to_owned(),
545                topology_fingerprint: self.topology_fingerprint.clone(),
546            },
547        )
548    }
549
550    pub fn execute(
551        &self,
552        _confirmation: PruneConfirmation,
553    ) -> Result<ForegroundCommand, PrunePlanError> {
554        Err(PrunePlanError::BuildxExecutionUnsupported)
555    }
556
557    pub fn unsupported(&self) -> UnsupportedPrune {
558        UnsupportedPrune {
559            owner: NativeCacheOwner::BuildkitBuilder,
560            reason: PruneUnsupportedReason::NoImmutableBuildxExecutionTarget,
561        }
562    }
563}
564
565/// Explicitly unsupported containerd aggregate pruning.
566///
567/// containerd cache ownership is split across namespaces, content, snapshots,
568/// images, and leases. This builder never guesses an aggregate command and
569/// never scans containerd's private storage.
570#[derive(Clone, Copy, Debug, Default)]
571pub struct ContainerdPrune;
572
573impl ContainerdPrune {
574    pub fn preview(&self) -> UnsupportedPrune {
575        UnsupportedPrune {
576            owner: NativeCacheOwner::Containerd,
577            reason: PruneUnsupportedReason::NoStableAggregateContainerdPrune,
578        }
579    }
580
581    pub fn execute(&self, _confirmation: PruneConfirmation) -> UnsupportedPrune {
582        self.preview()
583    }
584}
585
586#[cfg(test)]
587mod tests {
588    use std::io;
589    use std::process::ExitStatus;
590    use std::sync::Mutex;
591
592    use super::*;
593    use crate::process::{CaptureLimits, CommandOutcome, CommandRunner};
594
595    #[derive(Clone, Debug, PartialEq, Eq)]
596    struct Call {
597        program: String,
598        arguments: Vec<String>,
599        environment_count: usize,
600        has_working_directory: bool,
601    }
602
603    struct FakeRunner {
604        status: ExitStatus,
605        calls: Mutex<Vec<Call>>,
606    }
607
608    impl FakeRunner {
609        fn with_status(status: ExitStatus) -> Self {
610            Self {
611                status,
612                calls: Mutex::new(Vec::new()),
613            }
614        }
615
616        fn calls(&self) -> Vec<Call> {
617            self.calls.lock().unwrap().clone()
618        }
619    }
620
621    impl CommandRunner for FakeRunner {
622        fn run_captured(&self, _command: &CommandSpec, _limits: CaptureLimits) -> CommandOutcome {
623            panic!("native operations must not capture or probe")
624        }
625
626        fn run_foreground(&self, command: &CommandSpec) -> io::Result<ExitStatus> {
627            self.calls.lock().unwrap().push(Call {
628                program: command.program().to_string_lossy().into_owned(),
629                arguments: command
630                    .arguments()
631                    .iter()
632                    .map(|argument| argument.to_string_lossy().into_owned())
633                    .collect(),
634                environment_count: command.environment().len(),
635                has_working_directory: command.working_directory().is_some(),
636            });
637            Ok(self.status)
638        }
639    }
640
641    #[cfg(unix)]
642    fn exit_status(code: i32) -> ExitStatus {
643        use std::os::unix::process::ExitStatusExt;
644        ExitStatus::from_raw(code << 8)
645    }
646
647    #[cfg(windows)]
648    fn exit_status(code: i32) -> ExitStatus {
649        use std::os::windows::process::ExitStatusExt;
650        ExitStatus::from_raw(code as u32)
651    }
652
653    fn image(value: &str) -> ImageReference {
654        ImageReference::parse(value).unwrap()
655    }
656
657    fn platform(value: &str) -> OciPlatform {
658        OciPlatform::parse(value).unwrap()
659    }
660
661    fn identity(value: &str) -> Fingerprint {
662        Fingerprint::for_bytes(value.as_bytes())
663    }
664
665    fn local_docker_prune(context: &str, endpoint: &str) -> DockerImagePrune {
666        DockerImagePrune::new(
667            context,
668            endpoint,
669            if endpoint.starts_with("npipe:") {
670                EndpointTransport::NamedPipe
671            } else {
672                EndpointTransport::LocalSocket
673            },
674            EndpointScope::Local,
675            false,
676            false,
677        )
678        .unwrap()
679    }
680
681    #[cfg(not(windows))]
682    fn explicit_containerd_address() -> &'static str {
683        "unix:///run/private/containerd.sock"
684    }
685
686    #[cfg(windows)]
687    fn explicit_containerd_address() -> &'static str {
688        "npipe:////./pipe/private-containerd"
689    }
690
691    fn assert_direct(call: &Call) {
692        assert!(call.environment_count == 0);
693        assert!(!call.has_working_directory);
694        assert!(!matches!(call.program.as_str(), "sh" | "bash" | "cmd"));
695    }
696
697    #[test]
698    fn docker_pull_uses_canonical_image_and_platform_in_one_foreground_call() {
699        let operation = DockerPull::new(image("ubuntu:24.04"))
700            .with_platform(platform("Linux/X64"))
701            .into_command();
702        let evidence = serde_json::to_string(operation.evidence()).unwrap();
703        for secret in ["ubuntu", "24.04", "linux/amd64"] {
704            assert!(!evidence.contains(secret), "leaked {secret}: {evidence}");
705        }
706        assert!(evidence.contains("\"program\":\"docker\""));
707        assert!(evidence.contains("\"purpose\":\"pull\""));
708        assert_eq!(operation.evidence().argument_count(), 5);
709
710        let runner = FakeRunner::with_status(exit_status(0));
711        assert!(operation.execute(&runner).unwrap().success());
712
713        let calls = runner.calls();
714        assert_eq!(calls.len(), 1);
715        assert_eq!(calls[0].program, "docker");
716        assert_eq!(
717            calls[0].arguments,
718            [
719                "image",
720                "pull",
721                "--platform",
722                "linux/amd64",
723                "docker.io/library/ubuntu:24.04",
724            ]
725        );
726        assert_direct(&calls[0]);
727    }
728
729    #[test]
730    fn docker_pull_without_platform_has_no_platform_flag() {
731        let runner = FakeRunner::with_status(exit_status(0));
732        DockerPull::new(image("GHCR.IO/example/tool:v1"))
733            .into_command()
734            .execute(&runner)
735            .unwrap();
736
737        let calls = runner.calls();
738        assert_eq!(calls.len(), 1);
739        assert_eq!(
740            calls[0].arguments,
741            ["image", "pull", "ghcr.io/example/tool:v1"]
742        );
743        assert!(!calls[0]
744            .arguments
745            .iter()
746            .any(|argument| argument == "--platform"));
747    }
748
749    #[test]
750    fn docker_pull_does_not_inherit_prune_context_selection() {
751        let runner = FakeRunner::with_status(exit_status(0));
752        DockerPull::new(image("alpine:3"))
753            .into_command()
754            .execute(&runner)
755            .unwrap();
756        assert!(!runner.calls()[0]
757            .arguments
758            .iter()
759            .any(|argument| argument == "--context"));
760    }
761
762    #[test]
763    fn containerd_pull_keeps_validated_raw_selectors_only_in_execution() {
764        let operation = ContainerdPull::new(
765            explicit_containerd_address(),
766            "k8s.io",
767            image("registry.example/team/app@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
768        )
769        .unwrap()
770        .with_platform(platform("linux/arm64/v8"))
771        .into_command();
772
773        let evidence = serde_json::to_string(operation.evidence()).unwrap();
774        for secret in ["private", "k8s.io", "registry.example", "linux/arm64/v8"] {
775            assert!(!evidence.contains(secret), "leaked {secret}: {evidence}");
776        }
777        assert!(evidence.contains("\"program\":\"ctr\""));
778        assert_eq!(operation.evidence().argument_count(), 9);
779
780        let runner = FakeRunner::with_status(exit_status(0));
781        operation.execute(&runner).unwrap();
782        let calls = runner.calls();
783        assert_eq!(calls.len(), 1);
784        assert_eq!(calls[0].program, "ctr");
785        assert_eq!(calls[0].arguments[0], "--address");
786        assert_eq!(calls[0].arguments[1], explicit_containerd_address());
787        assert_eq!(
788            &calls[0].arguments[2..],
789            [
790                "--namespace",
791                "k8s.io",
792                "images",
793                "pull",
794                "--platform",
795                "linux/arm64/v8",
796                "registry.example/team/app@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
797            ]
798        );
799        assert_direct(&calls[0]);
800    }
801
802    #[test]
803    fn containerd_rejects_option_like_or_credential_bearing_selectors() {
804        let rejected_address =
805            ContainerdPull::new("--address=unix:///evil", "default", image("alpine:3"))
806                .unwrap_err();
807        assert!(matches!(
808            rejected_address,
809            ContainerdParseError::InvalidEndpoint
810        ));
811        let rejected_namespace = ContainerdPull::new(
812            explicit_containerd_address(),
813            "--namespace",
814            image("alpine:3"),
815        )
816        .unwrap_err();
817        assert!(matches!(
818            rejected_namespace,
819            ContainerdParseError::InvalidNamespace
820        ));
821        let credential_error = ContainerdPull::new(
822            "tcp://user:top-secret@example.test:1234",
823            "default",
824            image("alpine:3"),
825        )
826        .unwrap_err();
827        assert!(matches!(
828            credential_error,
829            ContainerdParseError::UnsafeEndpoint
830        ));
831        assert!(!credential_error.to_string().contains("top-secret"));
832        for unsupported in [
833            "http://127.0.0.1:1234",
834            "https://containerd.example.test",
835            "ssh://containerd.example.test",
836        ] {
837            assert!(matches!(
838                ContainerdPull::new(unsupported, "default", image("alpine:3")),
839                Err(ContainerdParseError::InvalidEndpoint)
840            ));
841        }
842
843        #[cfg(not(windows))]
844        assert!(matches!(
845            ContainerdPull::new(
846                "npipe:////./pipe/containerd-containerd",
847                "default",
848                image("alpine:3")
849            ),
850            Err(ContainerdParseError::InvalidEndpoint)
851        ));
852        #[cfg(windows)]
853        assert!(matches!(
854            ContainerdPull::new(
855                "unix:///run/containerd/containerd.sock",
856                "default",
857                image("alpine:3")
858            ),
859            Err(ContainerdParseError::InvalidEndpoint)
860        ));
861    }
862
863    #[cfg(windows)]
864    #[test]
865    fn containerd_windows_named_pipe_is_retained_in_execution_only() {
866        let operation = ContainerdPull::new(
867            "npipe:////./pipe/private-containerd",
868            "default",
869            image("alpine:3"),
870        )
871        .unwrap()
872        .into_command();
873        let evidence = serde_json::to_string(operation.evidence()).unwrap();
874        assert!(!evidence.contains("private-containerd"));
875
876        let runner = FakeRunner::with_status(exit_status(0));
877        operation.execute(&runner).unwrap();
878        assert_eq!(
879            runner.calls()[0].arguments,
880            [
881                "--address",
882                "npipe:////./pipe/private-containerd",
883                "--namespace",
884                "default",
885                "images",
886                "pull",
887                "docker.io/library/alpine:3",
888            ]
889        );
890    }
891
892    #[test]
893    fn a_nonzero_pull_exit_is_returned_without_retry() {
894        let runner = FakeRunner::with_status(exit_status(23));
895        let status = DockerPull::new(image("alpine:3"))
896            .into_command()
897            .execute(&runner)
898            .unwrap();
899
900        assert_eq!(status.code(), Some(23));
901        assert_eq!(runner.calls().len(), 1);
902    }
903
904    #[test]
905    fn docker_prune_preview_is_target_bound_and_contains_no_cache_totals() {
906        let prune = local_docker_prune("desktop-linux", "unix:///run/docker.sock");
907        let preview = prune.preview().unwrap();
908        let endpoint_fingerprint = match &preview.target {
909            NativePruneTarget::DockerContext {
910                endpoint_fingerprint,
911                ..
912            } => endpoint_fingerprint.clone(),
913            _ => unreachable!(),
914        };
915
916        assert_eq!(preview.schema_version, NATIVE_PRUNE_PREVIEW_SCHEMA_VERSION);
917        assert_eq!(preview.owner, NativeCacheOwner::DockerEngine);
918        assert_eq!(preview.scope, NativePruneScope::DanglingImages);
919        assert_eq!(
920            preview.target,
921            NativePruneTarget::DockerContext {
922                name: "desktop-linux".to_owned(),
923                endpoint_fingerprint,
924            }
925        );
926        assert_eq!(
927            preview.warning,
928            NativePruneWarning::MayRemoveStateCreatedOutsideOsdk
929        );
930        let serialized = serde_json::to_string(&preview).unwrap();
931        assert!(serialized.contains("desktop-linux"));
932        for excluded in ["cache", "total", "reclaimable", "containers", "volumes"] {
933            assert!(
934                !serialized.contains(excluded),
935                "leaked {excluded}: {serialized}"
936            );
937        }
938    }
939
940    #[test]
941    fn docker_image_prune_requires_matching_preview_and_executes_exactly_once() {
942        let prune = local_docker_prune("desktop-linux", "unix:///run/docker.sock");
943        let preview = prune.preview().unwrap();
944        let confirmation = PruneConfirmation::accept(&preview).unwrap();
945        let operation = prune.execute(confirmation).unwrap();
946        let evidence = serde_json::to_string(operation.evidence()).unwrap();
947        assert!(evidence.contains("\"purpose\":\"prune\""));
948        assert!(!evidence.contains("--force"));
949        assert!(!evidence.contains("desktop-linux"));
950
951        let runner = FakeRunner::with_status(exit_status(19));
952        let status = operation.execute(&runner).unwrap();
953        assert_eq!(status.code(), Some(19));
954        let calls = runner.calls();
955        assert_eq!(calls.len(), 1);
956        assert_eq!(calls[0].program, "docker");
957        assert_eq!(
958            calls[0].arguments,
959            [
960                "--host",
961                "unix:///run/docker.sock",
962                "image",
963                "prune",
964                "--force",
965            ]
966        );
967        assert_direct(&calls[0]);
968        for forbidden in [
969            "--context",
970            "system",
971            "--all",
972            "-a",
973            "volumes",
974            "containers",
975            "networks",
976        ] {
977            assert!(!calls[0]
978                .arguments
979                .iter()
980                .any(|argument| argument == forbidden));
981        }
982    }
983
984    #[test]
985    fn docker_prune_rejects_unsafe_contexts_and_mutated_previews() {
986        for invalid in ["", "--context", "team/context", "context with space"] {
987            assert_eq!(
988                DockerImagePrune::new(
989                    invalid,
990                    "unix:///run/docker.sock",
991                    EndpointTransport::LocalSocket,
992                    EndpointScope::Local,
993                    false,
994                    false,
995                )
996                .unwrap_err(),
997                PrunePlanError::InvalidDockerContext
998            );
999        }
1000
1001        let prune = local_docker_prune("desktop-linux", "unix:///run/docker.sock");
1002        let mut preview = prune.preview().unwrap();
1003        preview.scope = NativePruneScope::BuildCache;
1004        assert_eq!(
1005            PruneConfirmation::accept(&preview).unwrap_err(),
1006            PrunePlanError::PreviewNotAccepted
1007        );
1008    }
1009
1010    #[test]
1011    fn docker_prune_rejects_endpoints_that_cannot_be_pinned_without_context_state() {
1012        for (endpoint, transport, scope, skip_tls, has_tls) in [
1013            (
1014                "ssh://builder.example",
1015                EndpointTransport::Ssh,
1016                EndpointScope::Remote,
1017                false,
1018                false,
1019            ),
1020            (
1021                "tcp://127.0.0.1:2375",
1022                EndpointTransport::Tcp,
1023                EndpointScope::Local,
1024                false,
1025                false,
1026            ),
1027            (
1028                "npipe:////server/pipe/docker_engine",
1029                EndpointTransport::NamedPipe,
1030                EndpointScope::Local,
1031                false,
1032                false,
1033            ),
1034            (
1035                "npipe:////./pipe/docker/engine",
1036                EndpointTransport::NamedPipe,
1037                EndpointScope::Local,
1038                false,
1039                false,
1040            ),
1041            (
1042                "npipe:////./pipe/",
1043                EndpointTransport::NamedPipe,
1044                EndpointScope::Local,
1045                false,
1046                false,
1047            ),
1048            (
1049                "unix:///run/docker.sock",
1050                EndpointTransport::LocalSocket,
1051                EndpointScope::Local,
1052                true,
1053                false,
1054            ),
1055            (
1056                "unix:///run/docker.sock",
1057                EndpointTransport::LocalSocket,
1058                EndpointScope::Local,
1059                false,
1060                true,
1061            ),
1062        ] {
1063            assert_eq!(
1064                DockerImagePrune::new("context", endpoint, transport, scope, skip_tls, has_tls,)
1065                    .unwrap_err(),
1066                PrunePlanError::UnsupportedDockerEndpoint
1067            );
1068        }
1069
1070        let local_pipe = DockerImagePrune::new(
1071            "default",
1072            "npipe:////./pipe/docker_engine",
1073            EndpointTransport::NamedPipe,
1074            EndpointScope::Local,
1075            false,
1076            false,
1077        )
1078        .unwrap();
1079        let operation = local_pipe
1080            .execute(PruneConfirmation::accept(&local_pipe.preview().unwrap()).unwrap())
1081            .unwrap();
1082        let runner = FakeRunner::with_status(exit_status(0));
1083        operation.execute(&runner).unwrap();
1084        assert_eq!(
1085            runner.calls()[0].arguments,
1086            [
1087                "--host",
1088                "npipe:////./pipe/docker_engine",
1089                "image",
1090                "prune",
1091                "--force",
1092            ]
1093        );
1094    }
1095
1096    #[test]
1097    fn preview_id_is_deterministic_and_binds_target() {
1098        let prune = local_docker_prune("desktop-linux", "unix:///run/docker.sock");
1099        let first = prune.preview().unwrap();
1100        let second = prune.preview().unwrap();
1101        assert_eq!(first.preview_id, second.preview_id);
1102        assert_ne!(
1103            first.preview_id,
1104            local_docker_prune("another-context", "unix:///run/docker.sock")
1105                .preview()
1106                .unwrap()
1107                .preview_id
1108        );
1109        assert_ne!(
1110            first.preview_id,
1111            local_docker_prune("desktop-linux", "unix:///run/other.sock")
1112                .preview()
1113                .unwrap()
1114                .preview_id
1115        );
1116    }
1117
1118    #[test]
1119    fn buildx_prune_uses_only_the_validated_selected_builder() {
1120        let selector = BuildxBuilderSelector::named("team.private-builder").unwrap();
1121        let topology_fingerprint = identity("builder-topology-a");
1122        let prune = BuildxPrune::new(selector.clone(), topology_fingerprint.clone()).unwrap();
1123        assert_eq!(prune.selector(), &selector);
1124        let preview = prune.preview().unwrap();
1125        assert_eq!(
1126            preview.target,
1127            NativePruneTarget::BuildxBuilder {
1128                name: "team.private-builder".to_owned(),
1129                topology_fingerprint,
1130            }
1131        );
1132        assert_eq!(
1133            prune
1134                .execute(PruneConfirmation::accept(&preview).unwrap())
1135                .unwrap_err(),
1136            PrunePlanError::BuildxExecutionUnsupported
1137        );
1138    }
1139
1140    #[test]
1141    fn buildx_prune_revalidates_and_requires_an_explicit_builder() {
1142        assert_eq!(
1143            BuildxPrune::new(BuildxBuilderSelector::Auto, identity("topology")).unwrap_err(),
1144            PrunePlanError::ExplicitBuildxBuilderRequired
1145        );
1146        assert_eq!(
1147            BuildxPrune::new(
1148                BuildxBuilderSelector::Named("--all".to_owned()),
1149                identity("topology")
1150            )
1151            .unwrap_err(),
1152            PrunePlanError::InvalidBuildxBuilder
1153        );
1154        assert_eq!(
1155            BuildxPrune::new(
1156                BuildxBuilderSelector::Named(String::new()),
1157                identity("topology")
1158            )
1159            .unwrap_err(),
1160            PrunePlanError::InvalidBuildxBuilder
1161        );
1162    }
1163
1164    #[test]
1165    fn docker_prune_confirmation_rejects_changed_target() {
1166        let first = local_docker_prune("first", "unix:///run/docker.sock");
1167        let preview = first.preview().unwrap();
1168        let confirmation = PruneConfirmation::accept(&preview).unwrap();
1169        let second = local_docker_prune("second", "unix:///run/docker.sock");
1170        assert!(matches!(
1171            second.execute(confirmation),
1172            Err(PrunePlanError::PreviewNotAccepted)
1173        ));
1174
1175        let first = local_docker_prune("same", "unix:///run/first.sock");
1176        let confirmation = PruneConfirmation::accept(&first.preview().unwrap()).unwrap();
1177        let retargeted = local_docker_prune("same", "unix:///run/second.sock");
1178        assert!(matches!(
1179            retargeted.execute(confirmation),
1180            Err(PrunePlanError::PreviewNotAccepted)
1181        ));
1182    }
1183
1184    #[test]
1185    fn containerd_prune_is_always_explicitly_unsupported() {
1186        let unsupported = ContainerdPrune.preview();
1187        assert_eq!(unsupported.owner, NativeCacheOwner::Containerd);
1188        assert_eq!(
1189            unsupported.reason,
1190            PruneUnsupportedReason::NoStableAggregateContainerdPrune
1191        );
1192    }
1193}