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