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, or rendered and so about to be, whose
48//! core copy holds files the rendered unit lacks: the gateway overrides these
49//! by folder, so those core files are invisible; a first deploy over `core`
50//! is `hidden` like a later one, 261030-8m8 CR-02), or `shadows_removal` (a
51//! `removed` unit that also has a
52//! `core` copy: deleting from the mode collection will not take effect, the
53//! gateway keeps running the `core` copy).
54//!
55//! Comparison is byte-exact per file: a whitespace-only or key-order change
56//! is `changed`.
57//!
58//! ## Redundant pairs (quick 261102-cef, A2)
59//!
60//! On a target with `nodes.backup` ([`plan_with`] and
61//! [`Redundancy::Pair`]), a `tag-definition` or `tag-type-definition` unit is
62//! compared with its `core` copy, because the apply writes it into `core`:
63//! Ignition 8.3.6 never syncs those types from a mode collection (debug
64//! deploy-tag-redundancy-sync). Its rows name the `core` files as
65//! `.core/<collection path>` and its mode copy, if any, as bare rows; a
66//! ledgered mode copy is in the delete set (the migration), and an
67//! unledgered one is the residue [`pair_tag_keys`] refuses. Under `--base` a
68//! migrated unit is judged by its mode copy ([`apply_base`], quick
69//! 261102-kus). Everything else, and every standalone plan, is as described
70//! above.
71//!
72//! ## The delete set and `removed` (D-05, 22-03)
73//!
74//! The delete set is exactly the declared-names ledger's files minus the
75//! rendered files, restricted to files that exist in the mode collection
76//! (for a `.core/` ledger entry, in `core`; see [`plan_with`]). No
77//! ledger (a first deploy) gives an empty delete set; a ledger that exists
78//! but is unusable is refused before this function runs (the caller parses
79//! it with [`Ledger::parse`]). A ledger path is never in the delete set when
80//! it is:
81//!
82//! - rendered (byte-equal path), or equal to a rendered path under ASCII
83//!   case folding (a case-insensitive host would delete the rendered file;
84//!   this one is reported in `warnings`),
85//! - under an excluded tag provider ([`super::exclude`]): one warning per
86//!   provider, with the file count, even for a ledger written before the
87//!   provider was excluded and even when the scope names the provider,
88//! - under a type or tag provider the manifest no longer allows (reported in
89//!   `warnings`, never deleted),
90//! - of an unreadable unit (marks the unit `in_ledger`; never read as
91//!   absent), or
92//! - `config-mode.json` or under a dot-named component.
93//!
94//! Paths compare as exact UTF-8 bytes. A rendered unit that loses only some
95//! of its ledger files is `changed` and deletes just those files; a unit that
96//! is not rendered and has delete-set files is `removed`; `unmanaged` is a
97//! live unit with no delete-set file. Plan never writes or rewrites the
98//! ledger.
99//!
100//! ## Flags (22-03)
101//!
102//! Derived only from status, delete-set membership and type, so the same
103//! input gives the same flags. `destructive` is true for `removed` entries,
104//! for any entry with delete-set files, and for an `added` or `changed`
105//! tag-definition or tag-type-definition unit that drops tag data the gateway
106//! runs now ([`tag_data_dropped`], 261030-8m8 CR-01): its effective live copy
107//! (the mode copy when one exists, else `core`) holds a `tags.json` or
108//! `udts.json` (any letter case) the rendered unit lacks. No file is deleted
109//! there, but the gateway loads only the member files a marker lists (CD5c)
110//! and a mode-collection folder hides its `core` copy whole (CD5a S4,
111//! CD5-CTL), so those tags or UDT types leave the running gateway. On a first
112//! deploy the effective copy is `core`, so newly hidden core members count; a
113//! `hidden` relation alone does not, because core members a mode copy already
114//! hides are not lost again. Each such entry also raises one plan warning
115//! naming the unit and the dropped paths (never content). A `same` unit is
116//! never flagged this way: the apply does not touch it. `destructive_reason`
117//! is then
118//! `tag_data_loss` (`ignition/tag-provider`, `tag-group`,
119//! `tag-type-definition`, `tag-definition`), `connection_removed`
120//! (`ignition/database-connection`, `opc-connection`, `identity-provider`),
121//! `storage_removed` (`ignition/store-and-forward-engine`, `alarm-journal`),
122//! else `resource_removed` for `removed` and `files_removed` for `changed`.
123//! `lockout_risk` is true for every entry that is not `same` and not
124//! `unmanaged` (one the plan would touch) of `ignition/identity-provider` or
125//! `ignition/security-zone`.
126//!
127//! ## `plan_sha256` (D-08)
128//!
129//! The lowercase hex SHA-256 of this UTF-8 text, byte for byte. `LF` is
130//! `0x0A`, `NUL` is `0x00`:
131//!
132//! ```text
133//! ign-deploy:plan:v1 LF
134//! render_sha256 NUL <render_sha256 hex> LF
135//! E NUL <key> NUL <status> NUL <core> NUL <live_in joined by ","> LF
136//! F NUL <collection path> NUL <live sha256 or -> NUL <desired sha256 or -> LF
137//! D NUL <collection path> LF
138//! U NUL <key> NUL <live_in joined by ","> NUL <declared 0|1> NUL <in_ledger 0|1> LF
139//! ```
140//!
141//! One `E` line per entry that is not `unmanaged`, in unit-key byte order,
142//! each followed by its `F` lines in path byte order (a `.core/` row carries
143//! its `.core/` path, so only a pair plan with tag units, or a ledger with
144//! `.core/` entries, ever shows one; every other hash is unchanged); then one `D` line per
145//! delete-set path in byte order (none without a ledger); then one `U` line
146//! per unreadable unit in key byte order. `U` lines appear only when a unit
147//! is unreadable, so the header stays `v1` and every earlier hash is
148//! unchanged. `status` and `core` are
149//! the serialized words (`added`, `identical`, ...). Drift, diff text and
150//! warnings are excluded on purpose, as are `unmanaged` entries, so a review
151//! hash is reproducible with or without `--base` and unrelated live
152//! resources never invalidate it. Changing this text means re-goldening
153//! (`PLAN_GOLDEN` in `tests/deploy_plan.rs`) and Phase 23's recomputation.
154
155use std::collections::{BTreeMap, BTreeSet};
156use std::io::Read;
157use std::path::Path;
158
159use serde::Serialize;
160use sha2::{Digest, Sha256};
161
162use similar::TextDiff;
163
164use super::exclude;
165use super::layout::{
166    KeyCollection, PAIR_UNSYNCED_TAG_TYPES, RelPath, TAG_TYPES, core_key,
167    is_pair_unsynced_tag_path, resource_key, route, split_key,
168};
169use super::ledger::Ledger;
170use super::lint::{scan_embedded, scan_secrets};
171use super::node::{Node, classify};
172use super::render::member_named_tag_folder;
173use super::rules::GATEWAY_OWNED_FILES;
174use super::target::Redundancy;
175use super::transport::{
176    DeleteSet, FileBlob, ObservedFiles, READ_MAX_ENTRIES, READ_MAX_FILE_BYTES,
177    READ_MAX_TOTAL_BYTES, Scope, TreeListing,
178};
179use super::tree::{
180    MarkerFile, OpenError, RENDER_MARKER, RenderMarker, RenderedTree, TMP_SUFFIX,
181    open_regular_file, sha256_hex, walk_capped,
182};
183use crate::error::{CoreError, DeployFinding};
184
185/// First line of the canonical `plan_sha256` text.
186pub const PLAN_HASH_HEADER: &str = "ign-deploy:plan:v1";
187
188/// The plan status of one unit.
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
190#[serde(rename_all = "lowercase")]
191pub enum PlanStatus {
192    /// Rendered, no effective live copy.
193    Added,
194    /// Rendered, effective copy differs.
195    Changed,
196    /// Not rendered, with delete-set files in the mode collection.
197    Removed,
198    /// Rendered, effective copy byte-equal.
199    Same,
200    /// Live, allowlisted, not rendered; never deleted (D-06).
201    Unmanaged,
202}
203
204impl PlanStatus {
205    /// The status word as serialized and printed.
206    pub fn word(self) -> &'static str {
207        match self {
208            PlanStatus::Added => "added",
209            PlanStatus::Changed => "changed",
210            PlanStatus::Removed => "removed",
211            PlanStatus::Same => "same",
212            PlanStatus::Unmanaged => "unmanaged",
213        }
214    }
215}
216
217/// How the `core` copy of a unit relates to the rendered unit.
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
219#[serde(rename_all = "snake_case")]
220pub enum CoreRelation {
221    /// The unit has no `core` copy.
222    None,
223    /// The `core` copy has the same files, byte-equal to the render.
224    Identical,
225    /// The `core` copy exists and differs.
226    Differs,
227    /// A tag folder defined (or, when rendered, about to be defined) in the
228    /// mode collection hides `core`-only files.
229    Hidden,
230    /// Status `removed` while a `core` copy exists: deleting from the mode
231    /// collection will not take effect.
232    ShadowsRemoval,
233}
234
235impl CoreRelation {
236    /// The relation word as serialized and printed.
237    pub fn word(self) -> &'static str {
238        match self {
239            CoreRelation::None => "none",
240            CoreRelation::Identical => "identical",
241            CoreRelation::Differs => "differs",
242            CoreRelation::Hidden => "hidden",
243            CoreRelation::ShadowsRemoval => "shadows_removal",
244        }
245    }
246}
247
248/// One file of a unit: its live and rendered digests.
249#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
250pub struct PlanFile {
251    /// Collection-relative path. A
252    /// [`CORE_KEY_PREFIX`](super::layout::CORE_KEY_PREFIX) prefix
253    /// (`.core/<path>`) names the `core` collection: a routed tag unit's rows
254    /// on a redundant pair (quick 261102-cef, A2), or a `core` file the
255    /// delete set removes.
256    pub path: String,
257    /// SHA-256 of the live copy the row names: the effective copy for a bare
258    /// row of an unrouted unit, the `core` copy for a `.core/` row, the mode
259    /// copy for a bare row of a routed unit; null when the file is not live.
260    pub live_sha256: Option<String>,
261    /// SHA-256 of the rendered file; null when the file is not rendered.
262    pub desired_sha256: Option<String>,
263}
264
265/// One unit of the plan.
266#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
267pub struct PlanEntry {
268    /// The unit key (the unit directory, collection-relative).
269    pub key: String,
270    /// Module id, e.g. `ignition`.
271    pub module: String,
272    /// Resource type, e.g. `database-translator`.
273    #[serde(rename = "type")]
274    pub ty: String,
275    /// Resource name (or tag provider and folder path); null for a singleton.
276    pub name: Option<String>,
277    /// The unit's status.
278    pub status: PlanStatus,
279    /// Where live copies sit: the ordered subset of `["core", "mode"]`.
280    pub live_in: Vec<String>,
281    /// The `core` copy's relation to the rendered unit.
282    pub core: CoreRelation,
283    /// True for `removed` entries, entries with delete-set files, and
284    /// `added` or `changed` tag units that drop live tag data (module doc).
285    pub destructive: bool,
286    /// Why the entry is destructive (module doc); null when it is not.
287    pub destructive_reason: Option<String>,
288    /// True when a unit of a security type that the plan touches could lock
289    /// the operator out of the gateway.
290    pub lockout_risk: bool,
291    /// Three-way drift against a `--base` render: true when any file's live
292    /// sha256 differs from both the base and the desired sha256. Null
293    /// without `--base`. Excluded from `plan_sha256`.
294    pub drift: Option<bool>,
295    /// The unit's files, byte-sorted.
296    pub files: Vec<PlanFile>,
297    /// Redacted unified text diff of the unit's differing files (`--diff`);
298    /// null without it, and for `same` and `unmanaged` entries. Excluded from
299    /// `plan_sha256`.
300    pub diff: Option<String>,
301}
302
303/// Counts by status.
304#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
305pub struct PlanSummary {
306    /// Units that would be added.
307    pub added: usize,
308    /// Units that would change.
309    pub changed: usize,
310    /// Units that would be removed.
311    pub removed: usize,
312    /// Units already equal.
313    pub same: usize,
314    /// Live units the declared tree does not manage.
315    pub unmanaged: usize,
316    /// Units the host holds but could not serve whole
317    /// ([`Plan::unreadable`]); see `has_changes`.
318    pub unreadable: usize,
319    /// True when anything would be added, changed or removed, or when an
320    /// unreadable unit is declared or listed in the ledger (the deploy must
321    /// act on a unit it cannot see). `unmanaged` never counts (D-06), and
322    /// neither does an unreadable unit that is neither declared nor in the
323    /// ledger: like `unmanaged`, it is left alone, so a pull followed by
324    /// render and plan still reports no change. Its `U` line still keeps
325    /// `plan_sha256` distinct from a plan without it.
326    pub has_changes: bool,
327    /// Entries with `drift: true`; null without `--base`.
328    pub drifted: Option<usize>,
329}
330
331/// The plan.
332#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
333pub struct Plan {
334    /// Units in key byte order.
335    pub entries: Vec<PlanEntry>,
336    /// Counts by status.
337    pub summary: PlanSummary,
338    /// The files plan proposes to delete (module doc).
339    pub delete_set: DeleteSet,
340    /// Lowercase hex SHA-256 of the canonical plan text (module doc).
341    pub plan_sha256: String,
342    /// Plan-level warnings.
343    pub warnings: Vec<String>,
344    /// Units the host holds but could not serve whole, in key byte order
345    /// (module doc). Never an entry, never in the delete set.
346    pub unreadable: Vec<UnreadableUnit>,
347}
348
349/// A unit with at least one live file the host could not serve
350/// ([`ObservedFiles::unreadable`]): on a Windows sftp host, a host path of
351/// `LONG_PATH_LIMIT` (240) characters or more. The whole unit is
352/// unreadable, in both collections: it is never planned from its readable
353/// files, never treated as absent and never deleted.
354#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
355pub struct UnreadableUnit {
356    /// The unit key, as an entry would carry it.
357    pub key: String,
358    /// Where the unit has live files, readable or not: the ordered subset of
359    /// `["core", "mode"]`.
360    pub live_in: Vec<String>,
361    /// The declared tree renders this unit.
362    pub declared: bool,
363    /// The declared-names ledger lists a file of this unit.
364    pub in_ledger: bool,
365    /// The collection-relative paths that could not be read, byte-sorted.
366    pub paths: Vec<String>,
367    /// The longest host path length among `paths`, in characters.
368    pub longest: usize,
369}
370
371/// What [`unreadable_unit_keys`] knows about one unreadable unit.
372#[derive(Debug, Clone, Default, PartialEq, Eq)]
373pub struct UnreadableKey {
374    /// `core` and/or `mode`: the collections holding an unreadable file.
375    pub collections: BTreeSet<String>,
376    /// The unreadable collection-relative paths, byte-sorted and unique.
377    pub paths: Vec<String>,
378    /// The longest host path length among `paths`.
379    pub longest: usize,
380}
381
382/// The layer the unit keys of `collection` (`core` or `mode`) are computed
383/// over: the collection's observed paths plus its unreadable paths, so a
384/// `resource.json` marker groups readable and unreadable files alike. With
385/// no unreadable path it holds exactly the observed paths, so every key is
386/// the one [`unit_key`] gives over the observed layer.
387pub fn unit_key_layer(observed: &ObservedFiles, collection: &str) -> BTreeMap<RelPath, ()> {
388    let files = if collection == "core" {
389        &observed.core
390    } else {
391        &observed.mode
392    };
393    files
394        .keys()
395        .cloned()
396        .chain(
397            observed
398                .unreadable
399                .iter()
400                .filter(|u| u.collection == collection)
401                .map(|u| u.path.clone()),
402        )
403        .map(|p| (p, ()))
404        .collect()
405}
406
407/// The unreadable units of `observed`, by unit key ([`unit_key_layer`]).
408/// The one definition pull, plan and verify share: a unit with an
409/// unreadable file in either collection is wholly unreadable.
410pub fn unreadable_unit_keys(observed: &ObservedFiles) -> BTreeMap<String, UnreadableKey> {
411    let mut out: BTreeMap<String, UnreadableKey> = BTreeMap::new();
412    if observed.unreadable.is_empty() {
413        return out;
414    }
415    let core_layer = unit_key_layer(observed, "core");
416    let mode_layer = unit_key_layer(observed, "mode");
417    for u in &observed.unreadable {
418        let layer = match u.collection.as_str() {
419            "core" => &core_layer,
420            "mode" => &mode_layer,
421            _ => continue,
422        };
423        let info = out.entry(unit_key(&u.path, layer)).or_default();
424        info.collections.insert(u.collection.clone());
425        info.paths.push(u.path.as_str().to_string());
426        info.longest = info.longest.max(u.length);
427    }
428    for info in out.values_mut() {
429        info.paths.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
430        info.paths.dedup();
431    }
432    out
433}
434
435/// The unit key of `path` within `layer`: the nearest ancestor directory
436/// (the file's own directory included) holding `resource.json` or
437/// `unary-resource.json` in `layer`, else the file's parent directory.
438/// Never climbs above `<module>/<type>`.
439pub fn unit_key<V>(path: &RelPath, layer: &BTreeMap<RelPath, V>) -> String {
440    let parts: Vec<&str> = path.components().collect();
441    if parts.len() < 2 {
442        return path.as_str().to_string();
443    }
444    let parent_len = parts.len() - 1;
445    for end in (2..=parent_len).rev() {
446        let dir = parts[..end].join("/");
447        for marker in ["resource.json", "unary-resource.json"] {
448            if let Ok(candidate) = RelPath::new(&format!("{dir}/{marker}"))
449                && layer.contains_key(&candidate)
450            {
451                return dir;
452            }
453        }
454    }
455    parts[..parent_len].join("/")
456}
457
458/// path -> sha256, grouped by unit key.
459type Units = BTreeMap<String, BTreeMap<String, String>>;
460
461fn group<'a, V: 'a>(layer: &'a BTreeMap<RelPath, V>, digest: impl Fn(&'a V) -> String) -> Units {
462    let mut units = Units::new();
463    for (path, value) in layer {
464        units
465            .entry(unit_key(path, layer))
466            .or_default()
467            .insert(path.as_str().to_string(), digest(value));
468    }
469    units
470}
471
472/// [`group`], with unit keys computed over `keys` ([`unit_key_layer`]).
473fn group_keyed<'a, V: 'a>(
474    layer: &'a BTreeMap<RelPath, V>,
475    keys: &BTreeMap<RelPath, ()>,
476    digest: impl Fn(&'a V) -> String,
477) -> Units {
478    let mut units = Units::new();
479    for (path, value) in layer {
480        units
481            .entry(unit_key(path, keys))
482            .or_default()
483            .insert(path.as_str().to_string(), digest(value));
484    }
485    units
486}
487
488fn blob_digest(blob: &FileBlob) -> String {
489    blob.sha256.clone()
490}
491
492/// Whether `key` is a tag-definition or tag-type-definition unit below a
493/// provider (`ignition/<type>/<provider>[/...]`). The gateway overrides these
494/// by folder, so a mode-collection copy hides `core` files it does not hold.
495fn is_tag_folder(key: &str) -> bool {
496    let mut parts = key.split('/');
497    parts.next() == Some("ignition")
498        && matches!(parts.next(), Some("tag-definition" | "tag-type-definition"))
499        && parts.next().is_some()
500}
501
502/// The keys of the entries a deploy to a redundant pair may not carry
503/// (debug deploy-tag-redundancy-sync), byte-sorted and unique: the
504/// **residue** of quick 261102-cef (D3, P5). On a pair, a `tag-definition` or
505/// `tag-type-definition` unit
506/// ([`PAIR_UNSYNCED_TAG_TYPES`](super::layout::PAIR_UNSYNCED_TAG_TYPES)) is
507/// routed to `core` ([`plan_with`]), so `added`, `changed` and `same` no
508/// longer refuse by themselves. What does is a mode-collection file of such a
509/// unit that the apply cannot delete: a bare-path row (the mode copy) with a
510/// live sha that is not in `delete`, of an entry that is not `unmanaged` and
511/// is live in the mode collection. That file keeps the tag folder in the mode
512/// collection, and its emptied folders with it, so the backup stays wedged. A
513/// mode copy the delete set clears is the migration (or the removal), and its
514/// emptied folders go too (`transport::emptied_dir_floor`). `.core/` rows
515/// never count: `core` syncs. `unmanaged` entries are not the deploy's. A
516/// bare row with a desired sha, which only an unrouted plan has, would write
517/// the unit into the mode collection, so it is refused as well: a plan made
518/// for a standalone target can never carry a tag unit onto a pair.
519pub fn pair_tag_keys(entries: &[PlanEntry], delete: &DeleteSet) -> Vec<String> {
520    let deleted: BTreeSet<&str> = delete.files.iter().map(RelPath::as_str).collect();
521    let mut keys: Vec<String> = entries
522        .iter()
523        .filter(|e| e.module == "ignition" && PAIR_UNSYNCED_TAG_TYPES.contains(&e.ty.as_str()))
524        .filter(|e| e.status != PlanStatus::Unmanaged)
525        .filter(|e| {
526            let in_mode = e.live_in.iter().any(|c| c == "mode");
527            e.files.iter().any(|f| {
528                split_key(&f.path).0 == KeyCollection::Mode
529                    && (f.desired_sha256.is_some()
530                        || (in_mode
531                            && f.live_sha256.is_some()
532                            && !deleted.contains(f.path.as_str())))
533            })
534        })
535        .map(|e| e.key.clone())
536        .collect();
537    keys.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
538    keys.dedup();
539    keys
540}
541
542/// The file paths of the mode-collection copies of routed tag units a pair's
543/// plan still holds ([`pair_tag_keys`]'s rows, whatever the entry's status,
544/// `unmanaged` included), byte-sorted: what `deploy verify` warns about on a
545/// pair (quick 261102-cef, O2). Paths only.
546pub fn pair_mode_tag_files(entries: &[PlanEntry]) -> Vec<String> {
547    let mut paths: Vec<String> = entries
548        .iter()
549        .filter(|e| e.module == "ignition" && PAIR_UNSYNCED_TAG_TYPES.contains(&e.ty.as_str()))
550        .flat_map(|e| e.files.iter())
551        .filter(|f| split_key(&f.path).0 == KeyCollection::Mode && f.live_sha256.is_some())
552        .map(|f| f.path.clone())
553        .collect();
554    paths.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
555    paths.dedup();
556    paths
557}
558
559/// The residue **paths** of a pair's mode collection, byte-sorted and unique
560/// (debug deploy-tag-redundancy-sync, T6; quick 261103-ell, F3; quick
561/// 261104-7cy, C3): each a folder or a stray file below an `ignition/<type>`
562/// folder `listed` (the master's mode collection, listed whole by
563/// [`TreeReader::list_tree`](super::transport::TreeReader::list_tree)) of a
564/// type in [`PAIR_UNSYNCED_TAG_TYPES`](super::layout::PAIR_UNSYNCED_TAG_TYPES).
565/// The **read set** of a type is the bare (mode) rows with a live sha of the
566/// entries of that type, whatever their status. With no read path below it,
567/// the type folder itself is residue, named `ignition/<type>`. Otherwise a
568/// listed file not in the read set is residue, and so is a listed folder with
569/// no read path below it; the names are the topmost such paths (their parent
570/// folder is not residue). An empty folder, one holding only empty folders,
571/// files of providers outside the read scope and a loose file each keep the
572/// backup from syncing configuration, also beside in-scope files a migration
573/// deletes: the commit removes only the folders it empties. Read files are
574/// judged by [`pair_tag_keys`] instead, unit by unit, so a migration whose
575/// mode copy the delete set clears is never refused here, and `unmanaged`
576/// read files stay allowed (quick 261102-cef, P5). The rule needs only the
577/// listing and the plan entries.
578pub fn pair_mode_tag_dirs(listed: &[TreeListing], entries: &[PlanEntry]) -> Vec<String> {
579    let mut found: Vec<String> = Vec::new();
580    for listing in listed
581        .iter()
582        .filter(|l| l.module == "ignition" && PAIR_UNSYNCED_TAG_TYPES.contains(&l.ty.as_str()))
583    {
584        let folder = format!("ignition/{}", listing.ty);
585        let under = format!("{folder}/");
586        let read: BTreeSet<&str> = entries
587            .iter()
588            .filter(|e| e.module == "ignition" && e.ty == listing.ty)
589            .flat_map(|e| e.files.iter())
590            .filter(|f| split_key(&f.path).0 == KeyCollection::Mode && f.live_sha256.is_some())
591            .map(|f| f.path.as_str())
592            .filter(|path| path.starts_with(&under))
593            .collect();
594        if read.is_empty() {
595            found.push(folder);
596            continue;
597        }
598        let residue: BTreeSet<&str> = listing
599            .entries
600            .iter()
601            .filter(|entry| {
602                let path = entry.path.as_str();
603                if entry.dir {
604                    let inside = format!("{path}/");
605                    !read.iter().any(|r| r.starts_with(&inside))
606                } else {
607                    !read.contains(path)
608                }
609            })
610            .map(|entry| entry.path.as_str())
611            .collect();
612        found.extend(
613            residue
614                .iter()
615                .filter(|path| {
616                    path.rsplit_once('/')
617                        .is_none_or(|(parent, _)| !residue.contains(parent))
618                })
619                .map(|path| (*path).to_string()),
620        );
621    }
622    found.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
623    found.dedup();
624    found
625}
626
627/// Whether the `core` copy holds a file the rendered unit lacks.
628fn hides(core: &BTreeMap<String, String>, rendered: &BTreeMap<String, String>) -> bool {
629    core.keys().any(|path| !rendered.contains_key(path))
630}
631
632/// The rows of a routed unit (quick 261102-cef, P3), byte-sorted: a `.core/`
633/// row per path of the render and the `core` copy, with the `core` sha live,
634/// then a bare row per file of the mode copy, live with the mode sha and
635/// never desired.
636fn routed_rows(
637    wanted: Option<&BTreeMap<String, String>>,
638    core_copy: Option<&BTreeMap<String, String>>,
639    mode_copy: Option<&BTreeMap<String, String>>,
640) -> Vec<PlanFile> {
641    let paths: BTreeSet<&String> = wanted
642        .into_iter()
643        .chain(core_copy)
644        .flat_map(BTreeMap::keys)
645        .collect();
646    let mut files: Vec<PlanFile> = paths
647        .into_iter()
648        .map(|path| PlanFile {
649            path: core_key(path),
650            live_sha256: core_copy.and_then(|c| c.get(path)).cloned(),
651            desired_sha256: wanted.and_then(|w| w.get(path)).cloned(),
652        })
653        .chain(mode_copy.into_iter().flatten().map(|(path, sha)| PlanFile {
654            path: path.clone(),
655            live_sha256: Some(sha.clone()),
656            desired_sha256: None,
657        }))
658        .collect();
659    files.sort_by(|a, b| a.path.as_bytes().cmp(b.path.as_bytes()));
660    files
661}
662
663/// The member data files a tag marker lists (CD5c).
664const TAG_MEMBER_FILES: [&str; 2] = ["tags.json", "udts.json"];
665
666/// What an `added` or `changed` tag unit drops from the running gateway
667/// (261030-8m8 CR-01), as a plan warning, or `None` when it drops nothing.
668///
669/// Judged against the effective live copy only (the mode copy when one
670/// exists, else `core`): what the gateway runs now. Its `tags.json` /
671/// `udts.json` (any letter case, so a spelling the host folds is never
672/// missed) that the rendered unit lacks leave the gateway, because the
673/// rewritten marker no longer lists them (CD5c) and the mode copy overrides
674/// `core` by folder (CD5a S4, CD5-CTL), even though no file is deleted. On a
675/// first deploy the effective copy is `core`, so core members newly hidden
676/// are caught; core members a mode copy already hides are not lost again.
677///
678/// Paths only, never content.
679fn tag_data_dropped(
680    key: &str,
681    effective: Option<&BTreeMap<String, String>>,
682    rendered: &BTreeMap<String, String>,
683) -> Option<String> {
684    let live: Vec<&str> = effective
685        .into_iter()
686        .flat_map(BTreeMap::keys)
687        .filter(|path| {
688            let name = path.rsplit('/').next().unwrap_or_default();
689            TAG_MEMBER_FILES
690                .iter()
691                .any(|m| m.eq_ignore_ascii_case(name))
692                && !rendered.contains_key(*path)
693        })
694        .map(String::as_str)
695        .collect();
696    if live.is_empty() {
697        return None;
698    }
699    Some(format!(
700        "{key} drops tag data the gateway runs now: the live {} not rendered, and the gateway loads only the member files a tag folder's unary-resource.json lists (CD5c) and overrides the core copy by folder (CD5a); it is destructive (tag_data_loss), so apply needs --allow-destructive",
701        if live.len() == 1 {
702            format!("{} is", live[0])
703        } else {
704            format!("{} are", live.join(", "))
705        }
706    ))
707}
708
709/// Whether the manifest's `scope` allows `path`: its `<module>/<type>` is a
710/// listed type, or it is under `ignition/<tag type>/<provider>` for a listed
711/// tag provider.
712fn in_scope(scope: &Scope, path: &RelPath) -> bool {
713    let parts: Vec<&str> = path.components().collect();
714    if parts.len() < 3 {
715        return false;
716    }
717    let (module, ty) = (parts[0], parts[1]);
718    if scope.types.iter().any(|(m, t)| m == module && t == ty) {
719        return true;
720    }
721    module == "ignition"
722        && TAG_TYPES.contains(&ty)
723        && scope.tag_providers.iter().any(|p| p == parts[2])
724}
725
726/// The unit keys a ledger path falls in, over the `core` and the mode key
727/// layers ([`unit_key_layer`]).
728struct KeyLayers<'a> {
729    core: &'a BTreeMap<RelPath, ()>,
730    mode: &'a BTreeMap<RelPath, ()>,
731}
732
733/// The delete set (module doc), the warnings it raised (the per-path
734/// warnings in path byte order, then one warning per excluded tag provider
735/// of `excluded`, the effective exclusion set, named as `excluded` spells it
736/// and counting every case spelling of it in the ledger, in provider byte
737/// order), and
738/// the keys of the `unreadable` units the ledger lists.
739///
740/// A ledger entry is processed in the collection its key names (quick
741/// 261102-cef, P4). "Rendered" is the render routed for `redundancy`
742/// ([`route`]), so on a pair a bare (mode) tag path whose unit now renders to
743/// `core` is deleted when the mode collection holds it: the migration. A
744/// `.core/` entry is kept while the routed render holds the same key, is
745/// skipped when `core` lacks its path, and otherwise is deleted from `core`,
746/// on a standalone target too (a pair turned standalone). The case-fold skip
747/// compares an entry only with the rendered paths that land in its own
748/// collection. Without `.core/` entries and on a standalone target the
749/// result is exactly what it was before A2.
750#[allow(clippy::too_many_arguments)]
751fn delete_set(
752    rendered: &RenderedTree,
753    observed: &ObservedFiles,
754    ledger: Option<&Ledger>,
755    scope: &Scope,
756    excluded: &[String],
757    layers: &KeyLayers<'_>,
758    unreadable: &BTreeMap<String, UnreadableKey>,
759    redundancy: Redundancy,
760) -> (Vec<RelPath>, Vec<String>, BTreeSet<String>) {
761    let Some(ledger) = ledger else {
762        return (Vec::new(), Vec::new(), BTreeSet::new());
763    };
764    let mut routed: BTreeSet<String> = BTreeSet::new();
765    let mut mode_folded: BTreeSet<String> = BTreeSet::new();
766    let mut core_folded: BTreeSet<String> = BTreeSet::new();
767    for path in rendered.files.keys() {
768        let key = route(redundancy, path.as_str());
769        let folded = path.as_str().to_ascii_lowercase();
770        match split_key(&key).0 {
771            KeyCollection::Mode => mode_folded.insert(folded),
772            KeyCollection::Core => core_folded.insert(folded),
773        };
774        routed.insert(key);
775    }
776    let mut listed: Vec<&String> = ledger.files.iter().collect();
777    listed.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
778    listed.dedup();
779
780    let mut delete = Vec::new();
781    let mut warnings = Vec::new();
782    let mut excluded_files: BTreeMap<String, usize> = BTreeMap::new();
783    let mut ledgered: BTreeSet<String> = BTreeSet::new();
784    for raw in listed {
785        let Ok(key) = RelPath::new(raw) else {
786            continue;
787        };
788        let (collection, rest) = split_key(raw);
789        let Ok(path) = RelPath::new(rest) else {
790            continue;
791        };
792        // Only the two tag types are ever written to `core` (the ledger
793        // refuses anything else); never delete another `core` file.
794        if collection == KeyCollection::Core && !is_pair_unsynced_tag_path(rest) {
795            continue;
796        }
797        if path
798            .components()
799            .any(|c| c.starts_with('.') || GATEWAY_OWNED_FILES.contains(&c))
800        {
801            continue;
802        }
803        // An excluded provider is never written or deleted: checked before
804        // the scope, which matches bytes exactly and might name it.
805        if let Some(provider) = exclude::excluded_provider(&path, excluded) {
806            // counted under the exclusion entry's spelling, so every case
807            // spelling the ledger uses lands in one count (C4)
808            let entry = excluded
809                .iter()
810                .find(|e| e.eq_ignore_ascii_case(provider))
811                .map_or(provider, String::as_str);
812            *excluded_files.entry(entry.to_string()).or_default() += 1;
813            continue;
814        }
815        if !in_scope(scope, &path) {
816            warnings.push(format!(
817                "ledger file {key} is under a type or tag provider the manifest no longer allows; it is not deleted"
818            ));
819            continue;
820        }
821        // An unreadable unit is never absent: checked before the absent
822        // test below, which would otherwise read it as already gone.
823        if !unreadable.is_empty() {
824            let candidates = match collection {
825                KeyCollection::Mode => {
826                    vec![unit_key(&path, layers.mode), unit_key(&path, layers.core)]
827                }
828                KeyCollection::Core => vec![unit_key(&path, layers.core)],
829            };
830            let hit = candidates
831                .into_iter()
832                .find(|key| unreadable.contains_key(key));
833            if let Some(key) = hit {
834                ledgered.insert(key);
835                continue;
836            }
837        }
838        let (live, folded) = match collection {
839            KeyCollection::Mode => (&observed.mode, &mode_folded),
840            KeyCollection::Core => (&observed.core, &core_folded),
841        };
842        if routed.contains(raw.as_str()) || !live.contains_key(&path) {
843            continue;
844        }
845        if folded.contains(&path.as_str().to_ascii_lowercase()) {
846            warnings.push(format!(
847                "ledger file {key} differs from a rendered file only by case; it is not deleted because a case-insensitive host would delete the rendered file"
848            ));
849            continue;
850        }
851        delete.push(key);
852    }
853    // String order is byte order
854    for (provider, count) in excluded_files {
855        warnings.push(format!(
856            "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"
857        ));
858    }
859    (delete, warnings, ledgered)
860}
861
862/// The `destructive_reason` of a destructive entry (module doc).
863fn destructive_reason(module: &str, ty: &str, status: PlanStatus) -> &'static str {
864    match (module, ty) {
865        ("ignition", "tag-provider" | "tag-group" | "tag-type-definition" | "tag-definition") => {
866            "tag_data_loss"
867        }
868        ("ignition", "database-connection" | "opc-connection" | "identity-provider") => {
869            "connection_removed"
870        }
871        ("ignition", "store-and-forward-engine" | "alarm-journal") => "storage_removed",
872        _ if status == PlanStatus::Removed => "resource_removed",
873        _ => "files_removed",
874    }
875}
876
877/// The live tag folders named like a tag member or marker file (CD5d), one
878/// finding per folder in collection and path byte order, or none
879/// (261030-8m8 review WR-04).
880///
881/// The gateway creates such a folder itself (an import or the Designer)
882/// despite its own refusal text, writes it as a directory where the
883/// provider-root `tags.json` file belongs, and loses that file. Render refuses
884/// the shape in a source tree; a live copy would otherwise surface only when
885/// an apply renames a file onto the directory. So `deploy plan`, and through
886/// it `deploy apply`, refuses while one is live in `core` or the mode
887/// collection (`mode`), unreadable paths included. Each finding names the
888/// folder under the data directory (`config/resources/<collection>/...`),
889/// never content. `deploy pull` skips such a unit `render_refused`.
890pub fn live_tag_folder_findings(observed: &ObservedFiles, mode: &str) -> Vec<DeployFinding> {
891    let live = observed
892        .core
893        .keys()
894        .map(|path| ("core", path))
895        .chain(observed.mode.keys().map(|path| (mode, path)))
896        .chain(observed.unreadable.iter().map(|u| {
897            let collection = if u.collection == "core" { "core" } else { mode };
898            (collection, &u.path)
899        }));
900    let folders: BTreeSet<(&str, String)> = live
901        .filter_map(|(collection, path)| member_named_tag_folder(path).map(|dir| (collection, dir)))
902        .collect();
903    folders
904        .into_iter()
905        .map(|(collection, dir)| DeployFinding {
906            file: format!("config/resources/{collection}/{dir}/"),
907            line: None,
908            column: None,
909            message: "the gateway holds a tag folder named like a tag file (tags.json, udts.json or unary-resource.json), a directory where the provider's root tag file belongs, and loses that file (CD5d); rename or remove the folder on the gateway, then plan again".to_string(),
910        })
911        .collect()
912}
913
914/// Compare `rendered` with `observed`. `ledger` is the parsed declared-names
915/// ledger, `None` for a first deploy; `scope` is the manifest's current
916/// allowlist (it bounds what the ledger may delete); `excluded` is the
917/// effective exclusion set ([`super::exclude::effective`]): ledger files
918/// under an excluded tag provider are never deleted.
919///
920/// The plan of a standalone target: [`plan_with`] and
921/// [`Redundancy::Standalone`].
922pub fn plan(
923    rendered: &RenderedTree,
924    observed: &ObservedFiles,
925    ledger: Option<&Ledger>,
926    scope: &Scope,
927    excluded: &[String],
928) -> Plan {
929    plan_with(
930        rendered,
931        observed,
932        ledger,
933        scope,
934        excluded,
935        Redundancy::Standalone,
936    )
937}
938
939/// [`plan`] for a target of `redundancy` (quick 261102-cef, A2).
940///
941/// On a redundant pair a `tag-definition` or `tag-type-definition` unit is a
942/// **routed unit**: the apply writes it into `core` ([`route`]), so
943///
944/// - its status compares the render with the `core` copy (no `core` copy is
945///   `added`; byte-equal with no delete-set file is `same`; otherwise
946///   `changed`; undeclared with delete-set files is `removed`; else
947///   `unmanaged`);
948/// - its `files` are the `.core/` rows over the render and the `core` copy
949///   (`live_sha256` the `core` sha), plus one bare row per file of its mode
950///   copy (`live_sha256` the mode sha, `desired_sha256` null): the pre-images
951///   of the migration's deletes and the residue [`pair_tag_keys`] refuses;
952/// - its `core` relation is never `hidden`, and a `removed` one is
953///   `shadows_removal` unless the delete set removes every file of its `core`
954///   copy;
955/// - `tag_data_dropped` still judges the mode copy over `core`, what the
956///   gateway runs now (RESEARCH Pitfall 5).
957///
958/// Every other unit, and every unit of a standalone target, is planned
959/// exactly as before A2, so a standalone or tag-free plan, its
960/// `plan_sha256` and its delete set are byte-identical to [`plan`]'s. The
961/// only standalone difference comes from `.core/` ledger entries (a pair
962/// turned standalone): their `core` files join the delete set and the entry
963/// gains a `.core/` row per such file for its pre-image.
964pub fn plan_with(
965    rendered: &RenderedTree,
966    observed: &ObservedFiles,
967    ledger: Option<&Ledger>,
968    scope: &Scope,
969    excluded: &[String],
970    redundancy: Redundancy,
971) -> Plan {
972    let unreadable_keys = unreadable_unit_keys(observed);
973    let core_layer = unit_key_layer(observed, "core");
974    let mode_layer = unit_key_layer(observed, "mode");
975    let mut desired = group(&rendered.files, |bytes: &Vec<u8>| sha256_hex(bytes));
976    let mut core = group_keyed(&observed.core, &core_layer, blob_digest);
977    let mut mode = group_keyed(&observed.mode, &mode_layer, blob_digest);
978    let layers = KeyLayers {
979        core: &core_layer,
980        mode: &mode_layer,
981    };
982    let (delete, ledger_warnings, ledgered) = delete_set(
983        rendered,
984        observed,
985        ledger,
986        scope,
987        excluded,
988        &layers,
989        &unreadable_keys,
990        redundancy,
991    );
992    // Each delete counts toward the unit of its own collection's key layer.
993    let mut deleted: BTreeMap<String, usize> = BTreeMap::new();
994    let mut core_deleted: BTreeSet<String> = BTreeSet::new();
995    for path in &delete {
996        let unit = match split_key(path.as_str()) {
997            (KeyCollection::Mode, _) => unit_key(path, &mode_layer),
998            (KeyCollection::Core, rest) => {
999                core_deleted.insert(rest.to_string());
1000                match RelPath::new(rest) {
1001                    Ok(rest) => unit_key(&rest, &core_layer),
1002                    Err(_) => continue,
1003                }
1004            }
1005        };
1006        *deleted.entry(unit).or_default() += 1;
1007    }
1008
1009    // No partial units: an unreadable unit leaves every grouping before the
1010    // keys are built (key byte order, as the map iterates).
1011    let mut unreadable = Vec::with_capacity(unreadable_keys.len());
1012    for (key, info) in &unreadable_keys {
1013        let declared = desired.remove(key).is_some();
1014        let in_core = core.remove(key).is_some() || info.collections.contains("core");
1015        let in_mode = mode.remove(key).is_some() || info.collections.contains("mode");
1016        let mut live_in = Vec::new();
1017        if in_core {
1018            live_in.push("core".to_string());
1019        }
1020        if in_mode {
1021            live_in.push("mode".to_string());
1022        }
1023        unreadable.push(UnreadableUnit {
1024            key: key.clone(),
1025            live_in,
1026            declared,
1027            in_ledger: ledgered.contains(key),
1028            paths: info.paths.clone(),
1029            longest: info.longest,
1030        });
1031    }
1032
1033    let keys: BTreeSet<&String> = desired
1034        .keys()
1035        .chain(core.keys())
1036        .chain(mode.keys())
1037        .collect();
1038    let empty = BTreeMap::new();
1039
1040    let mut entries = Vec::with_capacity(keys.len());
1041    let mut tag_loss_warnings: Vec<String> = Vec::new();
1042    for key in keys {
1043        let wanted = desired.get(key);
1044        let core_copy = core.get(key);
1045        let mode_copy = mode.get(key);
1046        let effective = mode_copy.or(core_copy);
1047
1048        let mut live_in = Vec::new();
1049        if core_copy.is_some() {
1050            live_in.push("core".to_string());
1051        }
1052        if mode_copy.is_some() {
1053            live_in.push("mode".to_string());
1054        }
1055
1056        // A2 (quick 261102-cef): a routed unit is compared with `core`,
1057        // where the apply writes it.
1058        let routed = redundancy == Redundancy::Pair && is_pair_unsynced_tag_path(key);
1059        let basis = if routed { core_copy } else { effective };
1060        let deleted_here = deleted.get(key).copied().unwrap_or(0);
1061        let status = match (wanted, basis) {
1062            (Some(_), None) => PlanStatus::Added,
1063            (Some(w), Some(e)) if w == e && deleted_here == 0 => PlanStatus::Same,
1064            (Some(_), Some(_)) => PlanStatus::Changed,
1065            (None, _) if deleted_here > 0 => PlanStatus::Removed,
1066            (None, _) => PlanStatus::Unmanaged,
1067        };
1068        let rendered_files = wanted.unwrap_or(&empty);
1069        // A removal that deletes the whole `core` copy shadows nothing.
1070        let core_gone = core_copy.is_some_and(|c| c.keys().all(|path| core_deleted.contains(path)));
1071        let core_relation = match core_copy {
1072            None => CoreRelation::None,
1073            Some(_) if status == PlanStatus::Removed && !core_gone => CoreRelation::ShadowsRemoval,
1074            // CR-02 (261030-8m8): an unrouted rendered tag unit always lands
1075            // in the mode collection, so it hides `core` on a first deploy
1076            // too. A routed one lands in `core` and hides nothing.
1077            Some(c)
1078                if !routed
1079                    && is_tag_folder(key)
1080                    && (mode_copy.is_some() || wanted.is_some())
1081                    && hides(c, rendered_files) =>
1082            {
1083                CoreRelation::Hidden
1084            }
1085            Some(c) if c == rendered_files => CoreRelation::Identical,
1086            Some(_) => CoreRelation::Differs,
1087        };
1088
1089        let files = if routed {
1090            routed_rows(wanted, core_copy, mode_copy)
1091        } else {
1092            let paths: BTreeSet<&String> = wanted
1093                .into_iter()
1094                .chain(effective)
1095                .flat_map(BTreeMap::keys)
1096                .collect();
1097            let mut files: Vec<PlanFile> = paths
1098                .into_iter()
1099                .map(|path| PlanFile {
1100                    path: path.clone(),
1101                    live_sha256: effective.and_then(|e| e.get(path)).cloned(),
1102                    desired_sha256: wanted.and_then(|w| w.get(path)).cloned(),
1103                })
1104                .collect();
1105            // A `core` file the delete set removes (a `.core/` ledger entry
1106            // on a target that no longer routes it) carries its pre-image.
1107            if !core_deleted.is_empty() {
1108                files.extend(
1109                    core_copy
1110                        .into_iter()
1111                        .flatten()
1112                        .filter(|(path, _)| core_deleted.contains(*path))
1113                        .map(|(path, sha)| PlanFile {
1114                            path: core_key(path),
1115                            live_sha256: Some(sha.clone()),
1116                            desired_sha256: None,
1117                        }),
1118                );
1119                files.sort_by(|a, b| a.path.as_bytes().cmp(b.path.as_bytes()));
1120            }
1121            files
1122        };
1123
1124        let parts: Vec<&str> = key.split('/').collect();
1125        let module = parts.first().copied().unwrap_or_default();
1126        let ty = parts.get(1).copied().unwrap_or_default();
1127        // CR-01 (261030-8m8): a tag unit the apply writes can drop live tag
1128        // data without deleting a file. A `same` unit is not touched.
1129        let dropped = (matches!(status, PlanStatus::Added | PlanStatus::Changed)
1130            && is_tag_folder(key))
1131        .then(|| tag_data_dropped(key, effective, rendered_files))
1132        .flatten();
1133        let destructive = status == PlanStatus::Removed || deleted_here > 0 || dropped.is_some();
1134        tag_loss_warnings.extend(dropped);
1135        let lockout_risk = matches!(
1136            status,
1137            PlanStatus::Added | PlanStatus::Changed | PlanStatus::Removed
1138        ) && module == "ignition"
1139            && matches!(ty, "identity-provider" | "security-zone");
1140        entries.push(PlanEntry {
1141            key: key.clone(),
1142            module: module.to_string(),
1143            ty: ty.to_string(),
1144            name: (parts.len() >= 3).then(|| parts[2..].join("/")),
1145            status,
1146            live_in,
1147            core: core_relation,
1148            destructive,
1149            destructive_reason: destructive
1150                .then(|| destructive_reason(module, ty, status).to_string()),
1151            lockout_risk,
1152            drift: None,
1153            files,
1154            diff: None,
1155        });
1156    }
1157
1158    let mut summary = PlanSummary::default();
1159    for entry in &entries {
1160        match entry.status {
1161            PlanStatus::Added => summary.added += 1,
1162            PlanStatus::Changed => summary.changed += 1,
1163            PlanStatus::Removed => summary.removed += 1,
1164            PlanStatus::Same => summary.same += 1,
1165            PlanStatus::Unmanaged => summary.unmanaged += 1,
1166        }
1167    }
1168    summary.unreadable = unreadable.len();
1169    // P6: an unreadable unit the deploy must act on is a change
1170    summary.has_changes = summary.added + summary.changed + summary.removed > 0
1171        || unreadable.iter().any(|u| u.declared || u.in_ledger);
1172
1173    let mut warnings: Vec<String> = observed
1174        .local_units
1175        .iter()
1176        .map(|unit| {
1177            format!(
1178                "{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"
1179            )
1180        })
1181        .collect();
1182    warnings.extend(ledger_warnings);
1183    warnings.extend(tag_loss_warnings);
1184    for unit in unreadable.iter().filter(|u| !u.declared && !u.in_ledger) {
1185        warnings.push(format!(
1186            "{} 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",
1187            unit.key, unit.longest
1188        ));
1189    }
1190
1191    let plan_sha256 = sha256_hex(&canonical_text(
1192        &rendered.render_sha256(),
1193        &entries,
1194        &delete,
1195        &unreadable,
1196    ));
1197    Plan {
1198        entries,
1199        summary,
1200        delete_set: DeleteSet { files: delete },
1201        plan_sha256,
1202        warnings,
1203        unreadable,
1204    }
1205}
1206
1207/// A previous render (a `deploy render --out` directory), re-hashed from its
1208/// real bytes (`--base`, 22-04).
1209///
1210/// `render.json` is read for identity (`gateway`, `env`, `mode`) and as a
1211/// consistency check only: its file list, every per-file sha256 and its
1212/// `render_sha256` must agree with what [`BaseTree::read`] recomputes from the
1213/// directory, so a crash-unsafe refresh (21-SECURITY R-03 / WR-03), a leftover
1214/// `.ign-render-tmp` file, or any hand edit is refused instead of being
1215/// trusted. File bytes are hashed while streaming and never kept.
1216#[derive(Debug, Clone, PartialEq, Eq)]
1217pub struct BaseTree {
1218    /// The directory as given.
1219    pub dir: String,
1220    /// The recomputed `render_sha256` (equals the marker's).
1221    pub render_sha256: String,
1222    /// Recomputed sha256 per collection path (`render.json` excluded).
1223    pub files: BTreeMap<RelPath, String>,
1224}
1225
1226/// `--base <dir>: <problem>[ (first difference: <path>)]`.
1227fn base_refusal(dir: &Path, problem: impl std::fmt::Display, first: Option<&str>) -> CoreError {
1228    let at = first.map_or_else(String::new, |p| format!(" (first difference: {p})"));
1229    CoreError::InvalidInput {
1230        reason: format!("--base {}: {problem}{at}", dir.display()),
1231    }
1232}
1233
1234/// The first path (byte order) held by exactly one of the two sets.
1235fn first_difference<'a>(a: &'a BTreeSet<String>, b: &'a BTreeSet<String>) -> Option<&'a String> {
1236    a.symmetric_difference(b)
1237        .min_by(|x, y| x.as_bytes().cmp(y.as_bytes()))
1238}
1239
1240impl BaseTree {
1241    /// Read and verify `dir` as a clean render of `gateway`, `env`, `mode`.
1242    ///
1243    /// `dir` must be a real directory (not a symlink). It is walked with
1244    /// `symlink_metadata`, capped at [`READ_MAX_ENTRIES`]; a symlink, special
1245    /// file or non-UTF-8 name anywhere under it is refused. `render.json`
1246    /// must parse as a schema 1 [`RenderMarker`] of the same gateway,
1247    /// environment and mode; the walked files must be exactly the marker's
1248    /// files; every recomputed sha256, size and the recomputed listing digest
1249    /// must equal the marker's. Every refusal is `invalid_input` naming the
1250    /// directory, the problem and the first differing path; none echoes file
1251    /// content.
1252    pub fn read(dir: &Path, gateway: &str, env: &str, mode: &str) -> Result<BaseTree, CoreError> {
1253        let meta = match std::fs::symlink_metadata(dir) {
1254            Ok(meta) => meta,
1255            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1256                return Err(base_refusal(dir, "does not exist", None));
1257            }
1258            Err(e) => {
1259                return Err(base_refusal(
1260                    dir,
1261                    format!("cannot inspect it ({})", e.kind()),
1262                    None,
1263                ));
1264            }
1265        };
1266        if meta.file_type().is_symlink() {
1267            return Err(base_refusal(
1268                dir,
1269                "is a symlink; symlinks are refused",
1270                None,
1271            ));
1272        }
1273        if !meta.file_type().is_dir() {
1274            return Err(base_refusal(dir, "is not a directory", None));
1275        }
1276
1277        let mut walked: BTreeMap<String, std::path::PathBuf> = BTreeMap::new();
1278        let mut dirs: BTreeSet<String> = BTreeSet::new();
1279        walk_capped(dir, "", &mut walked, &mut dirs, READ_MAX_ENTRIES)
1280            .map_err(|why| base_refusal(dir, why, None))?;
1281
1282        let Some(marker_path) = walked.remove(RENDER_MARKER) else {
1283            return Err(base_refusal(
1284                dir,
1285                format!("holds no {RENDER_MARKER}; it is not a `deploy render --out` directory"),
1286                Some(RENDER_MARKER),
1287            ));
1288        };
1289        let marker = read_marker(dir, &marker_path)?;
1290        if marker.schema_version != 1 {
1291            return Err(base_refusal(
1292                dir,
1293                format!(
1294                    "{RENDER_MARKER} has schema_version {}, expected 1",
1295                    marker.schema_version
1296                ),
1297                None,
1298            ));
1299        }
1300        for (what, found, expected) in [
1301            ("gateway", &marker.gateway, gateway),
1302            ("env", &marker.env, env),
1303            ("mode", &marker.mode, mode),
1304        ] {
1305            if found != expected {
1306                return Err(base_refusal(
1307                    dir,
1308                    format!("{RENDER_MARKER} {what} is {found}, this plan's is {expected}"),
1309                    None,
1310                ));
1311            }
1312        }
1313
1314        let mut listed: BTreeMap<&str, &MarkerFile> = BTreeMap::new();
1315        for entry in &marker.files {
1316            if listed.insert(entry.path.as_str(), entry).is_some() {
1317                return Err(base_refusal(
1318                    dir,
1319                    format!("{RENDER_MARKER} lists a path twice"),
1320                    Some(&entry.path),
1321                ));
1322            }
1323        }
1324        let found: BTreeSet<String> = walked.keys().cloned().collect();
1325        let named: BTreeSet<String> = listed.keys().map(|p| (*p).to_string()).collect();
1326        if let Some(path) = first_difference(&found, &named) {
1327            let problem = if path.ends_with(TMP_SUFFIX) {
1328                "holds a leftover temporary file from an interrupted render".to_string()
1329            } else if found.contains(path) {
1330                format!("holds a file not listed in {RENDER_MARKER}")
1331            } else {
1332                format!("is missing a file listed in {RENDER_MARKER}")
1333            };
1334            return Err(base_refusal(dir, problem, Some(path)));
1335        }
1336        for d in &dirs {
1337            let prefix = format!("{d}/");
1338            if !named.iter().any(|p| p.starts_with(&prefix)) {
1339                return Err(base_refusal(
1340                    dir,
1341                    "holds a directory with no rendered file",
1342                    Some(d),
1343                ));
1344            }
1345        }
1346
1347        let mut files: BTreeMap<RelPath, String> = BTreeMap::new();
1348        let mut total: u64 = 0;
1349        for (rel, path) in &walked {
1350            let entry = listed[rel.as_str()];
1351            let key = RelPath::new(rel).map_err(|why| {
1352                base_refusal(
1353                    dir,
1354                    format!("{RENDER_MARKER} lists an unusable path ({why})"),
1355                    Some(rel),
1356                )
1357            })?;
1358            let (sha256, len) = hash_base_file(dir, rel, path, &mut total)?;
1359            if sha256 != entry.sha256 || len != entry.bytes {
1360                return Err(base_refusal(
1361                    dir,
1362                    format!("a file differs from the sha256 recorded in {RENDER_MARKER}"),
1363                    Some(rel),
1364                ));
1365            }
1366            files.insert(key, sha256);
1367        }
1368
1369        let render_sha256 = listing_digest(&files);
1370        if render_sha256 != marker.render_sha256 {
1371            return Err(base_refusal(
1372                dir,
1373                format!("the files do not match the render_sha256 recorded in {RENDER_MARKER}"),
1374                None,
1375            ));
1376        }
1377        Ok(BaseTree {
1378            dir: dir.to_string_lossy().into_owned(),
1379            render_sha256,
1380            files,
1381        })
1382    }
1383}
1384
1385/// `render.json`, read through the descriptor-relative helper and parsed
1386/// without echoing anything the file says.
1387fn read_marker(dir: &Path, path: &Path) -> Result<RenderMarker, CoreError> {
1388    let before = std::fs::symlink_metadata(path).map_err(|e| {
1389        base_refusal(
1390            dir,
1391            format!("cannot inspect {RENDER_MARKER} ({})", e.kind()),
1392            None,
1393        )
1394    })?;
1395    let (file, held) = open_base_file(dir, RENDER_MARKER, &before)?;
1396    if held.len() > READ_MAX_FILE_BYTES {
1397        return Err(base_refusal(
1398            dir,
1399            format!("{RENDER_MARKER} is over the {READ_MAX_FILE_BYTES} byte cap"),
1400            None,
1401        ));
1402    }
1403    let mut bytes = Vec::new();
1404    file.take(READ_MAX_FILE_BYTES + 1)
1405        .read_to_end(&mut bytes)
1406        .map_err(|e| {
1407            base_refusal(
1408                dir,
1409                format!("cannot read {RENDER_MARKER} ({})", e.kind()),
1410                None,
1411            )
1412        })?;
1413    if bytes.len() as u64 > READ_MAX_FILE_BYTES {
1414        return Err(base_refusal(
1415            dir,
1416            format!("{RENDER_MARKER} is over the {READ_MAX_FILE_BYTES} byte cap"),
1417            None,
1418        ));
1419    }
1420    serde_json::from_slice(&bytes).map_err(|_| {
1421        base_refusal(
1422            dir,
1423            format!("{RENDER_MARKER} is not a valid render marker"),
1424            None,
1425        )
1426    })
1427}
1428
1429fn open_base_file(
1430    dir: &Path,
1431    rel: &str,
1432    before: &std::fs::Metadata,
1433) -> Result<(std::fs::File, std::fs::Metadata), CoreError> {
1434    open_regular_file(dir, Path::new(rel), before).map_err(|e| match e {
1435        OpenError::Changed => base_refusal(dir, "changed while it was read", Some(rel)),
1436        OpenError::Io(e) => {
1437            base_refusal(dir, format!("cannot read a file ({})", e.kind()), Some(rel))
1438        }
1439    })
1440}
1441
1442/// Stream one base file through SHA-256 (never keeping its bytes), enforcing
1443/// the per-file and total byte caps. Returns the digest and the byte count.
1444fn hash_base_file(
1445    dir: &Path,
1446    rel: &str,
1447    path: &Path,
1448    total: &mut u64,
1449) -> Result<(String, u64), CoreError> {
1450    let before = std::fs::symlink_metadata(path).map_err(|e| {
1451        base_refusal(
1452            dir,
1453            format!("cannot inspect a file ({})", e.kind()),
1454            Some(rel),
1455        )
1456    })?;
1457    if !before.file_type().is_file() {
1458        return Err(base_refusal(dir, "is no longer a regular file", Some(rel)));
1459    }
1460    let (file, held) = open_base_file(dir, rel, &before)?;
1461    if held.len() > READ_MAX_FILE_BYTES {
1462        return Err(base_refusal(
1463            dir,
1464            format!("a file is over the {READ_MAX_FILE_BYTES} byte cap"),
1465            Some(rel),
1466        ));
1467    }
1468    *total += held.len();
1469    if *total > READ_MAX_TOTAL_BYTES {
1470        return Err(base_refusal(
1471            dir,
1472            format!("the files exceed {READ_MAX_TOTAL_BYTES} bytes in total"),
1473            Some(rel),
1474        ));
1475    }
1476    let mut hasher = Sha256::new();
1477    let mut buf = [0u8; 64 * 1024];
1478    let mut len: u64 = 0;
1479    let mut reader = file.take(READ_MAX_FILE_BYTES + 1);
1480    loop {
1481        let n = reader.read(&mut buf).map_err(|e| {
1482            base_refusal(dir, format!("cannot read a file ({})", e.kind()), Some(rel))
1483        })?;
1484        if n == 0 {
1485            break;
1486        }
1487        len += n as u64;
1488        if len > READ_MAX_FILE_BYTES {
1489            return Err(base_refusal(
1490                dir,
1491                format!("a file grew past the {READ_MAX_FILE_BYTES} byte cap while it was read"),
1492                Some(rel),
1493            ));
1494        }
1495        hasher.update(&buf[..n]);
1496    }
1497    Ok((format!("{:x}", hasher.finalize()), len))
1498}
1499
1500/// The `render_sha256` listing digest over `path NUL sha256 LF` lines, the
1501/// same text [`RenderedTree::render_sha256`] hashes.
1502fn listing_digest(files: &BTreeMap<RelPath, String>) -> String {
1503    let mut hasher = Sha256::new();
1504    for (path, sha256) in files {
1505        hasher.update(path.as_str().as_bytes());
1506        hasher.update([0u8]);
1507        hasher.update(sha256.as_bytes());
1508        hasher.update(b"\n");
1509    }
1510    format!("{:x}", hasher.finalize())
1511}
1512
1513/// Mark each entry's `drift` against `base` and count them.
1514///
1515/// An entry drifted when any of its files has a live sha256 that differs from
1516/// both the base sha256 and the desired sha256; an absent side counts as its
1517/// own value (`None`). This includes `unmanaged` entries (a unit created on
1518/// the gateway outside the pipeline) and an `added` entry whose live copy was
1519/// deleted outside the pipeline. The flag is not part of `plan_sha256`.
1520///
1521/// A pair migration (quick 261102-kus) is judged by the mode copy it deletes.
1522/// A `.core/` row with no live sha, in an entry with no `core` copy at all,
1523/// whose bare path the plan deletes from the mode collection and that has a
1524/// bare row, takes the sha of that bare row as its live side: the base was
1525/// rendered from that mode copy and the gateway runs it now. Bare rows are
1526/// always judged by the mode sha, so a mode copy that differs from the base is
1527/// drift. A `.core/` row whose bare path is missing from the mode copy keeps
1528/// `None` and is drift. A `core` copy of any shape, even a partial one, keeps
1529/// every `.core/` row on its own sha (cef D2: a Designer edit to a managed
1530/// core folder is drift).
1531pub fn apply_base(plan: &mut Plan, base: &BaseTree) {
1532    let deleted_bare: BTreeSet<&str> = plan
1533        .delete_set
1534        .files
1535        .iter()
1536        .map(RelPath::as_str)
1537        .filter(|path| split_key(path).0 == KeyCollection::Mode)
1538        .collect();
1539    let mut drifted = 0;
1540    for entry in &mut plan.entries {
1541        let core_absent = !entry.live_in.iter().any(|c| c == "core");
1542        let mode_shas: BTreeMap<&str, &String> = entry
1543            .files
1544            .iter()
1545            .filter(|file| split_key(&file.path).0 == KeyCollection::Mode)
1546            .filter_map(|file| Some((file.path.as_str(), file.live_sha256.as_ref()?)))
1547            .collect();
1548        let drift = entry.files.iter().any(|file| {
1549            // The base render is unrouted: a `.core/` row is looked up by its
1550            // collection path (quick 261102-cef, P14).
1551            let (collection, path) = split_key(&file.path);
1552            let base_sha = RelPath::new(path)
1553                .ok()
1554                .and_then(|path| base.files.get(&path));
1555            let migrated = collection == KeyCollection::Core
1556                && file.live_sha256.is_none()
1557                && core_absent
1558                && deleted_bare.contains(path);
1559            let live = if migrated {
1560                mode_shas.get(path).copied()
1561            } else {
1562                file.live_sha256.as_ref()
1563            };
1564            live != base_sha && live != file.desired_sha256.as_ref()
1565        });
1566        entry.drift = Some(drift);
1567        drifted += usize::from(drift);
1568    }
1569    plan.summary.drifted = Some(drifted);
1570}
1571
1572/// The longest diff text one entry carries; longer diffs end with a
1573/// truncation marker.
1574pub const DIFF_MAX_BYTES: usize = 65_536;
1575
1576/// Opening words of the line printed in place of a secret-bearing member.
1577pub const DIFF_WITHHELD: &str = "withheld: contains secret-bearing locations";
1578
1579/// Fill `diff` on `added`, `changed` and `removed` entries (`--diff`).
1580///
1581/// Each file of the entry whose live and desired sha256 differ contributes one
1582/// section, in path order:
1583///
1584/// - a `.json` member whose present sides both parse ([`classify`]) and carry
1585///   no secret-bearing location on either side is a unified text diff
1586///   (`similar`, 3 lines of context, headers `live/<path>` and
1587///   `desired/<path>`; an absent side is empty);
1588/// - a `.json` member where [`scan_secrets`] (`config.json`) or
1589///   [`scan_embedded`] (every other `.json`, and tag files) reports any
1590///   refused location or warning on either side, or where the diff-only key
1591///   check ([`sensitive_key_hits`]) finds a non-empty string or non-null
1592///   scalar at or beneath an object key named like `password`, `passwd`,
1593///   `secret`, `token`, `apikey`, `api_key`, `credential`, `privatekey` or
1594///   `private_key` (ASCII case-insensitive substring, any file, with or
1595///   without an OpenAPI description) on either side, is
1596///   `<path>: withheld: contains secret-bearing locations (<n>); live <sha|->,
1597///   desired <sha|->`, where `<n>` counts locations only;
1598/// - any other member, and a `.json` member that does not parse, is
1599///   `<path>: differs (live <sha|->, desired <sha|->)`.
1600///
1601/// The text diff is the only place file bytes reach the plan, and only after
1602/// both sides passed the scan and the key check, so Embedded ciphertext and
1603/// plaintext secrets never leave it. The key check exists because the render
1604/// lints cover the desired (repo) side: the live side is gateway-written and
1605/// never linted, so `--diff` is deliberately stricter than render. It does
1606/// not touch the render lints or what render accepts. An entry's text is capped at [`DIFF_MAX_BYTES`] and then
1607/// ends with a truncation marker. The live side of an entry is its effective
1608/// copy (the mode collection when the unit exists there, else `core`), the
1609/// same basis the digests use.
1610pub fn render_diffs(plan: &mut Plan, rendered: &RenderedTree, observed: &ObservedFiles) {
1611    for entry in &mut plan.entries {
1612        if !matches!(
1613            entry.status,
1614            PlanStatus::Added | PlanStatus::Changed | PlanStatus::Removed
1615        ) {
1616            continue;
1617        }
1618        let entry_layer = if entry.live_in.iter().any(|l| l == "mode") {
1619            &observed.mode
1620        } else {
1621            &observed.core
1622        };
1623        let mut text = String::new();
1624        for file in &entry.files {
1625            if file.live_sha256 == file.desired_sha256 {
1626                continue;
1627            }
1628            // A `.core/` row's live side is the `core` copy (quick
1629            // 261102-cef, P14); its path is the collection path.
1630            let (collection, rest) = split_key(&file.path);
1631            let layer = match collection {
1632                KeyCollection::Core => &observed.core,
1633                KeyCollection::Mode => entry_layer,
1634            };
1635            let Ok(path) = RelPath::new(rest) else {
1636                continue;
1637            };
1638            let live = layer.get(&path).map(|blob| blob.bytes.as_slice());
1639            // A routed unit's mode-copy row is never desired (the apply
1640            // deletes it), though the render holds its bare path: only a
1641            // row with a desired sha takes the render's bytes (quick
1642            // 261104-7cy, C4).
1643            let desired = file
1644                .desired_sha256
1645                .is_some()
1646                .then(|| rendered.files.get(&path).map(Vec::as_slice))
1647                .flatten();
1648            text.push_str(&file_diff(
1649                &path,
1650                live,
1651                desired,
1652                file.live_sha256.as_deref(),
1653                file.desired_sha256.as_deref(),
1654            ));
1655        }
1656        entry.diff = (!text.is_empty()).then(|| cap_diff(text));
1657    }
1658}
1659
1660/// One file's section of an entry's diff, newline-terminated.
1661fn file_diff(
1662    path: &RelPath,
1663    live: Option<&[u8]>,
1664    desired: Option<&[u8]>,
1665    live_sha: Option<&str>,
1666    desired_sha: Option<&str>,
1667) -> String {
1668    let shas = format!(
1669        "live {}, desired {}",
1670        live_sha.unwrap_or("-"),
1671        desired_sha.unwrap_or("-")
1672    );
1673    let hash_only = || format!("{path}: differs ({shas})\n");
1674    if !path.as_str().ends_with(".json") {
1675        return hash_only();
1676    }
1677    let mut locations = 0;
1678    for bytes in [live, desired].into_iter().flatten() {
1679        let Ok((_, node)) = classify(bytes) else {
1680            return hash_only();
1681        };
1682        locations += secret_locations(path, &node) + sensitive_key_hits(&node);
1683    }
1684    if locations > 0 {
1685        return format!("{path}: {DIFF_WITHHELD} ({locations}); {shas}\n");
1686    }
1687    // Both sides parsed as JSON, so they are valid UTF-8.
1688    let old = live.and_then(|b| std::str::from_utf8(b).ok()).unwrap_or("");
1689    let new = desired
1690        .and_then(|b| std::str::from_utf8(b).ok())
1691        .unwrap_or("");
1692    let diff = TextDiff::from_lines(old, new);
1693    let mut unified = diff.unified_diff();
1694    unified
1695        .context_radius(3)
1696        .header(&format!("live/{path}"), &format!("desired/{path}"));
1697    let mut text = unified.to_string();
1698    if !text.ends_with('\n') {
1699        text.push('\n');
1700    }
1701    text
1702}
1703
1704/// How many secret-bearing locations (refused or warned) the render lints
1705/// find in one parsed member. Counts only; nothing the scan says is kept.
1706fn secret_locations(path: &RelPath, node: &Node) -> usize {
1707    let origin = path.as_str();
1708    let parts: Vec<&str> = path.components().collect();
1709    let tag_file =
1710        parts.first() == Some(&"ignition") && parts.get(1).is_some_and(|ty| TAG_TYPES.contains(ty));
1711    let scan = if tag_file {
1712        scan_embedded(origin, node)
1713    } else {
1714        let key = resource_key(path);
1715        let skip = if key.name.is_some() { 3 } else { 2 };
1716        let rest: Vec<&str> = parts.iter().skip(skip).copied().collect();
1717        scan_secrets(origin, &key, &rest.join("/"), node)
1718    };
1719    scan.refused.len() + scan.warnings.len()
1720}
1721
1722/// Lowercase key-name fragments that mark a value as a secret for `--diff`.
1723const SENSITIVE_KEY_FRAGMENTS: [&str; 9] = [
1724    "password",
1725    "passwd",
1726    "secret",
1727    "token",
1728    "apikey",
1729    "api_key",
1730    "credential",
1731    "privatekey",
1732    "private_key",
1733];
1734
1735/// Diff-only secret check, stricter than the render lints. The render lints
1736/// were designed for the desired (repo) side; the live side is gateway-written
1737/// and never linted, so a plaintext `"password": "x"` in a live non-config
1738/// `.json` or tag file would otherwise print verbatim in a text diff.
1739///
1740/// Walks every object in a parsed member and counts each non-empty string or
1741/// non-null scalar at or beneath an object key whose ASCII-lowercased name
1742/// contains one of [`SENSITIVE_KEY_FRAGMENTS`]. It applies whatever the file
1743/// name or the OpenAPI description says. Counts only: neither the value nor
1744/// the key path is kept.
1745fn sensitive_key_hits(node: &Node) -> usize {
1746    sensitive_walk(node, false)
1747}
1748
1749fn sensitive_walk(node: &Node, under_secret_key: bool) -> usize {
1750    match node {
1751        Node::Obj(members) => members
1752            .iter()
1753            .map(|(key, value)| {
1754                let lowered = key.to_ascii_lowercase();
1755                let secret = under_secret_key
1756                    || SENSITIVE_KEY_FRAGMENTS
1757                        .iter()
1758                        .any(|fragment| lowered.contains(fragment));
1759                sensitive_walk(value, secret)
1760            })
1761            .sum(),
1762        Node::Arr(items) => items
1763            .iter()
1764            .map(|item| sensitive_walk(item, under_secret_key))
1765            .sum(),
1766        Node::Str(text) => usize::from(under_secret_key && !text.is_empty()),
1767        Node::Lit(lexeme) => usize::from(under_secret_key && lexeme != "null"),
1768    }
1769}
1770
1771/// Cap an entry's diff at [`DIFF_MAX_BYTES`], ending a longer one with the
1772/// truncation marker; otherwise drop the trailing newline.
1773fn cap_diff(mut text: String) -> String {
1774    if text.len() > DIFF_MAX_BYTES {
1775        let mut cut = DIFF_MAX_BYTES;
1776        while !text.is_char_boundary(cut) {
1777            cut -= 1;
1778        }
1779        text.truncate(cut);
1780        if !text.ends_with('\n') {
1781            text.push('\n');
1782        }
1783        text.push_str(&format!("... [diff truncated at {DIFF_MAX_BYTES} bytes]"));
1784    } else {
1785        while text.ends_with('\n') {
1786            text.pop();
1787        }
1788    }
1789    text
1790}
1791
1792/// The canonical text `plan_sha256` hashes (see the module doc).
1793fn canonical_text(
1794    render_sha256: &str,
1795    entries: &[PlanEntry],
1796    delete_set: &[RelPath],
1797    unreadable: &[UnreadableUnit],
1798) -> Vec<u8> {
1799    let mut text: Vec<u8> = Vec::new();
1800    text.extend_from_slice(PLAN_HASH_HEADER.as_bytes());
1801    text.push(b'\n');
1802    text.extend_from_slice(b"render_sha256\0");
1803    text.extend_from_slice(render_sha256.as_bytes());
1804    text.push(b'\n');
1805    for entry in entries.iter().filter(|e| e.status != PlanStatus::Unmanaged) {
1806        text.extend_from_slice(b"E\0");
1807        text.extend_from_slice(entry.key.as_bytes());
1808        text.push(0);
1809        text.extend_from_slice(entry.status.word().as_bytes());
1810        text.push(0);
1811        text.extend_from_slice(entry.core.word().as_bytes());
1812        text.push(0);
1813        text.extend_from_slice(entry.live_in.join(",").as_bytes());
1814        text.push(b'\n');
1815        for file in &entry.files {
1816            text.extend_from_slice(b"F\0");
1817            text.extend_from_slice(file.path.as_bytes());
1818            text.push(0);
1819            text.extend_from_slice(file.live_sha256.as_deref().unwrap_or("-").as_bytes());
1820            text.push(0);
1821            text.extend_from_slice(file.desired_sha256.as_deref().unwrap_or("-").as_bytes());
1822            text.push(b'\n');
1823        }
1824    }
1825    for path in delete_set {
1826        text.extend_from_slice(b"D\0");
1827        text.extend_from_slice(path.as_str().as_bytes());
1828        text.push(b'\n');
1829    }
1830    for unit in unreadable {
1831        text.extend_from_slice(b"U\0");
1832        text.extend_from_slice(unit.key.as_bytes());
1833        text.push(0);
1834        text.extend_from_slice(unit.live_in.join(",").as_bytes());
1835        text.push(0);
1836        text.push(if unit.declared { b'1' } else { b'0' });
1837        text.push(0);
1838        text.push(if unit.in_ledger { b'1' } else { b'0' });
1839        text.push(b'\n');
1840    }
1841    text
1842}
1843
1844#[cfg(test)]
1845mod tests {
1846    use super::*;
1847
1848    fn rp(s: &str) -> RelPath {
1849        RelPath::new(s).unwrap()
1850    }
1851
1852    fn layer(paths: &[&str]) -> BTreeMap<RelPath, ()> {
1853        paths.iter().map(|p| (rp(p), ())).collect()
1854    }
1855
1856    #[test]
1857    fn resource_files_group_under_their_resource_directory() {
1858        let l = layer(&[
1859            "ignition/database-translator/POSTGRES/config.json",
1860            "ignition/database-translator/POSTGRES/resource.json",
1861        ]);
1862        let key = unit_key(&rp("ignition/database-translator/POSTGRES/config.json"), &l);
1863        assert_eq!(key, "ignition/database-translator/POSTGRES");
1864    }
1865
1866    #[test]
1867    fn singletons_group_per_type() {
1868        let l = layer(&[
1869            "ignition/general-alarm-settings/config.json",
1870            "ignition/general-alarm-settings/resource.json",
1871        ]);
1872        let key = unit_key(&rp("ignition/general-alarm-settings/config.json"), &l);
1873        assert_eq!(key, "ignition/general-alarm-settings");
1874    }
1875
1876    #[test]
1877    fn nearest_marker_wins_for_tag_folders() {
1878        let l = layer(&[
1879            "ignition/tag-definition/P/unary-resource.json",
1880            "ignition/tag-definition/P/A/unary-resource.json",
1881            "ignition/tag-definition/P/A/tags.json",
1882            "ignition/tag-definition/P/B/tags.json",
1883        ]);
1884        assert_eq!(
1885            unit_key(&rp("ignition/tag-definition/P/A/tags.json"), &l),
1886            "ignition/tag-definition/P/A"
1887        );
1888        // no marker in B itself: the nearest ancestor with one is the provider
1889        assert_eq!(
1890            unit_key(&rp("ignition/tag-definition/P/B/tags.json"), &l),
1891            "ignition/tag-definition/P"
1892        );
1893    }
1894
1895    /// CD5a/CD5b (261030-8m8): root-level tag files join the provider-root
1896    /// unit; each nested type folder with a marker is its own unit, the
1897    /// sub-folder-only `A/` included (Observation CD5b-A: marker).
1898    #[test]
1899    fn cd5_unit_keys() {
1900        let l = layer(&[
1901            "ignition/tag-definition/P/unary-resource.json",
1902            "ignition/tag-definition/P/tags.json",
1903            "ignition/tag-definition/P/udts.json",
1904            "ignition/tag-type-definition/P/unary-resource.json",
1905            "ignition/tag-type-definition/P/udts.json",
1906            "ignition/tag-type-definition/P/A/unary-resource.json",
1907            "ignition/tag-type-definition/P/A/B/unary-resource.json",
1908            "ignition/tag-type-definition/P/A/B/udts.json",
1909        ]);
1910        for (path, key) in [
1911            (
1912                "ignition/tag-definition/P/tags.json",
1913                "ignition/tag-definition/P",
1914            ),
1915            (
1916                "ignition/tag-definition/P/udts.json",
1917                "ignition/tag-definition/P",
1918            ),
1919            (
1920                "ignition/tag-type-definition/P/udts.json",
1921                "ignition/tag-type-definition/P",
1922            ),
1923            (
1924                "ignition/tag-type-definition/P/A/unary-resource.json",
1925                "ignition/tag-type-definition/P/A",
1926            ),
1927            (
1928                "ignition/tag-type-definition/P/A/B/udts.json",
1929                "ignition/tag-type-definition/P/A/B",
1930            ),
1931            (
1932                "ignition/tag-type-definition/P/A/B/unary-resource.json",
1933                "ignition/tag-type-definition/P/A/B",
1934            ),
1935        ] {
1936            assert_eq!(unit_key(&rp(path), &l), key, "{path}");
1937        }
1938        // without an A/B marker the file falls to the nearest marked ancestor
1939        let l = layer(&[
1940            "ignition/tag-type-definition/P/unary-resource.json",
1941            "ignition/tag-type-definition/P/A/unary-resource.json",
1942            "ignition/tag-type-definition/P/A/B/udts.json",
1943        ]);
1944        assert_eq!(
1945            unit_key(&rp("ignition/tag-type-definition/P/A/B/udts.json"), &l),
1946            "ignition/tag-type-definition/P/A"
1947        );
1948        let l = layer(&[
1949            "ignition/tag-type-definition/P/unary-resource.json",
1950            "ignition/tag-type-definition/P/A/B/udts.json",
1951        ]);
1952        assert_eq!(
1953            unit_key(&rp("ignition/tag-type-definition/P/A/B/udts.json"), &l),
1954            "ignition/tag-type-definition/P"
1955        );
1956    }
1957
1958    #[test]
1959    fn no_marker_falls_back_to_the_parent() {
1960        let l = layer(&["ignition/database-translator/X/config.json"]);
1961        assert_eq!(
1962            unit_key(&rp("ignition/database-translator/X/config.json"), &l),
1963            "ignition/database-translator/X"
1964        );
1965    }
1966
1967    #[test]
1968    fn empty_inputs_plan_empty() {
1969        let scope = Scope {
1970            mode: "dev".to_string(),
1971            types: Vec::new(),
1972            tag_providers: Vec::new(),
1973        };
1974        let p = plan(
1975            &RenderedTree::default(),
1976            &ObservedFiles::default(),
1977            None,
1978            &scope,
1979            &["System".to_string()],
1980        );
1981        assert!(p.entries.is_empty());
1982        assert!(!p.summary.has_changes);
1983    }
1984}