Skip to main content

osdk_core/container/
plan.rs

1//! Versioned, deterministic contracts for read-only native mirror plans.
2//!
3//! A plan records semantic changes and fingerprints only. Native configuration
4//! bytes stay in the non-serializable [`NativeConfigSnapshot`] and
5//! [`NativeConfigCandidate`] values so diagnostic JSON cannot become a copy of
6//! daemon configuration. This module performs no writes.
7
8use 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/// A stable SHA-256 identity over canonical JSON or exact file bytes.
25#[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/// Serializable stale-input identity. It contains no native file contents.
53#[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
62/// A bounded, no-follow snapshot used only while constructing a plan.
63///
64/// The custom `Debug` implementation deliberately omits the captured bytes.
65pub 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    /// Capture an existing regular file, or record an explicit missing-file
81    /// state. The final path component is never followed through a symlink or
82    /// Windows reparse point.
83    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
190/// Candidate bytes accompany a plan in memory but are never serializable.
191pub 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    /// Complete validated candidate bytes for an explicit renderer or future
238    /// stale-checked apply consumer. Merely building a plan never writes them.
239    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/// Secret-safe identity of a configured mirror endpoint. Native candidate
331/// bytes retain the complete URL, while serialized plans expose only origin
332/// and the presence of a routing prefix.
333#[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    /// Recompute the self-authenticating plan identifier before a mutating
408    /// consumer trusts any public field.
409    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
431/// Builder-friendly unsigned plan. Finalization sorts file identities and binds
432/// `plan_id` to every semantic field except the ID itself.
433pub 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/// In-memory result: serializable semantic plan plus non-serializable candidate
503/// bytes. No file is written by constructing this value.
504#[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    // Resolve only ancestors. Resolving `absolute` itself would follow the
573    // final symlink before the no-follow metadata/open checks below.
574    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(&registry, &policy).unwrap();
820        policy.mirrors.reverse();
821        assert_ne!(original, policy_fingerprint(&registry, &policy).unwrap());
822        policy.mirrors.reverse();
823        policy.resolve = ContainerResolve::Mirror;
824        assert_ne!(original, policy_fingerprint(&registry, &policy).unwrap());
825    }
826}