Skip to main content

osdk_core/container/
mirror.rs

1//! Read-only semantic mirror planners for native container control planes.
2//!
3//! The planners consume already validated osdk policy plus typed discovery.
4//! They return versioned fingerprints and in-memory candidate bytes, but never
5//! write, restart, recreate, or elevate a native runtime.
6
7use std::collections::BTreeSet;
8use std::path::{Path, PathBuf};
9
10use serde::Serialize;
11use toml_edit::{Array, DocumentMut, Item, Table};
12
13use super::buildkit::{BuilderDriver, BuildkitDiscovery};
14use super::containerd::ContainerdDiscovery;
15use super::docker::{DockerContextKind, DockerDiscovery};
16use super::plan::{
17    policy_fingerprint, ActivationRequirement, BuildkitTargetDriver, DockerTargetKind,
18    EffectiveResolution, Fingerprint, MirrorChange, MirrorPlanBundle, MirrorPlanDraft,
19    MirrorPlanTarget, NativeConfigCandidate, NativeConfigFormat, NativeConfigSnapshot,
20    PlanApplicability, PlanError, PlanWarning, PlannedCapability, PlannedMirrorEndpoint,
21    RequiredPrivilege, ValidationStep,
22};
23use super::reference::RegistryName;
24use crate::config::{ContainerRegistryConfig, ContainerResolve};
25
26/// Operator-documented public pull-through caches that osdk can benchmark for
27/// Docker Hub when the user has not supplied an explicit policy. An explicit
28/// `[containers.registries."docker.io"]` table always replaces this list.
29pub const BUILTIN_DOCKER_HUB_MIRRORS: [&str; 2] =
30    ["https://mirror.gcr.io/", "https://docker.m.daocloud.io/"];
31
32/// A small public image used to compare Docker Hub mirrors by content and by
33/// a bounded layer range. It is never pulled into an osdk-owned image store.
34pub const DOCKER_HUB_BENCHMARK_IMAGE: &str = "docker.io/library/alpine:latest";
35pub const DOCKER_HUB_BENCHMARK_PLATFORM: &str = "linux/amd64";
36
37pub fn builtin_mirror_policy(registry: &RegistryName) -> Option<ContainerRegistryConfig> {
38    registry.is_docker_hub().then(|| ContainerRegistryConfig {
39        mirrors: BUILTIN_DOCKER_HUB_MIRRORS
40            .iter()
41            .map(|mirror| (*mirror).to_owned())
42            .collect(),
43        anonymous_only: true,
44        resolve: ContainerResolve::Mirror,
45    })
46}
47
48pub struct DockerMirrorPlanRequest<'a> {
49    pub registry: &'a RegistryName,
50    pub policy: &'a ContainerRegistryConfig,
51    pub discovery: &'a DockerDiscovery,
52    /// Exact explicitly selected daemon JSON, when local and unambiguous.
53    pub native_config: Option<&'a NativeConfigSnapshot>,
54}
55
56pub struct ContainerdMirrorPlanRequest<'a> {
57    pub registry: &'a RegistryName,
58    pub policy: &'a ContainerRegistryConfig,
59    pub discovery: &'a ContainerdDiscovery,
60    /// Exact `<config_path>/<registry>/hosts.toml` target.
61    pub hosts_config: Option<&'a NativeConfigSnapshot>,
62    /// Explicit main containerd config, required only when `config_path` is absent.
63    pub main_config: Option<&'a NativeConfigSnapshot>,
64}
65
66pub struct BuildkitMirrorPlanRequest<'a> {
67    pub registry: &'a RegistryName,
68    pub policy: &'a ContainerRegistryConfig,
69    pub discovery: &'a BuildkitDiscovery,
70    /// Explicit local `buildkitd.toml` associated with the selected builder.
71    pub native_config: Option<&'a NativeConfigSnapshot>,
72}
73
74pub fn plan_docker_mirrors(
75    request: DockerMirrorPlanRequest<'_>,
76) -> Result<MirrorPlanBundle, MirrorPlanError> {
77    let policy_fingerprint = policy_fingerprint(request.registry, request.policy)?;
78    let target = docker_target(request.discovery)?;
79    let mut warnings = BTreeSet::new();
80    let mut applicability = docker_applicability(request.discovery, &mut warnings);
81    let mut changes = Vec::new();
82    let mut candidates = Vec::new();
83    let mut inputs = Vec::new();
84
85    if request.policy.anonymous_only {
86        warnings.insert(PlanWarning::AnonymousOnlyNotEnforced);
87    }
88    if !request.registry.is_docker_hub() {
89        applicability = PlanApplicability::Unsupported;
90        warnings.insert(PlanWarning::DockerHubOnly);
91    } else {
92        let mirrors = validated_mirrors(request.policy)?;
93        let effective_resolution = match request.policy.resolve {
94            ContainerResolve::Mirror => EffectiveResolution::Mirror,
95            ContainerResolve::Upstream => {
96                warnings.insert(PlanWarning::ResolutionSeparationUnavailable);
97                if applicability == PlanApplicability::Ready {
98                    applicability = PlanApplicability::ManualOnly;
99                }
100                EffectiveResolution::RuntimeDefined
101            }
102        };
103        // Moby accepts only origin URLs for `registry-mirrors`. Preserve a
104        // configured path by refusing to render it instead of silently
105        // truncating the destination. Other native runtimes can express it.
106        if mirrors.iter().any(|mirror| mirror_has_path(mirror)) {
107            return Err(MirrorPlanError::UnsupportedDockerMirrorPath);
108        }
109        changes.push(MirrorChange::DockerHubMirrors {
110            mirrors: planned_mirrors(&mirrors)?,
111            effective_resolution,
112        });
113
114        if applicability != PlanApplicability::Unsupported {
115            if let Some(snapshot) = request.native_config {
116                inputs.push(snapshot.fingerprint().clone());
117                if !matches!(
118                    request
119                        .discovery
120                        .context
121                        .as_ref()
122                        .map(|context| context.kind),
123                    Some(DockerContextKind::Remote | DockerContextKind::Desktop)
124                ) {
125                    candidates.push(docker_candidate(snapshot, request.policy)?);
126                }
127            } else {
128                warnings.insert(PlanWarning::NativeConfigPathRequired);
129                if applicability == PlanApplicability::Ready {
130                    applicability = PlanApplicability::ManualOnly;
131                }
132            }
133        }
134    }
135
136    warnings.insert(PlanWarning::DaemonRestartRequired);
137    let candidate_fingerprints = candidates
138        .iter()
139        .map(|candidate| candidate.fingerprint().clone())
140        .collect();
141    let plan = MirrorPlanDraft {
142        target,
143        applicability,
144        policy_fingerprint,
145        inputs,
146        candidates: candidate_fingerprints,
147        changes,
148        privilege: docker_privilege(request.discovery),
149        activation: ActivationRequirement::RestartDaemon,
150        validation: BTreeSet::from([
151            ValidationStep::ParseCompleteJson,
152            ValidationStep::ValidateDockerDaemonConfig,
153            ValidationStep::RediscoverRuntimeIdentity,
154            ValidationStep::CompareInputFingerprints,
155        ]),
156        warnings,
157    }
158    .finalize()?;
159    Ok(MirrorPlanBundle { plan, candidates })
160}
161
162pub fn plan_containerd_mirrors(
163    request: ContainerdMirrorPlanRequest<'_>,
164) -> Result<MirrorPlanBundle, MirrorPlanError> {
165    let policy_fingerprint = policy_fingerprint(request.registry, request.policy)?;
166    let target = containerd_target(request.discovery);
167    let mirrors = validated_mirrors(request.policy)?;
168    let capabilities = match request.policy.resolve {
169        ContainerResolve::Upstream => BTreeSet::from([PlannedCapability::Pull]),
170        ContainerResolve::Mirror => {
171            BTreeSet::from([PlannedCapability::Pull, PlannedCapability::Resolve])
172        }
173    };
174    let mut changes = vec![MirrorChange::ContainerdRegistryHosts {
175        registry: request.registry.clone(),
176        mirrors: planned_mirrors(&mirrors)?,
177        capabilities: capabilities.clone(),
178    }];
179    let mut warnings = BTreeSet::from([PlanWarning::ExistingNativeEntriesPreserved]);
180    if request.policy.anonymous_only {
181        warnings.insert(PlanWarning::AnonymousOnlyNotEnforced);
182    }
183    let mut applicability =
184        if request.discovery.report.status == super::report::DiagnosticStatus::Healthy {
185            PlanApplicability::Ready
186        } else {
187            PlanApplicability::ManualOnly
188        };
189    let (_, endpoint_scope) = super::classify_endpoint(request.discovery.address.as_str());
190    if endpoint_scope == super::report::EndpointScope::Remote {
191        warnings.insert(PlanWarning::RemoteTarget);
192        applicability = PlanApplicability::ManualOnly;
193    } else if endpoint_scope != super::report::EndpointScope::Local {
194        applicability = PlanApplicability::ManualOnly;
195    }
196    let mut inputs = Vec::new();
197    let mut candidates = Vec::new();
198    let desired_config_path = request
199        .hosts_config
200        .map(hosts_root_from_snapshot)
201        .transpose()?;
202
203    if let (Some(discovered), Some(desired)) = (
204        request
205            .discovery
206            .config
207            .as_ref()
208            .and_then(|config| config.registry_config_path.as_ref()),
209        desired_config_path.as_ref(),
210    ) {
211        if !paths_equivalent(discovered, desired) {
212            return Err(MirrorPlanError::ConfigPathMismatch);
213        }
214    }
215
216    if let Some(snapshot) = request.hosts_config {
217        inputs.push(snapshot.fingerprint().clone());
218        candidates.push(containerd_hosts_candidate(
219            snapshot,
220            request.registry,
221            &mirrors,
222            &capabilities,
223        )?);
224    } else {
225        warnings.insert(PlanWarning::NativeConfigPathRequired);
226        applicability = PlanApplicability::ManualOnly;
227    }
228
229    let missing_config_path = request
230        .discovery
231        .config
232        .as_ref()
233        .and_then(|config| config.registry_config_path.as_ref())
234        .is_none();
235    let activation = if missing_config_path {
236        warnings.insert(PlanWarning::ContainerdConfigPathMissing);
237        warnings.insert(PlanWarning::DaemonRestartRequired);
238        applicability = PlanApplicability::ManualOnly;
239        if let Some(path) = desired_config_path.as_ref() {
240            changes.push(MirrorChange::ContainerdConfigPath {
241                path: path_string(path)?,
242            });
243            if let Some(main) = request.main_config {
244                inputs.push(main.fingerprint().clone());
245                if main.is_missing() {
246                    warnings.insert(PlanWarning::NativeConfigPathRequired);
247                } else {
248                    candidates.push(containerd_main_candidate(main, request.discovery, path)?);
249                }
250            } else {
251                warnings.insert(PlanWarning::NativeConfigPathRequired);
252            }
253        } else {
254            warnings.insert(PlanWarning::NativeConfigPathRequired);
255        }
256        ActivationRequirement::RestartDaemon
257    } else {
258        ActivationRequirement::None
259    };
260
261    let candidate_fingerprints = candidates
262        .iter()
263        .map(|candidate| candidate.fingerprint().clone())
264        .collect();
265    let plan = MirrorPlanDraft {
266        target,
267        applicability,
268        policy_fingerprint,
269        inputs,
270        candidates: candidate_fingerprints,
271        changes,
272        privilege: containerd_privilege(request.discovery),
273        activation,
274        validation: BTreeSet::from([
275            ValidationStep::ParseCompleteToml,
276            ValidationStep::RediscoverRuntimeIdentity,
277            ValidationStep::CompareInputFingerprints,
278        ]),
279        warnings,
280    }
281    .finalize()?;
282    Ok(MirrorPlanBundle { plan, candidates })
283}
284
285pub fn plan_buildkit_mirrors(
286    request: BuildkitMirrorPlanRequest<'_>,
287) -> Result<MirrorPlanBundle, MirrorPlanError> {
288    let policy_fingerprint = policy_fingerprint(request.registry, request.policy)?;
289    let target = buildkit_target(request.discovery)?;
290    let mirrors = validated_mirrors(request.policy)?;
291    let selected = request
292        .discovery
293        .selected_builder
294        .as_ref()
295        .ok_or(MirrorPlanError::MissingBuilder)?;
296    let mut warnings = BTreeSet::new();
297    if request.policy.anonymous_only {
298        warnings.insert(PlanWarning::AnonymousOnlyNotEnforced);
299    }
300    let effective_resolution = match request.policy.resolve {
301        ContainerResolve::Mirror => EffectiveResolution::Mirror,
302        ContainerResolve::Upstream => {
303            warnings.insert(PlanWarning::ResolutionSeparationUnavailable);
304            EffectiveResolution::RuntimeDefined
305        }
306    };
307    let mut changes = Vec::new();
308    let mut inputs = Vec::new();
309    let mut candidates = Vec::new();
310    let mut activation = ActivationRequirement::None;
311    let mut applicability = match selected.driver {
312        BuilderDriver::Docker => {
313            warnings.insert(PlanWarning::DockerDriverUsesEngineConfiguration);
314            PlanApplicability::Unsupported
315        }
316        BuilderDriver::DockerContainer => {
317            activation = ActivationRequirement::RecreateBuilder;
318            warnings.insert(PlanWarning::BuilderRecreateRequired);
319            if builder_is_local(selected) {
320                PlanApplicability::Ready
321            } else {
322                warnings.insert(PlanWarning::ExternalBuilderConfiguration);
323                PlanApplicability::ManualOnly
324            }
325        }
326        BuilderDriver::Kubernetes
327        | BuilderDriver::Remote
328        | BuilderDriver::Cloud
329        | BuilderDriver::Unknown => {
330            warnings.insert(PlanWarning::ExternalBuilderConfiguration);
331            PlanApplicability::ManualOnly
332        }
333    };
334    if request.policy.resolve == ContainerResolve::Upstream
335        && applicability == PlanApplicability::Ready
336    {
337        applicability = PlanApplicability::ManualOnly;
338    }
339
340    if !matches!(selected.driver, BuilderDriver::Docker) {
341        changes.push(MirrorChange::BuildkitRegistryMirrors {
342            registry: request.registry.clone(),
343            mirrors: planned_mirrors(&mirrors)?,
344            effective_resolution,
345        });
346    }
347    if matches!(selected.driver, BuilderDriver::DockerContainer) {
348        if let Some(snapshot) = request.native_config {
349            inputs.push(snapshot.fingerprint().clone());
350            if builder_is_local(selected) {
351                candidates.push(buildkit_candidate(snapshot, request.registry, &mirrors)?);
352            }
353        } else {
354            warnings.insert(PlanWarning::NativeConfigPathRequired);
355            applicability = PlanApplicability::ManualOnly;
356        }
357    }
358
359    let candidate_fingerprints = candidates
360        .iter()
361        .map(|candidate| candidate.fingerprint().clone())
362        .collect();
363    let plan = MirrorPlanDraft {
364        target,
365        applicability,
366        policy_fingerprint,
367        inputs,
368        candidates: candidate_fingerprints,
369        changes,
370        privilege: buildkit_privilege(selected.driver.clone()),
371        activation,
372        validation: BTreeSet::from([
373            ValidationStep::ParseCompleteToml,
374            ValidationStep::RediscoverBuilderIdentity,
375            ValidationStep::CompareInputFingerprints,
376        ]),
377        warnings,
378    }
379    .finalize()?;
380    Ok(MirrorPlanBundle { plan, candidates })
381}
382
383fn docker_target(discovery: &DockerDiscovery) -> Result<MirrorPlanTarget, MirrorPlanError> {
384    let context = discovery
385        .context
386        .as_ref()
387        .and_then(|context| context.name.as_deref())
388        .unwrap_or("unknown");
389    let kind = match discovery.context.as_ref().map(|context| context.kind) {
390        Some(DockerContextKind::Local) => DockerTargetKind::Local,
391        Some(DockerContextKind::Rootless) => DockerTargetKind::Rootless,
392        Some(DockerContextKind::Desktop) => DockerTargetKind::Desktop,
393        Some(DockerContextKind::Remote) => DockerTargetKind::Remote,
394        Some(DockerContextKind::Unknown) | None => DockerTargetKind::Unknown,
395    };
396    Ok(MirrorPlanTarget::Docker {
397        context: Fingerprint::for_bytes(context.as_bytes()),
398        kind,
399        endpoint: discovery
400            .context
401            .as_ref()
402            .and_then(|context| context.endpoint.as_ref())
403            .map(|endpoint| endpoint.address.clone()),
404        version: discovery.version.as_ref().and_then(|version| {
405            version
406                .server
407                .as_ref()
408                .or(version.client.as_ref())
409                .map(ToString::to_string)
410        }),
411    })
412}
413
414fn docker_applicability(
415    discovery: &DockerDiscovery,
416    warnings: &mut BTreeSet<PlanWarning>,
417) -> PlanApplicability {
418    match discovery.context.as_ref().map(|context| context.kind) {
419        Some(DockerContextKind::Local | DockerContextKind::Rootless) => PlanApplicability::Ready,
420        Some(DockerContextKind::Remote) => {
421            warnings.insert(PlanWarning::RemoteTarget);
422            PlanApplicability::ManualOnly
423        }
424        Some(DockerContextKind::Desktop) => {
425            warnings.insert(PlanWarning::ManagedDesktop);
426            PlanApplicability::ManualOnly
427        }
428        Some(DockerContextKind::Unknown) | None => PlanApplicability::ManualOnly,
429    }
430}
431
432fn docker_privilege(discovery: &DockerDiscovery) -> RequiredPrivilege {
433    match discovery.context.as_ref().map(|context| context.kind) {
434        Some(DockerContextKind::Local) => RequiredPrivilege::Root,
435        Some(DockerContextKind::Rootless | DockerContextKind::Desktop) => {
436            RequiredPrivilege::CurrentUser
437        }
438        Some(DockerContextKind::Remote) => RequiredPrivilege::RemoteAdministrator,
439        Some(DockerContextKind::Unknown) | None => RequiredPrivilege::Unknown,
440    }
441}
442
443fn docker_candidate(
444    snapshot: &NativeConfigSnapshot,
445    policy: &ContainerRegistryConfig,
446) -> Result<NativeConfigCandidate, MirrorPlanError> {
447    let mut value = match snapshot.bytes() {
448        Some(bytes) => serde_json::from_slice::<serde_json::Value>(bytes)
449            .map_err(|_| MirrorPlanError::InvalidNativeJson)?,
450        None => serde_json::Value::Object(serde_json::Map::new()),
451    };
452    let object = value
453        .as_object_mut()
454        .ok_or(MirrorPlanError::InvalidNativeJson)?;
455    object.insert(
456        "registry-mirrors".to_owned(),
457        serde_json::Value::Array(
458            validated_mirrors(policy)?
459                .into_iter()
460                .map(serde_json::Value::String)
461                .collect(),
462        ),
463    );
464    let bytes =
465        serde_json::to_vec_pretty(&value).map_err(|_| MirrorPlanError::InvalidNativeJson)?;
466    serde_json::from_slice::<serde_json::Value>(&bytes)
467        .map_err(|_| MirrorPlanError::InvalidNativeJson)?;
468    Ok(NativeConfigCandidate::new(
469        snapshot,
470        NativeConfigFormat::Json,
471        bytes,
472    )?)
473}
474
475fn containerd_target(discovery: &ContainerdDiscovery) -> MirrorPlanTarget {
476    let version = discovery
477        .versions
478        .ctr_server
479        .as_ref()
480        .or(discovery.versions.containerd.as_ref())
481        .map(ToString::to_string);
482    MirrorPlanTarget::Containerd {
483        endpoint: discovery.address.clone(),
484        namespace: discovery.namespace.clone(),
485        version,
486        config_path: discovery
487            .config
488            .as_ref()
489            .and_then(|config| config.registry_config_path.as_ref())
490            .and_then(|path| path.to_str())
491            .map(str::to_owned),
492    }
493}
494
495fn containerd_privilege(discovery: &ContainerdDiscovery) -> RequiredPrivilege {
496    let (_, scope) = super::classify_endpoint(discovery.address.as_str());
497    match scope {
498        super::report::EndpointScope::Local => RequiredPrivilege::Root,
499        super::report::EndpointScope::Remote => RequiredPrivilege::RemoteAdministrator,
500        _ => RequiredPrivilege::Unknown,
501    }
502}
503
504fn hosts_root_from_snapshot(snapshot: &NativeConfigSnapshot) -> Result<PathBuf, MirrorPlanError> {
505    let path = Path::new(&snapshot.fingerprint().path);
506    if path.file_name().and_then(|name| name.to_str()) != Some("hosts.toml") {
507        return Err(MirrorPlanError::InvalidHostsPath);
508    }
509    path.parent()
510        .and_then(Path::parent)
511        .map(Path::to_path_buf)
512        .ok_or(MirrorPlanError::InvalidHostsPath)
513}
514
515fn containerd_hosts_candidate(
516    snapshot: &NativeConfigSnapshot,
517    registry: &RegistryName,
518    mirrors: &[String],
519    capabilities: &BTreeSet<PlannedCapability>,
520) -> Result<NativeConfigCandidate, MirrorPlanError> {
521    let mut document = parse_toml_snapshot(snapshot)?;
522    if snapshot.is_missing() {
523        document["server"] = toml_edit::value(origin_endpoint(registry));
524    }
525    let host = ensure_table(&mut document, "host")?;
526    for mirror in mirrors {
527        // Configured mirror paths are base prefixes. With containerd's normal
528        // host semantics it appends `/v2/...` after this prefix, matching the
529        // registry diagnostic transport. Do not infer `override_path`; that
530        // flag means the configured path is already the complete API root.
531        let rendered = trim_root_url(mirror);
532        let key = existing_url_key(host, &rendered).unwrap_or(rendered);
533        let entry = host
534            .entry(&key)
535            .or_insert_with(|| Item::Table(Table::new()));
536        let table = entry
537            .as_table_like_mut()
538            .ok_or(MirrorPlanError::InvalidNativeToml)?;
539        let mut values = Array::new();
540        values.push("pull");
541        if capabilities.contains(&PlannedCapability::Resolve) {
542            values.push("resolve");
543        }
544        table.insert("capabilities", toml_edit::value(values));
545    }
546    candidate_from_toml(snapshot, document)
547}
548
549fn containerd_main_candidate(
550    snapshot: &NativeConfigSnapshot,
551    discovery: &ContainerdDiscovery,
552    config_path: &Path,
553) -> Result<NativeConfigCandidate, MirrorPlanError> {
554    let mut document = parse_toml_snapshot(snapshot)?;
555    let major = discovery
556        .versions
557        .ctr_server
558        .as_ref()
559        .or(discovery.versions.containerd.as_ref())
560        .map(|version| version.major)
561        .unwrap_or(1);
562    let plugin = if major >= 2 {
563        "io.containerd.cri.v1.images"
564    } else {
565        "io.containerd.grpc.v1.cri"
566    };
567    document["plugins"][plugin]["registry"]["config_path"] =
568        toml_edit::value(path_string(config_path)?);
569    candidate_from_toml(snapshot, document)
570}
571
572fn buildkit_target(discovery: &BuildkitDiscovery) -> Result<MirrorPlanTarget, MirrorPlanError> {
573    let selected = discovery
574        .selected_builder
575        .as_ref()
576        .ok_or(MirrorPlanError::MissingBuilder)?;
577    let mut nodes = selected
578        .nodes
579        .iter()
580        .map(|node| {
581            #[derive(Serialize)]
582            struct NodeIdentity<'a> {
583                name: &'a str,
584                endpoint: Option<&'a super::report::Endpoint>,
585                version: Option<String>,
586                platforms: Vec<String>,
587            }
588            let mut platforms = node
589                .platforms
590                .iter()
591                .map(|platform| {
592                    let mut value = format!("{}/{}", platform.os, platform.architecture);
593                    if let Some(variant) = &platform.variant {
594                        value.push('/');
595                        value.push_str(variant);
596                    }
597                    value
598                })
599                .collect::<Vec<_>>();
600            platforms.sort();
601            Fingerprint::for_canonical(&NodeIdentity {
602                name: &node.name,
603                endpoint: node.endpoint.as_ref(),
604                version: node.buildkit_version.as_ref().map(ToString::to_string),
605                platforms,
606            })
607        })
608        .collect::<Result<Vec<_>, _>>()?;
609    nodes.sort();
610    Ok(MirrorPlanTarget::Buildkit {
611        builder: selected.name.clone(),
612        driver: map_builder_driver(&selected.driver),
613        nodes,
614        version: discovery.buildx_version.as_ref().map(ToString::to_string),
615    })
616}
617
618fn map_builder_driver(driver: &BuilderDriver) -> BuildkitTargetDriver {
619    match driver {
620        BuilderDriver::Docker => BuildkitTargetDriver::Docker,
621        BuilderDriver::DockerContainer => BuildkitTargetDriver::DockerContainer,
622        BuilderDriver::Kubernetes => BuildkitTargetDriver::Kubernetes,
623        BuilderDriver::Remote => BuildkitTargetDriver::Remote,
624        BuilderDriver::Cloud => BuildkitTargetDriver::Cloud,
625        BuilderDriver::Unknown => BuildkitTargetDriver::Unknown,
626    }
627}
628
629fn builder_is_local(builder: &super::buildkit::SelectedBuilder) -> bool {
630    !builder.nodes.is_empty()
631        && builder.nodes.iter().all(|node| {
632            node.endpoint
633                .as_ref()
634                .is_some_and(|endpoint| endpoint.scope == super::report::EndpointScope::Local)
635        })
636}
637
638fn buildkit_privilege(driver: BuilderDriver) -> RequiredPrivilege {
639    match driver {
640        BuilderDriver::Docker | BuilderDriver::DockerContainer => RequiredPrivilege::CurrentUser,
641        BuilderDriver::Kubernetes | BuilderDriver::Remote | BuilderDriver::Cloud => {
642            RequiredPrivilege::RemoteAdministrator
643        }
644        BuilderDriver::Unknown => RequiredPrivilege::Unknown,
645    }
646}
647
648fn buildkit_candidate(
649    snapshot: &NativeConfigSnapshot,
650    registry: &RegistryName,
651    mirrors: &[String],
652) -> Result<NativeConfigCandidate, MirrorPlanError> {
653    let mut document = parse_toml_snapshot(snapshot)?;
654    let registries = ensure_table(&mut document, "registry")?;
655    let entry = registries
656        .entry(registry.as_str())
657        .or_insert_with(|| Item::Table(Table::new()));
658    let table = entry
659        .as_table_like_mut()
660        .ok_or(MirrorPlanError::InvalidNativeToml)?;
661    let mut values = Array::new();
662    for mirror in mirrors {
663        values.push(mirror_authority(mirror)?);
664    }
665    table.insert("mirrors", toml_edit::value(values));
666    candidate_from_toml(snapshot, document)
667}
668
669fn parse_toml_snapshot(snapshot: &NativeConfigSnapshot) -> Result<DocumentMut, MirrorPlanError> {
670    match snapshot.bytes() {
671        Some(bytes) => std::str::from_utf8(bytes)
672            .map_err(|_| MirrorPlanError::InvalidNativeToml)?
673            .parse()
674            .map_err(|_| MirrorPlanError::InvalidNativeToml),
675        None => Ok(DocumentMut::new()),
676    }
677}
678
679fn candidate_from_toml(
680    snapshot: &NativeConfigSnapshot,
681    document: DocumentMut,
682) -> Result<NativeConfigCandidate, MirrorPlanError> {
683    let bytes = document.to_string().into_bytes();
684    std::str::from_utf8(&bytes)
685        .map_err(|_| MirrorPlanError::InvalidNativeToml)?
686        .parse::<toml::Value>()
687        .map_err(|_| MirrorPlanError::InvalidNativeToml)?;
688    Ok(NativeConfigCandidate::new(
689        snapshot,
690        NativeConfigFormat::Toml,
691        bytes,
692    )?)
693}
694
695fn ensure_table<'a>(
696    document: &'a mut DocumentMut,
697    key: &str,
698) -> Result<&'a mut Table, MirrorPlanError> {
699    if document.get(key).is_none() {
700        let mut table = Table::new();
701        table.set_implicit(true);
702        document.insert(key, Item::Table(table));
703    }
704    document
705        .get_mut(key)
706        .and_then(Item::as_table_mut)
707        .ok_or(MirrorPlanError::InvalidNativeToml)
708}
709
710fn existing_url_key(table: &Table, requested: &str) -> Option<String> {
711    table
712        .iter()
713        .find(|(key, _)| urls_equivalent(key, requested))
714        .map(|(key, _)| key.to_owned())
715}
716
717fn urls_equivalent(left: &str, right: &str) -> bool {
718    match (mirror_url(left), mirror_url(right)) {
719        (Ok(left), Ok(right)) => left == right,
720        _ => false,
721    }
722}
723
724fn validated_mirrors(policy: &ContainerRegistryConfig) -> Result<Vec<String>, MirrorPlanError> {
725    policy
726        .mirrors
727        .iter()
728        .map(|mirror| mirror_url(mirror).map(Into::into))
729        .collect()
730}
731
732fn mirror_url(value: &str) -> Result<reqwest::Url, MirrorPlanError> {
733    let mut url = reqwest::Url::parse(value).map_err(|_| MirrorPlanError::UnsafeMirrorUrl)?;
734    if url.scheme() != "https"
735        || url.host_str().is_none()
736        || !url.username().is_empty()
737        || url.password().is_some()
738        || url.query().is_some()
739        || url.fragment().is_some()
740    {
741        return Err(MirrorPlanError::UnsafeMirrorUrl);
742    }
743    let path = url.path().trim_end_matches('/').to_owned();
744    url.set_path(&format!("{path}/"));
745    Ok(url)
746}
747
748fn mirror_has_path(value: &str) -> bool {
749    reqwest::Url::parse(value).is_ok_and(|url| !matches!(url.path(), "" | "/"))
750}
751
752fn planned_mirrors(values: &[String]) -> Result<Vec<PlannedMirrorEndpoint>, MirrorPlanError> {
753    values
754        .iter()
755        .map(|value| {
756            let url = mirror_url(value)?;
757            Ok(PlannedMirrorEndpoint {
758                origin: super::RedactedUrl::parse(value)
759                    .map_err(|_| MirrorPlanError::UnsafeMirrorUrl)?,
760                has_path_prefix: !matches!(url.path(), "" | "/"),
761            })
762        })
763        .collect()
764}
765
766fn trim_root_url(value: &str) -> String {
767    value.trim_end_matches('/').to_owned()
768}
769
770fn mirror_authority(value: &str) -> Result<String, MirrorPlanError> {
771    let value = mirror_url(value)?.to_string();
772    trim_root_url(&value)
773        .strip_prefix("https://")
774        .map(str::to_owned)
775        .ok_or(MirrorPlanError::UnsafeMirrorUrl)
776}
777
778fn origin_endpoint(registry: &RegistryName) -> String {
779    if registry.is_docker_hub() {
780        "https://registry-1.docker.io".to_owned()
781    } else {
782        format!("https://{}", registry.as_str())
783    }
784}
785
786fn paths_equivalent(left: &Path, right: &Path) -> bool {
787    let left = dunce::canonicalize(left).unwrap_or_else(|_| left.to_path_buf());
788    let right = dunce::canonicalize(right).unwrap_or_else(|_| right.to_path_buf());
789    left == right
790}
791
792fn path_string(path: &Path) -> Result<String, MirrorPlanError> {
793    path.to_str()
794        .map(str::to_owned)
795        .ok_or(MirrorPlanError::InvalidNativePath)
796}
797
798#[derive(Debug, thiserror::Error)]
799pub enum MirrorPlanError {
800    #[error(transparent)]
801    Plan(#[from] PlanError),
802    #[error("mirror endpoints must be HTTPS URLs without credentials, query, or fragment")]
803    UnsafeMirrorUrl,
804    #[error("Docker Engine registry mirrors do not support path-prefixed endpoints")]
805    UnsupportedDockerMirrorPath,
806    #[error("native Docker configuration is not a JSON object")]
807    InvalidNativeJson,
808    #[error("native container configuration is not valid TOML for this semantic change")]
809    InvalidNativeToml,
810    #[error("containerd hosts target must explicitly name <config_path>/<namespace>/hosts.toml")]
811    InvalidHostsPath,
812    #[error("containerd hosts target does not belong to the discovered config_path")]
813    ConfigPathMismatch,
814    #[error("selected Buildx builder was not discovered")]
815    MissingBuilder,
816    #[error("native configuration path is not valid UTF-8")]
817    InvalidNativePath,
818}
819
820#[cfg(test)]
821mod tests {
822    use std::collections::BTreeSet;
823
824    use semver::Version;
825
826    use super::*;
827    use crate::container::buildkit::{
828        BuildPlatform, BuilderNode, BuilderNodeStatus, SelectedBuilder,
829    };
830    use crate::container::containerd::{ContainerdConfig, ContainerdVersions};
831    use crate::container::docker::{DockerContext, DockerVersion};
832    use crate::container::report::{
833        DiagnosticReport, DiagnosticStatus, Endpoint, EndpointScope, EndpointTransport, RuntimeKind,
834    };
835    use crate::container::{ImageReference, OciPlatform, RedactedUrl};
836
837    fn policy(resolve: ContainerResolve) -> ContainerRegistryConfig {
838        ContainerRegistryConfig {
839            mirrors: vec![
840                "https://first.example/".into(),
841                "https://second.example/".into(),
842            ],
843            anonymous_only: true,
844            resolve,
845        }
846    }
847
848    #[test]
849    fn builtin_policy_is_scoped_to_docker_hub_and_has_a_stable_order() {
850        let hub = RegistryName::parse("docker.io").unwrap();
851        let policy = builtin_mirror_policy(&hub).unwrap();
852        assert_eq!(policy.mirrors, BUILTIN_DOCKER_HUB_MIRRORS);
853        assert!(policy.anonymous_only);
854        assert_eq!(policy.resolve, ContainerResolve::Mirror);
855        assert!(builtin_mirror_policy(&RegistryName::parse("ghcr.io").unwrap()).is_none());
856        assert!(ImageReference::parse(DOCKER_HUB_BENCHMARK_IMAGE).is_ok());
857        assert!(OciPlatform::parse(DOCKER_HUB_BENCHMARK_PLATFORM).is_ok());
858    }
859
860    fn docker(kind: DockerContextKind) -> DockerDiscovery {
861        DockerDiscovery {
862            status: DiagnosticStatus::Healthy,
863            context: Some(DockerContext {
864                name: Some("default".into()),
865                endpoint: Some(Endpoint::new(
866                    EndpointTransport::LocalSocket,
867                    EndpointScope::Local,
868                    RedactedUrl::parse("unix:///var/run/docker.sock").unwrap(),
869                )),
870                kind,
871                skip_tls_verify: false,
872                has_tls_material: false,
873                raw_endpoint: Some("unix:///var/run/docker.sock".into()),
874            }),
875            version: Some(DockerVersion {
876                client: Some(Version::new(28, 0, 0)),
877                server: Some(Version::new(28, 0, 0)),
878            }),
879            info: None,
880        }
881    }
882
883    #[test]
884    fn docker_preserves_unknown_json_keys_and_only_supports_hub() {
885        let temporary = tempfile::tempdir().unwrap();
886        let path = temporary.path().join("daemon.json");
887        std::fs::write(
888            &path,
889            br#"{"debug":true,"features":{"containerd-snapshotter":true},"registry-mirrors":["https://old.example"]}"#,
890        )
891        .unwrap();
892        let snapshot = NativeConfigSnapshot::capture(&path, 4096).unwrap();
893        let hub = RegistryName::parse("docker.io").unwrap();
894        let bundle = plan_docker_mirrors(DockerMirrorPlanRequest {
895            registry: &hub,
896            policy: &policy(ContainerResolve::Mirror),
897            discovery: &docker(DockerContextKind::Local),
898            native_config: Some(&snapshot),
899        })
900        .unwrap();
901        let candidate: serde_json::Value =
902            serde_json::from_slice(bundle.candidates[0].bytes()).unwrap();
903        assert_eq!(candidate["debug"], true);
904        assert_eq!(candidate["features"]["containerd-snapshotter"], true);
905        assert_eq!(candidate["registry-mirrors"].as_array().unwrap().len(), 2);
906        assert_eq!(bundle.plan.applicability, PlanApplicability::Ready);
907
908        let ghcr = RegistryName::parse("ghcr.io").unwrap();
909        let unsupported = plan_docker_mirrors(DockerMirrorPlanRequest {
910            registry: &ghcr,
911            policy: &policy(ContainerResolve::Mirror),
912            discovery: &docker(DockerContextKind::Local),
913            native_config: Some(&snapshot),
914        })
915        .unwrap();
916        assert_eq!(
917            unsupported.plan.applicability,
918            PlanApplicability::Unsupported
919        );
920        assert!(unsupported.candidates.is_empty());
921        assert!(unsupported
922            .plan
923            .warnings
924            .contains(&PlanWarning::DockerHubOnly));
925    }
926
927    #[test]
928    fn docker_upstream_resolution_and_remote_desktop_are_manual_only() {
929        let temporary = tempfile::tempdir().unwrap();
930        let path = temporary.path().join("daemon.json");
931        std::fs::write(&path, b"{}").unwrap();
932        let snapshot = NativeConfigSnapshot::capture(&path, 64).unwrap();
933        let registry = RegistryName::parse("docker.io").unwrap();
934        for kind in [
935            DockerContextKind::Local,
936            DockerContextKind::Remote,
937            DockerContextKind::Desktop,
938        ] {
939            let bundle = plan_docker_mirrors(DockerMirrorPlanRequest {
940                registry: &registry,
941                policy: &policy(ContainerResolve::Upstream),
942                discovery: &docker(kind),
943                native_config: Some(&snapshot),
944            })
945            .unwrap();
946            assert_eq!(bundle.plan.applicability, PlanApplicability::ManualOnly);
947            assert!(bundle
948                .plan
949                .warnings
950                .contains(&PlanWarning::ResolutionSeparationUnavailable));
951            if matches!(kind, DockerContextKind::Remote | DockerContextKind::Desktop) {
952                assert!(bundle.candidates.is_empty());
953            }
954        }
955    }
956
957    fn containerd(config_path: Option<PathBuf>, major: u64) -> ContainerdDiscovery {
958        ContainerdDiscovery {
959            report: DiagnosticReport::new(RuntimeKind::Containerd, DiagnosticStatus::Healthy),
960            versions: ContainerdVersions {
961                containerd: Some(Version::new(major, 0, 0)),
962                ctr_client: Some(Version::new(major, 0, 0)),
963                ctr_server: Some(Version::new(major, 0, 0)),
964            },
965            address: RedactedUrl::parse("unix:///run/containerd/containerd.sock").unwrap(),
966            namespace: "k8s.io".into(),
967            config: Some(ContainerdConfig {
968                version: Some(if major >= 2 { 3 } else { 2 }),
969                registry_config_path: config_path,
970                legacy_registry: BTreeSet::new(),
971            }),
972        }
973    }
974
975    #[test]
976    fn containerd_preserves_host_order_tls_and_unrelated_entries() {
977        let temporary = tempfile::tempdir().unwrap();
978        let root = temporary.path().join("certs.d");
979        let namespace = root.join("ghcr.io");
980        std::fs::create_dir_all(&namespace).unwrap();
981        let path = namespace.join("hosts.toml");
982        std::fs::write(
983            &path,
984            concat!(
985                "server = \"https://ghcr.io\"\n",
986                "custom = \"keep\"\n",
987                "[host.\"https://unrelated.example\"]\n",
988                "  capabilities = [\"pull\", \"resolve\", \"push\"]\n",
989                "  ca = \"/secret/ca.pem\"\n",
990                "[host.\"https://first.example\"]\n",
991                "  capabilities = [\"pull\", \"resolve\", \"push\"]\n",
992                "  skip_verify = true\n",
993                "  override_path = true\n",
994            ),
995        )
996        .unwrap();
997        let snapshot = NativeConfigSnapshot::capture(&path, 4096).unwrap();
998        let registry = RegistryName::parse("ghcr.io").unwrap();
999        let bundle = plan_containerd_mirrors(ContainerdMirrorPlanRequest {
1000            registry: &registry,
1001            policy: &policy(ContainerResolve::Upstream),
1002            discovery: &containerd(Some(root), 1),
1003            hosts_config: Some(&snapshot),
1004            main_config: None,
1005        })
1006        .unwrap();
1007        let text = std::str::from_utf8(bundle.candidates[0].bytes()).unwrap();
1008        let unrelated = text.find("https://unrelated.example").unwrap();
1009        let first = text.find("https://first.example").unwrap();
1010        let second = text.find("https://second.example").unwrap();
1011        assert!(unrelated < first && first < second, "{text}");
1012        assert!(text.contains("ca = \"/secret/ca.pem\""));
1013        assert!(text.contains("skip_verify = true"));
1014        assert!(text.contains("override_path = true"));
1015        assert!(text.contains("custom = \"keep\""));
1016
1017        let parsed: toml::Value = toml::from_str(text).unwrap();
1018        let first_caps = parsed["host"]["https://first.example"]["capabilities"]
1019            .as_array()
1020            .unwrap();
1021        assert_eq!(first_caps.len(), 1);
1022        assert_eq!(first_caps[0].as_str(), Some("pull"));
1023        let unrelated_caps = parsed["host"]["https://unrelated.example"]["capabilities"]
1024            .as_array()
1025            .unwrap();
1026        assert_eq!(unrelated_caps.len(), 3);
1027    }
1028
1029    #[test]
1030    fn containerd_mirror_resolution_adds_resolve_without_push() {
1031        let temporary = tempfile::tempdir().unwrap();
1032        let root = temporary.path().join("certs.d");
1033        let namespace = root.join("ghcr.io");
1034        std::fs::create_dir_all(&namespace).unwrap();
1035        let snapshot = NativeConfigSnapshot::capture(&namespace.join("hosts.toml"), 4096).unwrap();
1036        let registry = RegistryName::parse("ghcr.io").unwrap();
1037        let bundle = plan_containerd_mirrors(ContainerdMirrorPlanRequest {
1038            registry: &registry,
1039            policy: &policy(ContainerResolve::Mirror),
1040            discovery: &containerd(Some(root), 1),
1041            hosts_config: Some(&snapshot),
1042            main_config: None,
1043        })
1044        .unwrap();
1045        let text = std::str::from_utf8(bundle.candidates[0].bytes()).unwrap();
1046        assert!(text.contains("capabilities = [\"pull\", \"resolve\"]"));
1047        assert!(!text.contains("push"));
1048        assert_eq!(bundle.plan.activation, ActivationRequirement::None);
1049    }
1050
1051    #[test]
1052    fn missing_containerd_config_path_plans_versioned_key_and_restart() {
1053        for (major, plugin) in [
1054            (1, "io.containerd.grpc.v1.cri"),
1055            (2, "io.containerd.cri.v1.images"),
1056        ] {
1057            let temporary = tempfile::tempdir().unwrap();
1058            let root = temporary.path().join("certs.d");
1059            let namespace = root.join("docker.io");
1060            std::fs::create_dir_all(&namespace).unwrap();
1061            let hosts = NativeConfigSnapshot::capture(&namespace.join("hosts.toml"), 4096).unwrap();
1062            let main_path = temporary.path().join("config.toml");
1063            std::fs::write(&main_path, "version = 2\n[debug]\nlevel = \"info\"\n").unwrap();
1064            let main = NativeConfigSnapshot::capture(&main_path, 4096).unwrap();
1065            let registry = RegistryName::parse("docker.io").unwrap();
1066            let bundle = plan_containerd_mirrors(ContainerdMirrorPlanRequest {
1067                registry: &registry,
1068                policy: &policy(ContainerResolve::Mirror),
1069                discovery: &containerd(None, major),
1070                hosts_config: Some(&hosts),
1071                main_config: Some(&main),
1072            })
1073            .unwrap();
1074            let main_candidate = bundle
1075                .candidates
1076                .iter()
1077                .find(|candidate| candidate.fingerprint().path.ends_with("config.toml"))
1078                .unwrap();
1079            let text = std::str::from_utf8(main_candidate.bytes()).unwrap();
1080            assert!(text.contains(plugin), "{text}");
1081            assert!(text.contains("level = \"info\""));
1082            assert_eq!(bundle.plan.activation, ActivationRequirement::RestartDaemon);
1083            assert_eq!(bundle.plan.applicability, PlanApplicability::ManualOnly);
1084        }
1085    }
1086
1087    fn buildkit(driver: BuilderDriver, scope: EndpointScope) -> BuildkitDiscovery {
1088        BuildkitDiscovery {
1089            report: DiagnosticReport::new(RuntimeKind::Buildkit, DiagnosticStatus::Healthy),
1090            buildx_version: Some(Version::new(0, 36, 1)),
1091            selected_builder: Some(SelectedBuilder {
1092                name: "selected".into(),
1093                driver,
1094                nodes: vec![BuilderNode {
1095                    name: "selected0".into(),
1096                    endpoint: Some(Endpoint::new(
1097                        EndpointTransport::LocalSocket,
1098                        scope,
1099                        RedactedUrl::parse("unix:///var/run/docker.sock").unwrap(),
1100                    )),
1101                    endpoint_fingerprint: Some(Fingerprint::for_bytes(
1102                        b"unix:///var/run/docker.sock",
1103                    )),
1104                    status: BuilderNodeStatus::Running,
1105                    buildkit_version: Some(Version::new(0, 25, 0)),
1106                    platforms: BTreeSet::from([BuildPlatform {
1107                        os: "linux".into(),
1108                        architecture: "amd64".into(),
1109                        variant: None,
1110                    }]),
1111                }],
1112                has_error: false,
1113            }),
1114        }
1115    }
1116
1117    #[test]
1118    fn buildkit_enforces_driver_boundaries_and_preserves_gc_tls() {
1119        let temporary = tempfile::tempdir().unwrap();
1120        let path = temporary.path().join("buildkitd.toml");
1121        std::fs::write(
1122            &path,
1123            concat!(
1124                "[worker.oci]\n  gc = true\n",
1125                "[registry.\"ghcr.io\"]\n  ca = [\"/secret/ca.pem\"]\n",
1126            ),
1127        )
1128        .unwrap();
1129        let snapshot = NativeConfigSnapshot::capture(&path, 4096).unwrap();
1130        let registry = RegistryName::parse("ghcr.io").unwrap();
1131        let docker_driver = plan_buildkit_mirrors(BuildkitMirrorPlanRequest {
1132            registry: &registry,
1133            policy: &policy(ContainerResolve::Mirror),
1134            discovery: &buildkit(BuilderDriver::Docker, EndpointScope::Local),
1135            native_config: Some(&snapshot),
1136        })
1137        .unwrap();
1138        assert_eq!(
1139            docker_driver.plan.applicability,
1140            PlanApplicability::Unsupported
1141        );
1142        assert!(docker_driver.candidates.is_empty());
1143
1144        let container_driver = plan_buildkit_mirrors(BuildkitMirrorPlanRequest {
1145            registry: &registry,
1146            policy: &policy(ContainerResolve::Mirror),
1147            discovery: &buildkit(BuilderDriver::DockerContainer, EndpointScope::Local),
1148            native_config: Some(&snapshot),
1149        })
1150        .unwrap();
1151        assert_eq!(
1152            container_driver.plan.applicability,
1153            PlanApplicability::Ready
1154        );
1155        assert_eq!(
1156            container_driver.plan.activation,
1157            ActivationRequirement::RecreateBuilder
1158        );
1159        let text = std::str::from_utf8(container_driver.candidates[0].bytes()).unwrap();
1160        assert!(text.contains("gc = true"));
1161        assert!(text.contains("ca = [\"/secret/ca.pem\"]"));
1162        assert!(text.contains("mirrors = [\"first.example\", \"second.example\"]"));
1163
1164        let remote = plan_buildkit_mirrors(BuildkitMirrorPlanRequest {
1165            registry: &registry,
1166            policy: &policy(ContainerResolve::Mirror),
1167            discovery: &buildkit(BuilderDriver::Remote, EndpointScope::Remote),
1168            native_config: Some(&snapshot),
1169        })
1170        .unwrap();
1171        assert_eq!(remote.plan.applicability, PlanApplicability::ManualOnly);
1172        assert!(remote.candidates.is_empty());
1173    }
1174
1175    #[test]
1176    fn path_prefixed_mirrors_are_rendered_per_runtime_without_truncation() {
1177        let registry = RegistryName::parse("docker.io").unwrap();
1178        let prefixed_policy = ContainerRegistryConfig {
1179            mirrors: vec!["https://mirror.example:5443/cache/".into()],
1180            anonymous_only: true,
1181            resolve: ContainerResolve::Mirror,
1182        };
1183        let docker = plan_docker_mirrors(DockerMirrorPlanRequest {
1184            registry: &registry,
1185            policy: &prefixed_policy,
1186            discovery: &docker(DockerContextKind::Local),
1187            native_config: None,
1188        })
1189        .unwrap_err();
1190        assert!(matches!(
1191            docker,
1192            MirrorPlanError::UnsupportedDockerMirrorPath
1193        ));
1194
1195        let temporary = tempfile::tempdir().unwrap();
1196        let root = temporary.path().join("certs.d");
1197        let namespace = root.join("docker.io");
1198        std::fs::create_dir_all(&namespace).unwrap();
1199        let hosts = NativeConfigSnapshot::capture(&namespace.join("hosts.toml"), 4096).unwrap();
1200        let containerd = plan_containerd_mirrors(ContainerdMirrorPlanRequest {
1201            registry: &registry,
1202            policy: &prefixed_policy,
1203            discovery: &containerd(Some(root), 2),
1204            hosts_config: Some(&hosts),
1205            main_config: None,
1206        })
1207        .unwrap();
1208        let text = std::str::from_utf8(containerd.candidates[0].bytes()).unwrap();
1209        assert!(text.contains("https://mirror.example:5443/cache"), "{text}");
1210        assert!(!text.contains("override_path"), "{text}");
1211        assert!(
1212            text.contains("capabilities = [\"pull\", \"resolve\"]"),
1213            "{text}"
1214        );
1215        assert!(!text.contains("push"), "{text}");
1216        let plan_json = serde_json::to_string(&containerd.plan).unwrap();
1217        assert!(!plan_json.contains("cache"), "prefix leaked: {plan_json}");
1218        assert!(plan_json.contains("[redacted]"), "{plan_json}");
1219        assert!(
1220            plan_json.contains("\"has_path_prefix\":true"),
1221            "{plan_json}"
1222        );
1223
1224        let buildkit_path = temporary.path().join("buildkitd.toml");
1225        let buildkit_snapshot = NativeConfigSnapshot::capture(&buildkit_path, 4096).unwrap();
1226        let buildkit = plan_buildkit_mirrors(BuildkitMirrorPlanRequest {
1227            registry: &registry,
1228            policy: &prefixed_policy,
1229            discovery: &buildkit(BuilderDriver::DockerContainer, EndpointScope::Local),
1230            native_config: Some(&buildkit_snapshot),
1231        })
1232        .unwrap();
1233        let text = std::str::from_utf8(buildkit.candidates[0].bytes()).unwrap();
1234        assert!(
1235            text.contains("mirrors = [\"mirror.example:5443/cache\"]"),
1236            "{text}"
1237        );
1238        let plan_json = serde_json::to_string(&buildkit.plan).unwrap();
1239        assert!(!plan_json.contains("cache"), "prefix leaked: {plan_json}");
1240        assert!(plan_json.contains("[redacted]"), "{plan_json}");
1241    }
1242}