1use std::collections::BTreeSet;
9use std::fs::{File, OpenOptions};
10use std::io::{self, Read};
11use std::path::{Path, PathBuf};
12use std::time::UNIX_EPOCH;
13
14use serde::Serialize;
15use sha2::{Digest, Sha256};
16
17use super::redact::RedactedUrl;
18use super::reference::RegistryName;
19use crate::config::{ContainerRegistryConfig, ContainerResolve};
20
21pub const MIRROR_PLAN_SCHEMA_VERSION: u32 = 1;
22pub const MAX_NATIVE_CONFIG_BYTES: usize = 4 * 1024 * 1024;
23
24#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
26#[serde(transparent)]
27pub struct Fingerprint(String);
28
29impl Fingerprint {
30 pub fn as_str(&self) -> &str {
31 &self.0
32 }
33
34 pub fn for_bytes(bytes: &[u8]) -> Self {
35 Self(format!("sha256:{}", hex::encode(Sha256::digest(bytes))))
36 }
37
38 pub fn for_canonical<T: Serialize>(value: &T) -> Result<Self, PlanError> {
39 let bytes = serde_json_canonicalizer::to_vec(value)
40 .map_err(|_| PlanError::CanonicalSerialization)?;
41 Ok(Self::for_bytes(&bytes))
42 }
43}
44
45#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
46#[serde(rename_all = "kebab-case")]
47pub enum NativeInputState {
48 Missing,
49 RegularFile,
50}
51
52#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
54pub struct NativeInputFingerprint {
55 pub path: String,
56 pub state: NativeInputState,
57 pub size: u64,
58 pub content_sha256: Option<Fingerprint>,
59 pub metadata_sha256: Fingerprint,
60}
61
62pub struct NativeConfigSnapshot {
66 fingerprint: NativeInputFingerprint,
67 bytes: Option<Vec<u8>>,
68}
69
70impl std::fmt::Debug for NativeConfigSnapshot {
71 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 formatter
73 .debug_struct("NativeConfigSnapshot")
74 .field("fingerprint", &self.fingerprint)
75 .finish_non_exhaustive()
76 }
77}
78
79impl NativeConfigSnapshot {
80 pub fn capture(path: &Path, max_bytes: usize) -> Result<Self, PlanError> {
84 if max_bytes == 0 || max_bytes > MAX_NATIVE_CONFIG_BYTES {
85 return Err(PlanError::InvalidSizeLimit);
86 }
87 let (path, ancestor_identity) = canonical_target_path(path)?;
88 let printable_path = path.to_str().ok_or(PlanError::NonUtf8Path)?.to_owned();
89
90 let before = match std::fs::symlink_metadata(&path) {
91 Ok(metadata) => metadata,
92 Err(error) if error.kind() == io::ErrorKind::NotFound => {
93 let metadata_sha256 = Fingerprint::for_canonical(&(
94 printable_path.as_str(),
95 NativeInputState::Missing,
96 ancestor_identity,
97 ))?;
98 return Ok(Self {
99 fingerprint: NativeInputFingerprint {
100 path: printable_path,
101 state: NativeInputState::Missing,
102 size: 0,
103 content_sha256: None,
104 metadata_sha256,
105 },
106 bytes: None,
107 });
108 }
109 Err(error) => return Err(PlanError::Read(error.kind())),
110 };
111 ensure_regular_no_link(&before)?;
112 if before.len() > max_bytes as u64 {
113 return Err(PlanError::ConfigTooLarge);
114 }
115
116 let mut file = open_no_follow(&path).map_err(|error| match error.kind() {
117 io::ErrorKind::NotFound => PlanError::InputChanged,
118 kind => PlanError::Read(kind),
119 })?;
120 let opened = file
121 .metadata()
122 .map_err(|error| PlanError::Read(error.kind()))?;
123 ensure_regular_no_link(&opened)?;
124 if metadata_identity(&before)? != metadata_identity(&opened)? {
125 return Err(PlanError::InputChanged);
126 }
127
128 let mut bytes = Vec::with_capacity((opened.len() as usize).min(max_bytes));
129 file.by_ref()
130 .take(max_bytes as u64 + 1)
131 .read_to_end(&mut bytes)
132 .map_err(|error| PlanError::Read(error.kind()))?;
133 if bytes.len() > max_bytes {
134 return Err(PlanError::ConfigTooLarge);
135 }
136
137 let after_handle = file
138 .metadata()
139 .map_err(|error| PlanError::Read(error.kind()))?;
140 let after_path = std::fs::symlink_metadata(&path).map_err(|_| PlanError::InputChanged)?;
141 ensure_regular_no_link(&after_path)?;
142 let identity = metadata_identity(&opened)?;
143 if identity != metadata_identity(&after_handle)?
144 || identity != metadata_identity(&after_path)?
145 || after_handle.len() != bytes.len() as u64
146 {
147 return Err(PlanError::InputChanged);
148 }
149
150 Ok(Self {
151 fingerprint: NativeInputFingerprint {
152 path: printable_path,
153 state: NativeInputState::RegularFile,
154 size: bytes.len() as u64,
155 content_sha256: Some(Fingerprint::for_bytes(&bytes)),
156 metadata_sha256: Fingerprint::for_canonical(&identity)?,
157 },
158 bytes: Some(bytes),
159 })
160 }
161
162 pub fn fingerprint(&self) -> &NativeInputFingerprint {
163 &self.fingerprint
164 }
165
166 pub fn is_missing(&self) -> bool {
167 self.bytes.is_none()
168 }
169
170 pub(crate) fn bytes(&self) -> Option<&[u8]> {
171 self.bytes.as_deref()
172 }
173}
174
175#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
176#[serde(rename_all = "kebab-case")]
177pub enum NativeConfigFormat {
178 Json,
179 Toml,
180}
181
182#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
183pub struct NativeCandidateFingerprint {
184 pub path: String,
185 pub format: NativeConfigFormat,
186 pub size: u64,
187 pub content_sha256: Fingerprint,
188}
189
190pub struct NativeConfigCandidate {
192 input: NativeInputFingerprint,
193 fingerprint: NativeCandidateFingerprint,
194 bytes: Vec<u8>,
195}
196
197impl std::fmt::Debug for NativeConfigCandidate {
198 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199 formatter
200 .debug_struct("NativeConfigCandidate")
201 .field("input", &self.input)
202 .field("fingerprint", &self.fingerprint)
203 .finish_non_exhaustive()
204 }
205}
206
207impl NativeConfigCandidate {
208 pub(crate) fn new(
209 snapshot: &NativeConfigSnapshot,
210 format: NativeConfigFormat,
211 bytes: Vec<u8>,
212 ) -> Result<Self, PlanError> {
213 if bytes.len() > MAX_NATIVE_CONFIG_BYTES {
214 return Err(PlanError::ConfigTooLarge);
215 }
216 let fingerprint = NativeCandidateFingerprint {
217 path: snapshot.fingerprint.path.clone(),
218 format,
219 size: bytes.len() as u64,
220 content_sha256: Fingerprint::for_bytes(&bytes),
221 };
222 Ok(Self {
223 input: snapshot.fingerprint.clone(),
224 fingerprint,
225 bytes,
226 })
227 }
228
229 pub fn input(&self) -> &NativeInputFingerprint {
230 &self.input
231 }
232
233 pub fn fingerprint(&self) -> &NativeCandidateFingerprint {
234 &self.fingerprint
235 }
236
237 pub fn bytes(&self) -> &[u8] {
240 &self.bytes
241 }
242}
243
244#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
245#[serde(rename_all = "kebab-case")]
246pub enum PlanApplicability {
247 Ready,
248 ManualOnly,
249 Unsupported,
250}
251
252#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
253#[serde(rename_all = "kebab-case")]
254pub enum RequiredPrivilege {
255 None,
256 CurrentUser,
257 Root,
258 Administrator,
259 RemoteAdministrator,
260 Unknown,
261}
262
263#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
264#[serde(rename_all = "kebab-case")]
265pub enum ActivationRequirement {
266 None,
267 RestartDaemon,
268 RecreateBuilder,
269}
270
271#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
272#[serde(rename_all = "kebab-case")]
273pub enum EffectiveResolution {
274 Upstream,
275 Mirror,
276 RuntimeDefined,
277}
278
279#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
280#[serde(rename_all = "kebab-case")]
281pub enum PlannedCapability {
282 Pull,
283 Resolve,
284}
285
286#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
287#[serde(rename_all = "kebab-case")]
288pub enum DockerTargetKind {
289 Local,
290 Rootless,
291 Desktop,
292 Remote,
293 Unknown,
294}
295
296#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
297#[serde(rename_all = "kebab-case")]
298pub enum BuildkitTargetDriver {
299 Docker,
300 DockerContainer,
301 Kubernetes,
302 Remote,
303 Cloud,
304 Unknown,
305}
306
307#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
308#[serde(tag = "runtime", rename_all = "kebab-case")]
309pub enum MirrorPlanTarget {
310 Docker {
311 context: Fingerprint,
312 kind: DockerTargetKind,
313 endpoint: Option<RedactedUrl>,
314 version: Option<String>,
315 },
316 Containerd {
317 endpoint: RedactedUrl,
318 namespace: String,
319 version: Option<String>,
320 config_path: Option<String>,
321 },
322 Buildkit {
323 builder: String,
324 driver: BuildkitTargetDriver,
325 nodes: Vec<Fingerprint>,
326 version: Option<String>,
327 },
328}
329
330#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
334pub struct PlannedMirrorEndpoint {
335 pub origin: RedactedUrl,
336 pub has_path_prefix: bool,
337}
338
339#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
340#[serde(tag = "kind", rename_all = "kebab-case")]
341pub enum MirrorChange {
342 DockerHubMirrors {
343 mirrors: Vec<PlannedMirrorEndpoint>,
344 effective_resolution: EffectiveResolution,
345 },
346 ContainerdRegistryHosts {
347 registry: RegistryName,
348 mirrors: Vec<PlannedMirrorEndpoint>,
349 capabilities: BTreeSet<PlannedCapability>,
350 },
351 ContainerdConfigPath {
352 path: String,
353 },
354 BuildkitRegistryMirrors {
355 registry: RegistryName,
356 mirrors: Vec<PlannedMirrorEndpoint>,
357 effective_resolution: EffectiveResolution,
358 },
359}
360
361#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
362#[serde(rename_all = "kebab-case")]
363pub enum PlanWarning {
364 AnonymousOnlyNotEnforced,
365 DockerHubOnly,
366 ResolutionSeparationUnavailable,
367 RemoteTarget,
368 ManagedDesktop,
369 NativeConfigPathRequired,
370 ContainerdConfigPathMissing,
371 ExistingNativeEntriesPreserved,
372 DaemonRestartRequired,
373 BuilderRecreateRequired,
374 DockerDriverUsesEngineConfiguration,
375 ExternalBuilderConfiguration,
376}
377
378#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
379#[serde(rename_all = "kebab-case")]
380pub enum ValidationStep {
381 ParseCompleteJson,
382 ParseCompleteToml,
383 ValidateDockerDaemonConfig,
384 RediscoverRuntimeIdentity,
385 RediscoverBuilderIdentity,
386 CompareInputFingerprints,
387}
388
389#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
390pub struct MirrorPlan {
391 pub schema_version: u32,
392 pub plan_id: Fingerprint,
393 pub target: MirrorPlanTarget,
394 pub applicability: PlanApplicability,
395 pub policy_fingerprint: Fingerprint,
396 pub target_fingerprint: Fingerprint,
397 pub inputs: Vec<NativeInputFingerprint>,
398 pub candidates: Vec<NativeCandidateFingerprint>,
399 pub changes: Vec<MirrorChange>,
400 pub privilege: RequiredPrivilege,
401 pub activation: ActivationRequirement,
402 pub validation: BTreeSet<ValidationStep>,
403 pub warnings: BTreeSet<PlanWarning>,
404}
405
406impl MirrorPlan {
407 pub fn validate(&self) -> Result<(), PlanError> {
410 let expected = MirrorPlanDraft {
411 target: self.target.clone(),
412 applicability: self.applicability,
413 policy_fingerprint: self.policy_fingerprint.clone(),
414 inputs: self.inputs.clone(),
415 candidates: self.candidates.clone(),
416 changes: self.changes.clone(),
417 privilege: self.privilege,
418 activation: self.activation,
419 validation: self.validation.clone(),
420 warnings: self.warnings.clone(),
421 }
422 .finalize()?;
423 if expected == *self {
424 Ok(())
425 } else {
426 Err(PlanError::InvalidPlan)
427 }
428 }
429}
430
431pub struct MirrorPlanDraft {
434 pub target: MirrorPlanTarget,
435 pub applicability: PlanApplicability,
436 pub policy_fingerprint: Fingerprint,
437 pub inputs: Vec<NativeInputFingerprint>,
438 pub candidates: Vec<NativeCandidateFingerprint>,
439 pub changes: Vec<MirrorChange>,
440 pub privilege: RequiredPrivilege,
441 pub activation: ActivationRequirement,
442 pub validation: BTreeSet<ValidationStep>,
443 pub warnings: BTreeSet<PlanWarning>,
444}
445
446impl MirrorPlanDraft {
447 pub fn finalize(mut self) -> Result<MirrorPlan, PlanError> {
448 self.inputs
449 .sort_by(|left, right| left.path.cmp(&right.path));
450 self.candidates
451 .sort_by(|left, right| left.path.cmp(&right.path));
452 let target_fingerprint = Fingerprint::for_canonical(&self.target)?;
453 let unsigned = UnsignedPlan {
454 schema_version: MIRROR_PLAN_SCHEMA_VERSION,
455 target: &self.target,
456 applicability: self.applicability,
457 policy_fingerprint: &self.policy_fingerprint,
458 target_fingerprint: &target_fingerprint,
459 inputs: &self.inputs,
460 candidates: &self.candidates,
461 changes: &self.changes,
462 privilege: self.privilege,
463 activation: self.activation,
464 validation: &self.validation,
465 warnings: &self.warnings,
466 };
467 let plan_id = Fingerprint::for_canonical(&unsigned)?;
468 Ok(MirrorPlan {
469 schema_version: MIRROR_PLAN_SCHEMA_VERSION,
470 plan_id,
471 target: self.target,
472 applicability: self.applicability,
473 policy_fingerprint: self.policy_fingerprint,
474 target_fingerprint,
475 inputs: self.inputs,
476 candidates: self.candidates,
477 changes: self.changes,
478 privilege: self.privilege,
479 activation: self.activation,
480 validation: self.validation,
481 warnings: self.warnings,
482 })
483 }
484}
485
486#[derive(Serialize)]
487struct UnsignedPlan<'a> {
488 schema_version: u32,
489 target: &'a MirrorPlanTarget,
490 applicability: PlanApplicability,
491 policy_fingerprint: &'a Fingerprint,
492 target_fingerprint: &'a Fingerprint,
493 inputs: &'a [NativeInputFingerprint],
494 candidates: &'a [NativeCandidateFingerprint],
495 changes: &'a [MirrorChange],
496 privilege: RequiredPrivilege,
497 activation: ActivationRequirement,
498 validation: &'a BTreeSet<ValidationStep>,
499 warnings: &'a BTreeSet<PlanWarning>,
500}
501
502#[derive(Debug)]
505pub struct MirrorPlanBundle {
506 pub plan: MirrorPlan,
507 pub candidates: Vec<NativeConfigCandidate>,
508}
509
510pub fn policy_fingerprint(
511 registry: &RegistryName,
512 policy: &ContainerRegistryConfig,
513) -> Result<Fingerprint, PlanError> {
514 #[derive(Serialize)]
515 struct Policy<'a> {
516 registry: &'a RegistryName,
517 mirrors: &'a [String],
518 anonymous_only: bool,
519 resolve: ContainerResolve,
520 }
521 Fingerprint::for_canonical(&Policy {
522 registry,
523 mirrors: &policy.mirrors,
524 anonymous_only: policy.anonymous_only,
525 resolve: policy.resolve,
526 })
527}
528
529#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
530pub enum PlanError {
531 #[error("mirror plan identity does not match its contents")]
532 InvalidPlan,
533 #[error("native configuration path has no final file name")]
534 InvalidPath,
535 #[error("native configuration path is not valid UTF-8")]
536 NonUtf8Path,
537 #[error("native configuration size limit must be positive")]
538 InvalidSizeLimit,
539 #[error("native configuration is not a regular no-follow file")]
540 UnsafeFileType,
541 #[error("native configuration exceeds the bounded snapshot limit")]
542 ConfigTooLarge,
543 #[error("native configuration changed while it was being inspected")]
544 InputChanged,
545 #[error("could not read native configuration ({0:?})")]
546 Read(io::ErrorKind),
547 #[error("could not canonicalize plan data")]
548 CanonicalSerialization,
549}
550
551fn canonical_target_path(path: &Path) -> Result<(PathBuf, Vec<(String, String)>), PlanError> {
552 use std::path::Component;
553
554 if path.file_name().is_none()
555 || path
556 .components()
557 .any(|component| component == Component::ParentDir)
558 {
559 return Err(PlanError::InvalidPath);
560 }
561 let absolute = if path.is_absolute() {
562 path.to_path_buf()
563 } else {
564 std::env::current_dir()
565 .map_err(|error| PlanError::Read(error.kind()))?
566 .join(path)
567 };
568 let file_name = absolute
569 .file_name()
570 .ok_or(PlanError::InvalidPath)?
571 .to_os_string();
572 let mut existing = absolute.parent().ok_or(PlanError::InvalidPath)?;
575 let mut missing = Vec::new();
576 while !existing.exists() {
577 let name = existing.file_name().ok_or(PlanError::InvalidPath)?;
578 missing.push(name.to_os_string());
579 existing = existing.parent().ok_or(PlanError::InvalidPath)?;
580 }
581 let existing = dunce::canonicalize(existing).map_err(|error| PlanError::Read(error.kind()))?;
582 let existing_metadata =
583 std::fs::metadata(&existing).map_err(|error| PlanError::Read(error.kind()))?;
584 let ancestor_identity = metadata_identity(&existing_metadata)?;
585 let mut canonical = existing;
586 for component in missing.into_iter().rev() {
587 canonical.push(component);
588 }
589 canonical.push(file_name);
590 Ok((canonical, ancestor_identity))
591}
592
593fn ensure_regular_no_link(metadata: &std::fs::Metadata) -> Result<(), PlanError> {
594 if !metadata.file_type().is_file() || metadata.file_type().is_symlink() || is_reparse(metadata)
595 {
596 return Err(PlanError::UnsafeFileType);
597 }
598 Ok(())
599}
600
601#[cfg(windows)]
602fn is_reparse(metadata: &std::fs::Metadata) -> bool {
603 use std::os::windows::fs::MetadataExt;
604 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
605 metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
606}
607
608#[cfg(not(windows))]
609fn is_reparse(_metadata: &std::fs::Metadata) -> bool {
610 false
611}
612
613fn open_no_follow(path: &Path) -> io::Result<File> {
614 let mut options = OpenOptions::new();
615 options.read(true);
616 #[cfg(unix)]
617 {
618 use std::os::unix::fs::OpenOptionsExt;
619 options.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW);
620 }
621 #[cfg(windows)]
622 {
623 use std::os::windows::fs::OpenOptionsExt;
624 const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
625 options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
626 }
627 options.open(path)
628}
629
630fn metadata_identity(metadata: &std::fs::Metadata) -> Result<Vec<(String, String)>, PlanError> {
631 let modified = metadata
632 .modified()
633 .ok()
634 .and_then(|value| value.duration_since(UNIX_EPOCH).ok())
635 .map(|value| value.as_nanos().to_string())
636 .unwrap_or_default();
637 let mut values = vec![
638 ("len".to_owned(), metadata.len().to_string()),
639 ("modified-nanos".to_owned(), modified),
640 (
641 "readonly".to_owned(),
642 metadata.permissions().readonly().to_string(),
643 ),
644 ];
645 #[cfg(unix)]
646 {
647 use std::os::unix::fs::MetadataExt;
648 values.extend([
649 ("dev".to_owned(), metadata.dev().to_string()),
650 ("ino".to_owned(), metadata.ino().to_string()),
651 ("mode".to_owned(), metadata.mode().to_string()),
652 ]);
653 }
654 #[cfg(windows)]
655 {
656 use std::os::windows::fs::MetadataExt;
657 values.extend([
658 (
659 "attributes".to_owned(),
660 metadata.file_attributes().to_string(),
661 ),
662 (
663 "creation-time".to_owned(),
664 metadata.creation_time().to_string(),
665 ),
666 (
667 "last-write-time".to_owned(),
668 metadata.last_write_time().to_string(),
669 ),
670 ]);
671 }
672 values.sort();
673 Ok(values)
674}
675
676#[cfg(test)]
677mod tests {
678 use super::*;
679
680 fn draft(input: NativeInputFingerprint) -> MirrorPlanDraft {
681 let target = MirrorPlanTarget::Docker {
682 context: Fingerprint::for_bytes(b"default"),
683 kind: DockerTargetKind::Local,
684 endpoint: None,
685 version: Some("28.0.0".into()),
686 };
687 MirrorPlanDraft {
688 target,
689 applicability: PlanApplicability::Ready,
690 policy_fingerprint: Fingerprint::for_bytes(b"policy"),
691 inputs: vec![input],
692 candidates: Vec::new(),
693 changes: vec![MirrorChange::DockerHubMirrors {
694 mirrors: vec![PlannedMirrorEndpoint {
695 origin: RedactedUrl::parse("https://mirror.example/").unwrap(),
696 has_path_prefix: false,
697 }],
698 effective_resolution: EffectiveResolution::RuntimeDefined,
699 }],
700 privilege: RequiredPrivilege::Root,
701 activation: ActivationRequirement::RestartDaemon,
702 validation: BTreeSet::from([ValidationStep::ParseCompleteJson]),
703 warnings: BTreeSet::new(),
704 }
705 }
706
707 #[test]
708 fn snapshot_is_bounded_nofollow_and_records_missing_state() {
709 let temporary = tempfile::tempdir().unwrap();
710 let file = temporary.path().join("daemon.json");
711 std::fs::write(&file, b"{\"debug\":true}").unwrap();
712 let snapshot = NativeConfigSnapshot::capture(&file, 64).unwrap();
713 assert_eq!(snapshot.fingerprint.state, NativeInputState::RegularFile);
714 assert_eq!(snapshot.bytes(), Some(&b"{\"debug\":true}"[..]));
715 assert_eq!(
716 NativeConfigSnapshot::capture(&file, 4).unwrap_err(),
717 PlanError::ConfigTooLarge
718 );
719 assert_eq!(
720 NativeConfigSnapshot::capture(&file, MAX_NATIVE_CONFIG_BYTES + 1).unwrap_err(),
721 PlanError::InvalidSizeLimit
722 );
723
724 let missing =
725 NativeConfigSnapshot::capture(&temporary.path().join("missing.json"), 64).unwrap();
726 assert_eq!(missing.fingerprint.state, NativeInputState::Missing);
727 assert!(missing.bytes().is_none());
728 }
729
730 #[cfg(unix)]
731 #[test]
732 fn snapshot_rejects_symlink_without_following_it() {
733 use std::os::unix::fs::symlink;
734 let temporary = tempfile::tempdir().unwrap();
735 let target = temporary.path().join("secret");
736 let link = temporary.path().join("daemon.json");
737 std::fs::write(&target, b"secret").unwrap();
738 symlink(&target, &link).unwrap();
739 assert_eq!(
740 NativeConfigSnapshot::capture(&link, 64).unwrap_err(),
741 PlanError::UnsafeFileType
742 );
743 }
744
745 #[test]
746 fn candidate_debug_and_plan_json_do_not_contain_native_bytes() {
747 let temporary = tempfile::tempdir().unwrap();
748 let file = temporary.path().join("daemon.json");
749 std::fs::write(&file, b"{\"token\":\"top-secret\"}").unwrap();
750 let snapshot = NativeConfigSnapshot::capture(&file, 128).unwrap();
751 let candidate = NativeConfigCandidate::new(
752 &snapshot,
753 NativeConfigFormat::Json,
754 b"{\"token\":\"changed-secret\"}".to_vec(),
755 )
756 .unwrap();
757 let mut draft = draft(snapshot.fingerprint().clone());
758 draft.candidates.push(candidate.fingerprint().clone());
759 let plan = draft.finalize().unwrap();
760 let json = serde_json::to_string(&plan).unwrap();
761 let debug = format!("{candidate:?}");
762 for secret in ["top-secret", "changed-secret"] {
763 assert!(!json.contains(secret));
764 assert!(!debug.contains(secret));
765 }
766 }
767
768 #[test]
769 fn deterministic_id_binds_semantics_but_not_input_order() {
770 let temporary = tempfile::tempdir().unwrap();
771 let first = NativeConfigSnapshot::capture(&temporary.path().join("a"), 64)
772 .unwrap()
773 .fingerprint()
774 .clone();
775 let second = NativeConfigSnapshot::capture(&temporary.path().join("b"), 64)
776 .unwrap()
777 .fingerprint()
778 .clone();
779 let mut left = draft(first.clone());
780 left.inputs.push(second.clone());
781 let mut right = draft(second);
782 right.inputs.push(first);
783 let left = left.finalize().unwrap();
784 let right = right.finalize().unwrap();
785 assert_eq!(left.plan_id, right.plan_id);
786
787 let mut changed = draft(left.inputs[0].clone());
788 changed.applicability = PlanApplicability::ManualOnly;
789 assert_ne!(left.plan_id, changed.finalize().unwrap().plan_id);
790 }
791
792 #[test]
793 fn validate_rejects_a_tampered_plan_id_or_semantic_field() {
794 let temporary = tempfile::tempdir().unwrap();
795 let input = NativeConfigSnapshot::capture(&temporary.path().join("daemon.json"), 64)
796 .unwrap()
797 .fingerprint()
798 .clone();
799 let plan = draft(input).finalize().unwrap();
800 assert_eq!(plan.validate(), Ok(()));
801
802 let mut bad_id = plan.clone();
803 bad_id.plan_id = Fingerprint::for_bytes(b"different");
804 assert_eq!(bad_id.validate(), Err(PlanError::InvalidPlan));
805
806 let mut bad_semantics = plan;
807 bad_semantics.activation = ActivationRequirement::None;
808 assert_eq!(bad_semantics.validate(), Err(PlanError::InvalidPlan));
809 }
810
811 #[test]
812 fn policy_fingerprint_binds_order_and_resolution() {
813 let registry = RegistryName::parse("docker.io").unwrap();
814 let mut policy = ContainerRegistryConfig {
815 mirrors: vec!["https://a.example/".into(), "https://b.example/".into()],
816 anonymous_only: true,
817 resolve: ContainerResolve::Upstream,
818 };
819 let original = policy_fingerprint(®istry, &policy).unwrap();
820 policy.mirrors.reverse();
821 assert_ne!(original, policy_fingerprint(®istry, &policy).unwrap());
822 policy.mirrors.reverse();
823 policy.resolve = ContainerResolve::Mirror;
824 assert_ne!(original, policy_fingerprint(®istry, &policy).unwrap());
825 }
826}