Skip to main content

ignition_core/deploy/
plan.rs

1//! The plan engine (Phase 22): the rendered tree against what is live.
2//!
3//! Pure: it takes a [`RenderedTree`] and an [`ObservedFiles`] and returns a
4//! [`Plan`]. No I/O, no clock, no network; the transport reads, this
5//! compares.
6//!
7//! ## Units
8//!
9//! The plan reasons about **units**, not files. A file's unit is the key of
10//! its *unit directory*: the nearest ancestor directory (the file's own
11//! directory included) that holds `resource.json` or `unary-resource.json`
12//! in the same layer, else the file's parent directory ([`unit_key`]).
13//! Resources therefore group per `<module>/<type>/<name>`, singletons per
14//! `<module>/<type>`, and tag files per folder
15//! (`ignition/tag-definition/<P>/<Folder>`), never one entry per provider.
16//!
17//! ## Basis (D-17)
18//!
19//! A rendered unit is compared with its **effective live copy**: the copy in
20//! the mode collection when the unit exists there, else the copy in `core`
21//! (the gateway overrides whole resources, and tag folders, by name). Each
22//! entry's `live_in` names where live copies sit, the ordered subset of
23//! `["core", "mode"]`, so a `same` unit that is not yet in the mode
24//! collection is still visible.
25//!
26//! | status | meaning |
27//! |---|---|
28//! | `added` | rendered, no effective live copy |
29//! | `changed` | rendered, effective copy differs in file set or any byte |
30//! | `same` | rendered, effective copy has the same files, byte-equal |
31//! | `removed` | not rendered, and the declared-names ledger lists a file of the unit that still exists in the mode collection (the delete set) |
32//! | `unmanaged` | live, of an allowlisted type or declared provider, not rendered; never deleted, never counted in `has_changes` (D-06) |
33//!
34//! `core` relates the `core` collection's copy of the unit to the rendered
35//! unit: `none` (no core copy), `identical` (same files, byte-equal),
36//! `differs`, or `hidden` (a tag-definition or tag-type-definition folder
37//! defined in the mode collection whose core copy holds files the rendered
38//! unit lacks: the gateway overrides these by folder, so those core files
39//! are invisible), or `shadows_removal` (a `removed` unit that also has a
40//! `core` copy: deleting from the mode collection will not take effect, the
41//! gateway keeps running the `core` copy).
42//!
43//! Comparison is byte-exact per file: a whitespace-only or key-order change
44//! is `changed`.
45//!
46//! ## The delete set and `removed` (D-05, 22-03)
47//!
48//! The delete set is exactly the declared-names ledger's files minus the
49//! rendered files, restricted to files that exist in the mode collection. No
50//! ledger (a first deploy) gives an empty delete set; a ledger that exists
51//! but is unusable is refused before this function runs (the caller parses
52//! it with [`Ledger::parse`]). A ledger path is never in the delete set when
53//! it is:
54//!
55//! - rendered (byte-equal path), or equal to a rendered path under ASCII
56//!   case folding (a case-insensitive host would delete the rendered file;
57//!   this one is reported in `warnings`),
58//! - under a type or tag provider the manifest no longer allows (reported in
59//!   `warnings`, never deleted), or
60//! - `config-mode.json` or under a dot-named component.
61//!
62//! Paths compare as exact UTF-8 bytes. A rendered unit that loses only some
63//! of its ledger files is `changed` and deletes just those files; a unit that
64//! is not rendered and has delete-set files is `removed`; `unmanaged` is a
65//! live unit with no delete-set file. Plan never writes or rewrites the
66//! ledger.
67//!
68//! ## Flags (22-03)
69//!
70//! Derived only from status, delete-set membership and type, so the same
71//! input gives the same flags. `destructive` is true for `removed` entries
72//! and for any entry with delete-set files; `destructive_reason` is then
73//! `tag_data_loss` (`ignition/tag-provider`, `tag-group`,
74//! `tag-type-definition`, `tag-definition`), `connection_removed`
75//! (`ignition/database-connection`, `opc-connection`, `identity-provider`),
76//! `storage_removed` (`ignition/store-and-forward-engine`, `alarm-journal`),
77//! else `resource_removed` for `removed` and `files_removed` for `changed`.
78//! `lockout_risk` is true for every entry that is not `same` and not
79//! `unmanaged` (one the plan would touch) of `ignition/identity-provider` or
80//! `ignition/security-zone`.
81//!
82//! ## `plan_sha256` (D-08)
83//!
84//! The lowercase hex SHA-256 of this UTF-8 text, byte for byte. `LF` is
85//! `0x0A`, `NUL` is `0x00`:
86//!
87//! ```text
88//! ign-deploy:plan:v1 LF
89//! render_sha256 NUL <render_sha256 hex> LF
90//! E NUL <key> NUL <status> NUL <core> NUL <live_in joined by ","> LF
91//! F NUL <collection path> NUL <live sha256 or -> NUL <desired sha256 or -> LF
92//! D NUL <collection path> LF
93//! ```
94//!
95//! One `E` line per entry that is not `unmanaged`, in unit-key byte order,
96//! each followed by its `F` lines in path byte order; then one `D` line per
97//! delete-set path in byte order (none without a ledger). `status` and `core` are
98//! the serialized words (`added`, `identical`, ...). Drift, diff text and
99//! warnings are excluded on purpose, as are `unmanaged` entries, so a review
100//! hash is reproducible with or without `--base` and unrelated live
101//! resources never invalidate it. Changing this text means re-goldening
102//! (`PLAN_GOLDEN` in `tests/deploy_plan.rs`) and Phase 23's recomputation.
103
104use std::collections::{BTreeMap, BTreeSet};
105use std::io::Read;
106use std::path::Path;
107
108use serde::Serialize;
109use sha2::{Digest, Sha256};
110
111use similar::TextDiff;
112
113use super::layout::{RelPath, TAG_TYPES, resource_key};
114use super::ledger::Ledger;
115use super::lint::{scan_embedded, scan_secrets};
116use super::node::{Node, classify};
117use super::rules::GATEWAY_OWNED_FILES;
118use super::transport::{
119    DeleteSet, FileBlob, ObservedFiles, READ_MAX_ENTRIES, READ_MAX_FILE_BYTES,
120    READ_MAX_TOTAL_BYTES, Scope,
121};
122use super::tree::{
123    MarkerFile, OpenError, RENDER_MARKER, RenderMarker, RenderedTree, TMP_SUFFIX,
124    open_regular_file, sha256_hex, walk_capped,
125};
126use crate::error::CoreError;
127
128/// First line of the canonical `plan_sha256` text.
129pub const PLAN_HASH_HEADER: &str = "ign-deploy:plan:v1";
130
131/// The plan status of one unit.
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
133#[serde(rename_all = "lowercase")]
134pub enum PlanStatus {
135    /// Rendered, no effective live copy.
136    Added,
137    /// Rendered, effective copy differs.
138    Changed,
139    /// Not rendered, with delete-set files in the mode collection.
140    Removed,
141    /// Rendered, effective copy byte-equal.
142    Same,
143    /// Live, allowlisted, not rendered; never deleted (D-06).
144    Unmanaged,
145}
146
147impl PlanStatus {
148    /// The status word as serialized and printed.
149    pub fn word(self) -> &'static str {
150        match self {
151            PlanStatus::Added => "added",
152            PlanStatus::Changed => "changed",
153            PlanStatus::Removed => "removed",
154            PlanStatus::Same => "same",
155            PlanStatus::Unmanaged => "unmanaged",
156        }
157    }
158}
159
160/// How the `core` copy of a unit relates to the rendered unit.
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
162#[serde(rename_all = "snake_case")]
163pub enum CoreRelation {
164    /// The unit has no `core` copy.
165    None,
166    /// The `core` copy has the same files, byte-equal to the render.
167    Identical,
168    /// The `core` copy exists and differs.
169    Differs,
170    /// A tag folder defined in the mode collection hides `core`-only files.
171    Hidden,
172    /// Status `removed` while a `core` copy exists: deleting from the mode
173    /// collection will not take effect.
174    ShadowsRemoval,
175}
176
177impl CoreRelation {
178    /// The relation word as serialized and printed.
179    pub fn word(self) -> &'static str {
180        match self {
181            CoreRelation::None => "none",
182            CoreRelation::Identical => "identical",
183            CoreRelation::Differs => "differs",
184            CoreRelation::Hidden => "hidden",
185            CoreRelation::ShadowsRemoval => "shadows_removal",
186        }
187    }
188}
189
190/// One file of a unit: its live and rendered digests.
191#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
192pub struct PlanFile {
193    /// Collection-relative path.
194    pub path: String,
195    /// SHA-256 of the effective live copy; null when the file is not live.
196    pub live_sha256: Option<String>,
197    /// SHA-256 of the rendered file; null when the file is not rendered.
198    pub desired_sha256: Option<String>,
199}
200
201/// One unit of the plan.
202#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
203pub struct PlanEntry {
204    /// The unit key (the unit directory, collection-relative).
205    pub key: String,
206    /// Module id, e.g. `ignition`.
207    pub module: String,
208    /// Resource type, e.g. `database-translator`.
209    #[serde(rename = "type")]
210    pub ty: String,
211    /// Resource name (or tag provider and folder path); null for a singleton.
212    pub name: Option<String>,
213    /// The unit's status.
214    pub status: PlanStatus,
215    /// Where live copies sit: the ordered subset of `["core", "mode"]`.
216    pub live_in: Vec<String>,
217    /// The `core` copy's relation to the rendered unit.
218    pub core: CoreRelation,
219    /// True for `removed` entries and entries with delete-set files.
220    pub destructive: bool,
221    /// Why the entry is destructive (module doc); null when it is not.
222    pub destructive_reason: Option<String>,
223    /// True when a unit of a security type that the plan touches could lock
224    /// the operator out of the gateway.
225    pub lockout_risk: bool,
226    /// Three-way drift against a `--base` render: true when any file's live
227    /// sha256 differs from both the base and the desired sha256. Null
228    /// without `--base`. Excluded from `plan_sha256`.
229    pub drift: Option<bool>,
230    /// The unit's files, byte-sorted.
231    pub files: Vec<PlanFile>,
232    /// Redacted unified text diff of the unit's differing files (`--diff`);
233    /// null without it, and for `same` and `unmanaged` entries. Excluded from
234    /// `plan_sha256`.
235    pub diff: Option<String>,
236}
237
238/// Counts by status.
239#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
240pub struct PlanSummary {
241    /// Units that would be added.
242    pub added: usize,
243    /// Units that would change.
244    pub changed: usize,
245    /// Units that would be removed.
246    pub removed: usize,
247    /// Units already equal.
248    pub same: usize,
249    /// Live units the declared tree does not manage.
250    pub unmanaged: usize,
251    /// True when anything would be added, changed or removed. `unmanaged`
252    /// never counts (D-06).
253    pub has_changes: bool,
254    /// Entries with `drift: true`; null without `--base`.
255    pub drifted: Option<usize>,
256}
257
258/// The plan.
259#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
260pub struct Plan {
261    /// Units in key byte order.
262    pub entries: Vec<PlanEntry>,
263    /// Counts by status.
264    pub summary: PlanSummary,
265    /// The files plan proposes to delete (module doc).
266    pub delete_set: DeleteSet,
267    /// Lowercase hex SHA-256 of the canonical plan text (module doc).
268    pub plan_sha256: String,
269    /// Plan-level warnings.
270    pub warnings: Vec<String>,
271}
272
273/// The unit key of `path` within `layer`: the nearest ancestor directory
274/// (the file's own directory included) holding `resource.json` or
275/// `unary-resource.json` in `layer`, else the file's parent directory.
276/// Never climbs above `<module>/<type>`.
277pub fn unit_key<V>(path: &RelPath, layer: &BTreeMap<RelPath, V>) -> String {
278    let parts: Vec<&str> = path.components().collect();
279    if parts.len() < 2 {
280        return path.as_str().to_string();
281    }
282    let parent_len = parts.len() - 1;
283    for end in (2..=parent_len).rev() {
284        let dir = parts[..end].join("/");
285        for marker in ["resource.json", "unary-resource.json"] {
286            if let Ok(candidate) = RelPath::new(&format!("{dir}/{marker}"))
287                && layer.contains_key(&candidate)
288            {
289                return dir;
290            }
291        }
292    }
293    parts[..parent_len].join("/")
294}
295
296/// path -> sha256, grouped by unit key.
297type Units = BTreeMap<String, BTreeMap<String, String>>;
298
299fn group<'a, V: 'a>(layer: &'a BTreeMap<RelPath, V>, digest: impl Fn(&'a V) -> String) -> Units {
300    let mut units = Units::new();
301    for (path, value) in layer {
302        units
303            .entry(unit_key(path, layer))
304            .or_default()
305            .insert(path.as_str().to_string(), digest(value));
306    }
307    units
308}
309
310fn blob_digest(blob: &FileBlob) -> String {
311    blob.sha256.clone()
312}
313
314/// Whether `key` is a tag-definition or tag-type-definition unit below a
315/// provider (`ignition/<type>/<provider>[/...]`). The gateway overrides these
316/// by folder, so a mode-collection copy hides `core` files it does not hold.
317fn is_tag_folder(key: &str) -> bool {
318    let mut parts = key.split('/');
319    parts.next() == Some("ignition")
320        && matches!(parts.next(), Some("tag-definition" | "tag-type-definition"))
321        && parts.next().is_some()
322}
323
324/// Whether the `core` copy holds a file the rendered unit lacks.
325fn hides(core: &BTreeMap<String, String>, rendered: &BTreeMap<String, String>) -> bool {
326    core.keys().any(|path| !rendered.contains_key(path))
327}
328
329/// Whether the manifest's `scope` allows `path`: its `<module>/<type>` is a
330/// listed type, or it is under `ignition/<tag type>/<provider>` for a listed
331/// tag provider.
332fn in_scope(scope: &Scope, path: &RelPath) -> bool {
333    let parts: Vec<&str> = path.components().collect();
334    if parts.len() < 3 {
335        return false;
336    }
337    let (module, ty) = (parts[0], parts[1]);
338    if scope.types.iter().any(|(m, t)| m == module && t == ty) {
339        return true;
340    }
341    module == "ignition"
342        && TAG_TYPES.contains(&ty)
343        && scope.tag_providers.iter().any(|p| p == parts[2])
344}
345
346/// The delete set (module doc) and the warnings it raised, both in path
347/// byte order.
348fn delete_set(
349    rendered: &RenderedTree,
350    observed: &ObservedFiles,
351    ledger: Option<&Ledger>,
352    scope: &Scope,
353) -> (Vec<RelPath>, Vec<String>) {
354    let Some(ledger) = ledger else {
355        return (Vec::new(), Vec::new());
356    };
357    let rendered_folded: BTreeSet<String> = rendered
358        .files
359        .keys()
360        .map(|p| p.as_str().to_ascii_lowercase())
361        .collect();
362    let mut listed: Vec<&String> = ledger.files.iter().collect();
363    listed.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
364    listed.dedup();
365
366    let mut delete = Vec::new();
367    let mut warnings = Vec::new();
368    for raw in listed {
369        let Ok(path) = RelPath::new(raw) else {
370            continue;
371        };
372        if path
373            .components()
374            .any(|c| c.starts_with('.') || GATEWAY_OWNED_FILES.contains(&c))
375        {
376            continue;
377        }
378        if !in_scope(scope, &path) {
379            warnings.push(format!(
380                "ledger file {path} is under a type or tag provider the manifest no longer allows; it is not deleted"
381            ));
382            continue;
383        }
384        if rendered.files.contains_key(&path) || !observed.mode.contains_key(&path) {
385            continue;
386        }
387        if rendered_folded.contains(&path.as_str().to_ascii_lowercase()) {
388            warnings.push(format!(
389                "ledger file {path} differs from a rendered file only by case; it is not deleted because a case-insensitive host would delete the rendered file"
390            ));
391            continue;
392        }
393        delete.push(path);
394    }
395    (delete, warnings)
396}
397
398/// The `destructive_reason` of a destructive entry (module doc).
399fn destructive_reason(module: &str, ty: &str, status: PlanStatus) -> &'static str {
400    match (module, ty) {
401        ("ignition", "tag-provider" | "tag-group" | "tag-type-definition" | "tag-definition") => {
402            "tag_data_loss"
403        }
404        ("ignition", "database-connection" | "opc-connection" | "identity-provider") => {
405            "connection_removed"
406        }
407        ("ignition", "store-and-forward-engine" | "alarm-journal") => "storage_removed",
408        _ if status == PlanStatus::Removed => "resource_removed",
409        _ => "files_removed",
410    }
411}
412
413/// Compare `rendered` with `observed`. `ledger` is the parsed declared-names
414/// ledger, `None` for a first deploy; `scope` is the manifest's current
415/// allowlist (it bounds what the ledger may delete).
416pub fn plan(
417    rendered: &RenderedTree,
418    observed: &ObservedFiles,
419    ledger: Option<&Ledger>,
420    scope: &Scope,
421) -> Plan {
422    let desired = group(&rendered.files, |bytes: &Vec<u8>| sha256_hex(bytes));
423    let core = group(&observed.core, blob_digest);
424    let mode = group(&observed.mode, blob_digest);
425    let (delete, ledger_warnings) = delete_set(rendered, observed, ledger, scope);
426    let mut deleted: BTreeMap<String, usize> = BTreeMap::new();
427    for path in &delete {
428        *deleted.entry(unit_key(path, &observed.mode)).or_default() += 1;
429    }
430
431    let keys: BTreeSet<&String> = desired
432        .keys()
433        .chain(core.keys())
434        .chain(mode.keys())
435        .collect();
436    let empty = BTreeMap::new();
437
438    let mut entries = Vec::with_capacity(keys.len());
439    for key in keys {
440        let wanted = desired.get(key);
441        let core_copy = core.get(key);
442        let mode_copy = mode.get(key);
443        let effective = mode_copy.or(core_copy);
444
445        let mut live_in = Vec::new();
446        if core_copy.is_some() {
447            live_in.push("core".to_string());
448        }
449        if mode_copy.is_some() {
450            live_in.push("mode".to_string());
451        }
452
453        let deleted_here = deleted.get(key).copied().unwrap_or(0);
454        let status = match (wanted, effective) {
455            (Some(_), None) => PlanStatus::Added,
456            (Some(w), Some(e)) if w == e && deleted_here == 0 => PlanStatus::Same,
457            (Some(_), Some(_)) => PlanStatus::Changed,
458            (None, _) if deleted_here > 0 => PlanStatus::Removed,
459            (None, _) => PlanStatus::Unmanaged,
460        };
461        let rendered_files = wanted.unwrap_or(&empty);
462        let core_relation = match core_copy {
463            None => CoreRelation::None,
464            Some(_) if status == PlanStatus::Removed => CoreRelation::ShadowsRemoval,
465            Some(c) if is_tag_folder(key) && mode_copy.is_some() && hides(c, rendered_files) => {
466                CoreRelation::Hidden
467            }
468            Some(c) if c == rendered_files => CoreRelation::Identical,
469            Some(_) => CoreRelation::Differs,
470        };
471
472        let paths: BTreeSet<&String> = wanted
473            .into_iter()
474            .chain(effective)
475            .flat_map(BTreeMap::keys)
476            .collect();
477        let files = paths
478            .into_iter()
479            .map(|path| PlanFile {
480                path: path.clone(),
481                live_sha256: effective.and_then(|e| e.get(path)).cloned(),
482                desired_sha256: wanted.and_then(|w| w.get(path)).cloned(),
483            })
484            .collect();
485
486        let parts: Vec<&str> = key.split('/').collect();
487        let module = parts.first().copied().unwrap_or_default();
488        let ty = parts.get(1).copied().unwrap_or_default();
489        let destructive = status == PlanStatus::Removed || deleted_here > 0;
490        let lockout_risk = matches!(
491            status,
492            PlanStatus::Added | PlanStatus::Changed | PlanStatus::Removed
493        ) && module == "ignition"
494            && matches!(ty, "identity-provider" | "security-zone");
495        entries.push(PlanEntry {
496            key: key.clone(),
497            module: module.to_string(),
498            ty: ty.to_string(),
499            name: (parts.len() >= 3).then(|| parts[2..].join("/")),
500            status,
501            live_in,
502            core: core_relation,
503            destructive,
504            destructive_reason: destructive
505                .then(|| destructive_reason(module, ty, status).to_string()),
506            lockout_risk,
507            drift: None,
508            files,
509            diff: None,
510        });
511    }
512
513    let mut summary = PlanSummary::default();
514    for entry in &entries {
515        match entry.status {
516            PlanStatus::Added => summary.added += 1,
517            PlanStatus::Changed => summary.changed += 1,
518            PlanStatus::Removed => summary.removed += 1,
519            PlanStatus::Same => summary.same += 1,
520            PlanStatus::Unmanaged => summary.unmanaged += 1,
521        }
522    }
523    summary.has_changes = summary.added + summary.changed + summary.removed > 0;
524
525    let mut warnings: Vec<String> = observed
526        .local_units
527        .iter()
528        .map(|unit| {
529            format!(
530                "{unit} also exists in the `local` collection, which the gateway loads over the mode collection; the plan does not describe what the gateway runs for it"
531            )
532        })
533        .collect();
534    warnings.extend(ledger_warnings);
535
536    let plan_sha256 = sha256_hex(&canonical_text(
537        &rendered.render_sha256(),
538        &entries,
539        &delete,
540    ));
541    Plan {
542        entries,
543        summary,
544        delete_set: DeleteSet { files: delete },
545        plan_sha256,
546        warnings,
547    }
548}
549
550/// A previous render (a `deploy render --out` directory), re-hashed from its
551/// real bytes (`--base`, 22-04).
552///
553/// `render.json` is read for identity (`gateway`, `env`, `mode`) and as a
554/// consistency check only: its file list, every per-file sha256 and its
555/// `render_sha256` must agree with what [`BaseTree::read`] recomputes from the
556/// directory, so a crash-unsafe refresh (21-SECURITY R-03 / WR-03), a leftover
557/// `.ign-render-tmp` file, or any hand edit is refused instead of being
558/// trusted. File bytes are hashed while streaming and never kept.
559#[derive(Debug, Clone, PartialEq, Eq)]
560pub struct BaseTree {
561    /// The directory as given.
562    pub dir: String,
563    /// The recomputed `render_sha256` (equals the marker's).
564    pub render_sha256: String,
565    /// Recomputed sha256 per collection path (`render.json` excluded).
566    pub files: BTreeMap<RelPath, String>,
567}
568
569/// `--base <dir>: <problem>[ (first difference: <path>)]`.
570fn base_refusal(dir: &Path, problem: impl std::fmt::Display, first: Option<&str>) -> CoreError {
571    let at = first.map_or_else(String::new, |p| format!(" (first difference: {p})"));
572    CoreError::InvalidInput {
573        reason: format!("--base {}: {problem}{at}", dir.display()),
574    }
575}
576
577/// The first path (byte order) held by exactly one of the two sets.
578fn first_difference<'a>(a: &'a BTreeSet<String>, b: &'a BTreeSet<String>) -> Option<&'a String> {
579    a.symmetric_difference(b)
580        .min_by(|x, y| x.as_bytes().cmp(y.as_bytes()))
581}
582
583impl BaseTree {
584    /// Read and verify `dir` as a clean render of `gateway`, `env`, `mode`.
585    ///
586    /// `dir` must be a real directory (not a symlink). It is walked with
587    /// `symlink_metadata`, capped at [`READ_MAX_ENTRIES`]; a symlink, special
588    /// file or non-UTF-8 name anywhere under it is refused. `render.json`
589    /// must parse as a schema 1 [`RenderMarker`] of the same gateway,
590    /// environment and mode; the walked files must be exactly the marker's
591    /// files; every recomputed sha256, size and the recomputed listing digest
592    /// must equal the marker's. Every refusal is `invalid_input` naming the
593    /// directory, the problem and the first differing path; none echoes file
594    /// content.
595    pub fn read(dir: &Path, gateway: &str, env: &str, mode: &str) -> Result<BaseTree, CoreError> {
596        let meta = match std::fs::symlink_metadata(dir) {
597            Ok(meta) => meta,
598            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
599                return Err(base_refusal(dir, "does not exist", None));
600            }
601            Err(e) => {
602                return Err(base_refusal(
603                    dir,
604                    format!("cannot inspect it ({})", e.kind()),
605                    None,
606                ));
607            }
608        };
609        if meta.file_type().is_symlink() {
610            return Err(base_refusal(
611                dir,
612                "is a symlink; symlinks are refused",
613                None,
614            ));
615        }
616        if !meta.file_type().is_dir() {
617            return Err(base_refusal(dir, "is not a directory", None));
618        }
619
620        let mut walked: BTreeMap<String, std::path::PathBuf> = BTreeMap::new();
621        let mut dirs: BTreeSet<String> = BTreeSet::new();
622        walk_capped(dir, "", &mut walked, &mut dirs, READ_MAX_ENTRIES)
623            .map_err(|why| base_refusal(dir, why, None))?;
624
625        let Some(marker_path) = walked.remove(RENDER_MARKER) else {
626            return Err(base_refusal(
627                dir,
628                format!("holds no {RENDER_MARKER}; it is not a `deploy render --out` directory"),
629                Some(RENDER_MARKER),
630            ));
631        };
632        let marker = read_marker(dir, &marker_path)?;
633        if marker.schema_version != 1 {
634            return Err(base_refusal(
635                dir,
636                format!(
637                    "{RENDER_MARKER} has schema_version {}, expected 1",
638                    marker.schema_version
639                ),
640                None,
641            ));
642        }
643        for (what, found, expected) in [
644            ("gateway", &marker.gateway, gateway),
645            ("env", &marker.env, env),
646            ("mode", &marker.mode, mode),
647        ] {
648            if found != expected {
649                return Err(base_refusal(
650                    dir,
651                    format!("{RENDER_MARKER} {what} is {found}, this plan's is {expected}"),
652                    None,
653                ));
654            }
655        }
656
657        let mut listed: BTreeMap<&str, &MarkerFile> = BTreeMap::new();
658        for entry in &marker.files {
659            if listed.insert(entry.path.as_str(), entry).is_some() {
660                return Err(base_refusal(
661                    dir,
662                    format!("{RENDER_MARKER} lists a path twice"),
663                    Some(&entry.path),
664                ));
665            }
666        }
667        let found: BTreeSet<String> = walked.keys().cloned().collect();
668        let named: BTreeSet<String> = listed.keys().map(|p| (*p).to_string()).collect();
669        if let Some(path) = first_difference(&found, &named) {
670            let problem = if path.ends_with(TMP_SUFFIX) {
671                "holds a leftover temporary file from an interrupted render".to_string()
672            } else if found.contains(path) {
673                format!("holds a file not listed in {RENDER_MARKER}")
674            } else {
675                format!("is missing a file listed in {RENDER_MARKER}")
676            };
677            return Err(base_refusal(dir, problem, Some(path)));
678        }
679        for d in &dirs {
680            let prefix = format!("{d}/");
681            if !named.iter().any(|p| p.starts_with(&prefix)) {
682                return Err(base_refusal(
683                    dir,
684                    "holds a directory with no rendered file",
685                    Some(d),
686                ));
687            }
688        }
689
690        let mut files: BTreeMap<RelPath, String> = BTreeMap::new();
691        let mut total: u64 = 0;
692        for (rel, path) in &walked {
693            let entry = listed[rel.as_str()];
694            let key = RelPath::new(rel).map_err(|why| {
695                base_refusal(
696                    dir,
697                    format!("{RENDER_MARKER} lists an unusable path ({why})"),
698                    Some(rel),
699                )
700            })?;
701            let (sha256, len) = hash_base_file(dir, rel, path, &mut total)?;
702            if sha256 != entry.sha256 || len != entry.bytes {
703                return Err(base_refusal(
704                    dir,
705                    format!("a file differs from the sha256 recorded in {RENDER_MARKER}"),
706                    Some(rel),
707                ));
708            }
709            files.insert(key, sha256);
710        }
711
712        let render_sha256 = listing_digest(&files);
713        if render_sha256 != marker.render_sha256 {
714            return Err(base_refusal(
715                dir,
716                format!("the files do not match the render_sha256 recorded in {RENDER_MARKER}"),
717                None,
718            ));
719        }
720        Ok(BaseTree {
721            dir: dir.to_string_lossy().into_owned(),
722            render_sha256,
723            files,
724        })
725    }
726}
727
728/// `render.json`, read through the descriptor-relative helper and parsed
729/// without echoing anything the file says.
730fn read_marker(dir: &Path, path: &Path) -> Result<RenderMarker, CoreError> {
731    let before = std::fs::symlink_metadata(path).map_err(|e| {
732        base_refusal(
733            dir,
734            format!("cannot inspect {RENDER_MARKER} ({})", e.kind()),
735            None,
736        )
737    })?;
738    let (file, held) = open_base_file(dir, RENDER_MARKER, &before)?;
739    if held.len() > READ_MAX_FILE_BYTES {
740        return Err(base_refusal(
741            dir,
742            format!("{RENDER_MARKER} is over the {READ_MAX_FILE_BYTES} byte cap"),
743            None,
744        ));
745    }
746    let mut bytes = Vec::new();
747    file.take(READ_MAX_FILE_BYTES + 1)
748        .read_to_end(&mut bytes)
749        .map_err(|e| {
750            base_refusal(
751                dir,
752                format!("cannot read {RENDER_MARKER} ({})", e.kind()),
753                None,
754            )
755        })?;
756    if bytes.len() as u64 > READ_MAX_FILE_BYTES {
757        return Err(base_refusal(
758            dir,
759            format!("{RENDER_MARKER} is over the {READ_MAX_FILE_BYTES} byte cap"),
760            None,
761        ));
762    }
763    serde_json::from_slice(&bytes).map_err(|_| {
764        base_refusal(
765            dir,
766            format!("{RENDER_MARKER} is not a valid render marker"),
767            None,
768        )
769    })
770}
771
772fn open_base_file(
773    dir: &Path,
774    rel: &str,
775    before: &std::fs::Metadata,
776) -> Result<(std::fs::File, std::fs::Metadata), CoreError> {
777    open_regular_file(dir, Path::new(rel), before).map_err(|e| match e {
778        OpenError::Changed => base_refusal(dir, "changed while it was read", Some(rel)),
779        OpenError::Io(e) => {
780            base_refusal(dir, format!("cannot read a file ({})", e.kind()), Some(rel))
781        }
782    })
783}
784
785/// Stream one base file through SHA-256 (never keeping its bytes), enforcing
786/// the per-file and total byte caps. Returns the digest and the byte count.
787fn hash_base_file(
788    dir: &Path,
789    rel: &str,
790    path: &Path,
791    total: &mut u64,
792) -> Result<(String, u64), CoreError> {
793    let before = std::fs::symlink_metadata(path).map_err(|e| {
794        base_refusal(
795            dir,
796            format!("cannot inspect a file ({})", e.kind()),
797            Some(rel),
798        )
799    })?;
800    if !before.file_type().is_file() {
801        return Err(base_refusal(dir, "is no longer a regular file", Some(rel)));
802    }
803    let (file, held) = open_base_file(dir, rel, &before)?;
804    if held.len() > READ_MAX_FILE_BYTES {
805        return Err(base_refusal(
806            dir,
807            format!("a file is over the {READ_MAX_FILE_BYTES} byte cap"),
808            Some(rel),
809        ));
810    }
811    *total += held.len();
812    if *total > READ_MAX_TOTAL_BYTES {
813        return Err(base_refusal(
814            dir,
815            format!("the files exceed {READ_MAX_TOTAL_BYTES} bytes in total"),
816            Some(rel),
817        ));
818    }
819    let mut hasher = Sha256::new();
820    let mut buf = [0u8; 64 * 1024];
821    let mut len: u64 = 0;
822    let mut reader = file.take(READ_MAX_FILE_BYTES + 1);
823    loop {
824        let n = reader.read(&mut buf).map_err(|e| {
825            base_refusal(dir, format!("cannot read a file ({})", e.kind()), Some(rel))
826        })?;
827        if n == 0 {
828            break;
829        }
830        len += n as u64;
831        if len > READ_MAX_FILE_BYTES {
832            return Err(base_refusal(
833                dir,
834                format!("a file grew past the {READ_MAX_FILE_BYTES} byte cap while it was read"),
835                Some(rel),
836            ));
837        }
838        hasher.update(&buf[..n]);
839    }
840    Ok((format!("{:x}", hasher.finalize()), len))
841}
842
843/// The `render_sha256` listing digest over `path NUL sha256 LF` lines, the
844/// same text [`RenderedTree::render_sha256`] hashes.
845fn listing_digest(files: &BTreeMap<RelPath, String>) -> String {
846    let mut hasher = Sha256::new();
847    for (path, sha256) in files {
848        hasher.update(path.as_str().as_bytes());
849        hasher.update([0u8]);
850        hasher.update(sha256.as_bytes());
851        hasher.update(b"\n");
852    }
853    format!("{:x}", hasher.finalize())
854}
855
856/// Mark each entry's `drift` against `base` and count them.
857///
858/// An entry drifted when any of its files has a live sha256 that differs from
859/// both the base sha256 and the desired sha256; an absent side counts as its
860/// own value (`None`). This includes `unmanaged` entries (a unit created on
861/// the gateway outside the pipeline) and an `added` entry whose live copy was
862/// deleted outside the pipeline. The flag is not part of `plan_sha256`.
863pub fn apply_base(plan: &mut Plan, base: &BaseTree) {
864    let mut drifted = 0;
865    for entry in &mut plan.entries {
866        let drift = entry.files.iter().any(|file| {
867            let base_sha = RelPath::new(&file.path)
868                .ok()
869                .and_then(|path| base.files.get(&path));
870            file.live_sha256.as_ref() != base_sha && file.live_sha256 != file.desired_sha256
871        });
872        entry.drift = Some(drift);
873        drifted += usize::from(drift);
874    }
875    plan.summary.drifted = Some(drifted);
876}
877
878/// The longest diff text one entry carries; longer diffs end with a
879/// truncation marker.
880pub const DIFF_MAX_BYTES: usize = 65_536;
881
882/// Opening words of the line printed in place of a secret-bearing member.
883pub const DIFF_WITHHELD: &str = "withheld: contains secret-bearing locations";
884
885/// Fill `diff` on `added`, `changed` and `removed` entries (`--diff`).
886///
887/// Each file of the entry whose live and desired sha256 differ contributes one
888/// section, in path order:
889///
890/// - a `.json` member whose present sides both parse ([`classify`]) and carry
891///   no secret-bearing location on either side is a unified text diff
892///   (`similar`, 3 lines of context, headers `live/<path>` and
893///   `desired/<path>`; an absent side is empty);
894/// - a `.json` member where [`scan_secrets`] (`config.json`) or
895///   [`scan_embedded`] (every other `.json`, and tag files) reports any
896///   refused location or warning on either side, or where the diff-only key
897///   check ([`sensitive_key_hits`]) finds a non-empty string or non-null
898///   scalar at or beneath an object key named like `password`, `passwd`,
899///   `secret`, `token`, `apikey`, `api_key`, `credential`, `privatekey` or
900///   `private_key` (ASCII case-insensitive substring, any file, with or
901///   without an OpenAPI description) on either side, is
902///   `<path>: withheld: contains secret-bearing locations (<n>); live <sha|->,
903///   desired <sha|->`, where `<n>` counts locations only;
904/// - any other member, and a `.json` member that does not parse, is
905///   `<path>: differs (live <sha|->, desired <sha|->)`.
906///
907/// The text diff is the only place file bytes reach the plan, and only after
908/// both sides passed the scan and the key check, so Embedded ciphertext and
909/// plaintext secrets never leave it. The key check exists because the render
910/// lints cover the desired (repo) side: the live side is gateway-written and
911/// never linted, so `--diff` is deliberately stricter than render. It does
912/// not touch the render lints or what render accepts. An entry's text is capped at [`DIFF_MAX_BYTES`] and then
913/// ends with a truncation marker. The live side of an entry is its effective
914/// copy (the mode collection when the unit exists there, else `core`), the
915/// same basis the digests use.
916pub fn render_diffs(plan: &mut Plan, rendered: &RenderedTree, observed: &ObservedFiles) {
917    for entry in &mut plan.entries {
918        if !matches!(
919            entry.status,
920            PlanStatus::Added | PlanStatus::Changed | PlanStatus::Removed
921        ) {
922            continue;
923        }
924        let layer = if entry.live_in.iter().any(|l| l == "mode") {
925            &observed.mode
926        } else {
927            &observed.core
928        };
929        let mut text = String::new();
930        for file in &entry.files {
931            if file.live_sha256 == file.desired_sha256 {
932                continue;
933            }
934            let Ok(path) = RelPath::new(&file.path) else {
935                continue;
936            };
937            let live = layer.get(&path).map(|blob| blob.bytes.as_slice());
938            let desired = rendered.files.get(&path).map(Vec::as_slice);
939            text.push_str(&file_diff(
940                &path,
941                live,
942                desired,
943                file.live_sha256.as_deref(),
944                file.desired_sha256.as_deref(),
945            ));
946        }
947        entry.diff = (!text.is_empty()).then(|| cap_diff(text));
948    }
949}
950
951/// One file's section of an entry's diff, newline-terminated.
952fn file_diff(
953    path: &RelPath,
954    live: Option<&[u8]>,
955    desired: Option<&[u8]>,
956    live_sha: Option<&str>,
957    desired_sha: Option<&str>,
958) -> String {
959    let shas = format!(
960        "live {}, desired {}",
961        live_sha.unwrap_or("-"),
962        desired_sha.unwrap_or("-")
963    );
964    let hash_only = || format!("{path}: differs ({shas})\n");
965    if !path.as_str().ends_with(".json") {
966        return hash_only();
967    }
968    let mut locations = 0;
969    for bytes in [live, desired].into_iter().flatten() {
970        let Ok((_, node)) = classify(bytes) else {
971            return hash_only();
972        };
973        locations += secret_locations(path, &node) + sensitive_key_hits(&node);
974    }
975    if locations > 0 {
976        return format!("{path}: {DIFF_WITHHELD} ({locations}); {shas}\n");
977    }
978    // Both sides parsed as JSON, so they are valid UTF-8.
979    let old = live.and_then(|b| std::str::from_utf8(b).ok()).unwrap_or("");
980    let new = desired
981        .and_then(|b| std::str::from_utf8(b).ok())
982        .unwrap_or("");
983    let diff = TextDiff::from_lines(old, new);
984    let mut unified = diff.unified_diff();
985    unified
986        .context_radius(3)
987        .header(&format!("live/{path}"), &format!("desired/{path}"));
988    let mut text = unified.to_string();
989    if !text.ends_with('\n') {
990        text.push('\n');
991    }
992    text
993}
994
995/// How many secret-bearing locations (refused or warned) the render lints
996/// find in one parsed member. Counts only; nothing the scan says is kept.
997fn secret_locations(path: &RelPath, node: &Node) -> usize {
998    let origin = path.as_str();
999    let parts: Vec<&str> = path.components().collect();
1000    let tag_file =
1001        parts.first() == Some(&"ignition") && parts.get(1).is_some_and(|ty| TAG_TYPES.contains(ty));
1002    let scan = if tag_file {
1003        scan_embedded(origin, node)
1004    } else {
1005        let key = resource_key(path);
1006        let skip = if key.name.is_some() { 3 } else { 2 };
1007        let rest: Vec<&str> = parts.iter().skip(skip).copied().collect();
1008        scan_secrets(origin, &key, &rest.join("/"), node)
1009    };
1010    scan.refused.len() + scan.warnings.len()
1011}
1012
1013/// Lowercase key-name fragments that mark a value as a secret for `--diff`.
1014const SENSITIVE_KEY_FRAGMENTS: [&str; 9] = [
1015    "password",
1016    "passwd",
1017    "secret",
1018    "token",
1019    "apikey",
1020    "api_key",
1021    "credential",
1022    "privatekey",
1023    "private_key",
1024];
1025
1026/// Diff-only secret check, stricter than the render lints. The render lints
1027/// were designed for the desired (repo) side; the live side is gateway-written
1028/// and never linted, so a plaintext `"password": "x"` in a live non-config
1029/// `.json` or tag file would otherwise print verbatim in a text diff.
1030///
1031/// Walks every object in a parsed member and counts each non-empty string or
1032/// non-null scalar at or beneath an object key whose ASCII-lowercased name
1033/// contains one of [`SENSITIVE_KEY_FRAGMENTS`]. It applies whatever the file
1034/// name or the OpenAPI description says. Counts only: neither the value nor
1035/// the key path is kept.
1036fn sensitive_key_hits(node: &Node) -> usize {
1037    sensitive_walk(node, false)
1038}
1039
1040fn sensitive_walk(node: &Node, under_secret_key: bool) -> usize {
1041    match node {
1042        Node::Obj(members) => members
1043            .iter()
1044            .map(|(key, value)| {
1045                let lowered = key.to_ascii_lowercase();
1046                let secret = under_secret_key
1047                    || SENSITIVE_KEY_FRAGMENTS
1048                        .iter()
1049                        .any(|fragment| lowered.contains(fragment));
1050                sensitive_walk(value, secret)
1051            })
1052            .sum(),
1053        Node::Arr(items) => items
1054            .iter()
1055            .map(|item| sensitive_walk(item, under_secret_key))
1056            .sum(),
1057        Node::Str(text) => usize::from(under_secret_key && !text.is_empty()),
1058        Node::Lit(lexeme) => usize::from(under_secret_key && lexeme != "null"),
1059    }
1060}
1061
1062/// Cap an entry's diff at [`DIFF_MAX_BYTES`], ending a longer one with the
1063/// truncation marker; otherwise drop the trailing newline.
1064fn cap_diff(mut text: String) -> String {
1065    if text.len() > DIFF_MAX_BYTES {
1066        let mut cut = DIFF_MAX_BYTES;
1067        while !text.is_char_boundary(cut) {
1068            cut -= 1;
1069        }
1070        text.truncate(cut);
1071        if !text.ends_with('\n') {
1072            text.push('\n');
1073        }
1074        text.push_str(&format!("... [diff truncated at {DIFF_MAX_BYTES} bytes]"));
1075    } else {
1076        while text.ends_with('\n') {
1077            text.pop();
1078        }
1079    }
1080    text
1081}
1082
1083/// The canonical text `plan_sha256` hashes (see the module doc).
1084fn canonical_text(render_sha256: &str, entries: &[PlanEntry], delete_set: &[RelPath]) -> Vec<u8> {
1085    let mut text: Vec<u8> = Vec::new();
1086    text.extend_from_slice(PLAN_HASH_HEADER.as_bytes());
1087    text.push(b'\n');
1088    text.extend_from_slice(b"render_sha256\0");
1089    text.extend_from_slice(render_sha256.as_bytes());
1090    text.push(b'\n');
1091    for entry in entries.iter().filter(|e| e.status != PlanStatus::Unmanaged) {
1092        text.extend_from_slice(b"E\0");
1093        text.extend_from_slice(entry.key.as_bytes());
1094        text.push(0);
1095        text.extend_from_slice(entry.status.word().as_bytes());
1096        text.push(0);
1097        text.extend_from_slice(entry.core.word().as_bytes());
1098        text.push(0);
1099        text.extend_from_slice(entry.live_in.join(",").as_bytes());
1100        text.push(b'\n');
1101        for file in &entry.files {
1102            text.extend_from_slice(b"F\0");
1103            text.extend_from_slice(file.path.as_bytes());
1104            text.push(0);
1105            text.extend_from_slice(file.live_sha256.as_deref().unwrap_or("-").as_bytes());
1106            text.push(0);
1107            text.extend_from_slice(file.desired_sha256.as_deref().unwrap_or("-").as_bytes());
1108            text.push(b'\n');
1109        }
1110    }
1111    for path in delete_set {
1112        text.extend_from_slice(b"D\0");
1113        text.extend_from_slice(path.as_str().as_bytes());
1114        text.push(b'\n');
1115    }
1116    text
1117}
1118
1119#[cfg(test)]
1120mod tests {
1121    use super::*;
1122
1123    fn rp(s: &str) -> RelPath {
1124        RelPath::new(s).unwrap()
1125    }
1126
1127    fn layer(paths: &[&str]) -> BTreeMap<RelPath, ()> {
1128        paths.iter().map(|p| (rp(p), ())).collect()
1129    }
1130
1131    #[test]
1132    fn resource_files_group_under_their_resource_directory() {
1133        let l = layer(&[
1134            "ignition/database-translator/POSTGRES/config.json",
1135            "ignition/database-translator/POSTGRES/resource.json",
1136        ]);
1137        let key = unit_key(&rp("ignition/database-translator/POSTGRES/config.json"), &l);
1138        assert_eq!(key, "ignition/database-translator/POSTGRES");
1139    }
1140
1141    #[test]
1142    fn singletons_group_per_type() {
1143        let l = layer(&[
1144            "ignition/general-alarm-settings/config.json",
1145            "ignition/general-alarm-settings/resource.json",
1146        ]);
1147        let key = unit_key(&rp("ignition/general-alarm-settings/config.json"), &l);
1148        assert_eq!(key, "ignition/general-alarm-settings");
1149    }
1150
1151    #[test]
1152    fn nearest_marker_wins_for_tag_folders() {
1153        let l = layer(&[
1154            "ignition/tag-definition/P/unary-resource.json",
1155            "ignition/tag-definition/P/A/unary-resource.json",
1156            "ignition/tag-definition/P/A/tags.json",
1157            "ignition/tag-definition/P/B/tags.json",
1158        ]);
1159        assert_eq!(
1160            unit_key(&rp("ignition/tag-definition/P/A/tags.json"), &l),
1161            "ignition/tag-definition/P/A"
1162        );
1163        // no marker in B itself: the nearest ancestor with one is the provider
1164        assert_eq!(
1165            unit_key(&rp("ignition/tag-definition/P/B/tags.json"), &l),
1166            "ignition/tag-definition/P"
1167        );
1168    }
1169
1170    #[test]
1171    fn no_marker_falls_back_to_the_parent() {
1172        let l = layer(&["ignition/database-translator/X/config.json"]);
1173        assert_eq!(
1174            unit_key(&rp("ignition/database-translator/X/config.json"), &l),
1175            "ignition/database-translator/X"
1176        );
1177    }
1178
1179    #[test]
1180    fn empty_inputs_plan_empty() {
1181        let scope = Scope {
1182            mode: "dev".to_string(),
1183            types: Vec::new(),
1184            tag_providers: Vec::new(),
1185        };
1186        let p = plan(
1187            &RenderedTree::default(),
1188            &ObservedFiles::default(),
1189            None,
1190            &scope,
1191        );
1192        assert!(p.entries.is_empty());
1193        assert!(!p.summary.has_changes);
1194    }
1195}