1use 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
26pub const BUILTIN_DOCKER_HUB_MIRRORS: [&str; 2] =
30 ["https://mirror.gcr.io/", "https://docker.m.daocloud.io/"];
31
32pub 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 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 pub hosts_config: Option<&'a NativeConfigSnapshot>,
62 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 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 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 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: ®istry,
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: ®istry,
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: ®istry,
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: ®istry,
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: ®istry,
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: ®istry,
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: ®istry,
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: ®istry,
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: ®istry,
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: ®istry,
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}