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