1use 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#[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 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#[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 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#[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 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
172pub const NATIVE_PRUNE_PREVIEW_SCHEMA_VERSION: u32 = 2;
174
175#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
177#[serde(rename_all = "kebab-case")]
178pub enum NativePruneScope {
179 DanglingImages,
182 BuildCache,
184}
185
186#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
188#[serde(rename_all = "kebab-case")]
189pub enum NativePruneWarning {
190 MayRemoveStateCreatedOutsideOsdk,
191}
192
193#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
195#[serde(tag = "kind", rename_all = "kebab-case")]
196pub enum NativePruneTarget {
197 DockerContext {
200 name: String,
201 endpoint_fingerprint: Fingerprint,
202 },
203 BuildxBuilder {
206 name: String,
207 topology_fingerprint: Fingerprint,
208 },
209}
210
211#[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#[derive(Debug, PartialEq, Eq)]
228pub struct PruneConfirmation {
229 accepted_preview_id: Fingerprint,
230}
231
232impl PruneConfirmation {
233 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
265#[serde(rename_all = "kebab-case")]
266pub enum PruneUnsupportedReason {
267 NoStableAggregateContainerdPrune,
268 NoImmutableBuildxExecutionTarget,
269}
270
271#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
273pub struct UnsupportedPrune {
274 pub owner: NativeCacheOwner,
275 pub reason: PruneUnsupportedReason,
276}
277
278#[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#[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 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#[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 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#[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}