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