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/// Whether the `core` copy holds a file the rendered unit lacks.
476fn hides(core: &BTreeMap<String, String>, rendered: &BTreeMap<String, String>) -> bool {
477    core.keys().any(|path| !rendered.contains_key(path))
478}
479
480/// The member data files a tag marker lists (CD5c).
481const TAG_MEMBER_FILES: [&str; 2] = ["tags.json", "udts.json"];
482
483/// What an `added` or `changed` tag unit drops from the running gateway
484/// (261030-8m8 CR-01), as a plan warning, or `None` when it drops nothing.
485///
486/// Judged against the effective live copy only (the mode copy when one
487/// exists, else `core`): what the gateway runs now. Its `tags.json` /
488/// `udts.json` (any letter case, so a spelling the host folds is never
489/// missed) that the rendered unit lacks leave the gateway, because the
490/// rewritten marker no longer lists them (CD5c) and the mode copy overrides
491/// `core` by folder (CD5a S4, CD5-CTL), even though no file is deleted. On a
492/// first deploy the effective copy is `core`, so core members newly hidden
493/// are caught; core members a mode copy already hides are not lost again.
494///
495/// Paths only, never content.
496fn tag_data_dropped(
497    key: &str,
498    effective: Option<&BTreeMap<String, String>>,
499    rendered: &BTreeMap<String, String>,
500) -> Option<String> {
501    let live: Vec<&str> = effective
502        .into_iter()
503        .flat_map(BTreeMap::keys)
504        .filter(|path| {
505            let name = path.rsplit('/').next().unwrap_or_default();
506            TAG_MEMBER_FILES
507                .iter()
508                .any(|m| m.eq_ignore_ascii_case(name))
509                && !rendered.contains_key(*path)
510        })
511        .map(String::as_str)
512        .collect();
513    if live.is_empty() {
514        return None;
515    }
516    Some(format!(
517        "{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",
518        if live.len() == 1 {
519            format!("{} is", live[0])
520        } else {
521            format!("{} are", live.join(", "))
522        }
523    ))
524}
525
526/// Whether the manifest's `scope` allows `path`: its `<module>/<type>` is a
527/// listed type, or it is under `ignition/<tag type>/<provider>` for a listed
528/// tag provider.
529fn in_scope(scope: &Scope, path: &RelPath) -> bool {
530    let parts: Vec<&str> = path.components().collect();
531    if parts.len() < 3 {
532        return false;
533    }
534    let (module, ty) = (parts[0], parts[1]);
535    if scope.types.iter().any(|(m, t)| m == module && t == ty) {
536        return true;
537    }
538    module == "ignition"
539        && TAG_TYPES.contains(&ty)
540        && scope.tag_providers.iter().any(|p| p == parts[2])
541}
542
543/// The unit keys a ledger path falls in, over the `core` and the mode key
544/// layers ([`unit_key_layer`]).
545struct KeyLayers<'a> {
546    core: &'a BTreeMap<RelPath, ()>,
547    mode: &'a BTreeMap<RelPath, ()>,
548}
549
550/// The delete set (module doc), the warnings it raised (the per-path
551/// warnings in path byte order, then one warning per excluded tag provider
552/// of `excluded`, the effective exclusion set, named as `excluded` spells it
553/// and counting every case spelling of it in the ledger, in provider byte
554/// order), and
555/// the keys of the `unreadable` units the ledger lists.
556fn delete_set(
557    rendered: &RenderedTree,
558    observed: &ObservedFiles,
559    ledger: Option<&Ledger>,
560    scope: &Scope,
561    excluded: &[String],
562    layers: &KeyLayers<'_>,
563    unreadable: &BTreeMap<String, UnreadableKey>,
564) -> (Vec<RelPath>, Vec<String>, BTreeSet<String>) {
565    let Some(ledger) = ledger else {
566        return (Vec::new(), Vec::new(), BTreeSet::new());
567    };
568    let rendered_folded: BTreeSet<String> = rendered
569        .files
570        .keys()
571        .map(|p| p.as_str().to_ascii_lowercase())
572        .collect();
573    let mut listed: Vec<&String> = ledger.files.iter().collect();
574    listed.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
575    listed.dedup();
576
577    let mut delete = Vec::new();
578    let mut warnings = Vec::new();
579    let mut excluded_files: BTreeMap<String, usize> = BTreeMap::new();
580    let mut ledgered: BTreeSet<String> = BTreeSet::new();
581    for raw in listed {
582        let Ok(path) = RelPath::new(raw) else {
583            continue;
584        };
585        if path
586            .components()
587            .any(|c| c.starts_with('.') || GATEWAY_OWNED_FILES.contains(&c))
588        {
589            continue;
590        }
591        // An excluded provider is never written or deleted: checked before
592        // the scope, which matches bytes exactly and might name it.
593        if let Some(provider) = exclude::excluded_provider(&path, excluded) {
594            // counted under the exclusion entry's spelling, so every case
595            // spelling the ledger uses lands in one count (C4)
596            let entry = excluded
597                .iter()
598                .find(|e| e.eq_ignore_ascii_case(provider))
599                .map_or(provider, String::as_str);
600            *excluded_files.entry(entry.to_string()).or_default() += 1;
601            continue;
602        }
603        if !in_scope(scope, &path) {
604            warnings.push(format!(
605                "ledger file {path} is under a type or tag provider the manifest no longer allows; it is not deleted"
606            ));
607            continue;
608        }
609        // An unreadable unit is never absent: checked before the absent
610        // test below, which would otherwise read it as already gone.
611        if !unreadable.is_empty() {
612            let hit = [unit_key(&path, layers.mode), unit_key(&path, layers.core)]
613                .into_iter()
614                .find(|key| unreadable.contains_key(key));
615            if let Some(key) = hit {
616                ledgered.insert(key);
617                continue;
618            }
619        }
620        if rendered.files.contains_key(&path) || !observed.mode.contains_key(&path) {
621            continue;
622        }
623        if rendered_folded.contains(&path.as_str().to_ascii_lowercase()) {
624            warnings.push(format!(
625                "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"
626            ));
627            continue;
628        }
629        delete.push(path);
630    }
631    // String order is byte order
632    for (provider, count) in excluded_files {
633        warnings.push(format!(
634            "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"
635        ));
636    }
637    (delete, warnings, ledgered)
638}
639
640/// The `destructive_reason` of a destructive entry (module doc).
641fn destructive_reason(module: &str, ty: &str, status: PlanStatus) -> &'static str {
642    match (module, ty) {
643        ("ignition", "tag-provider" | "tag-group" | "tag-type-definition" | "tag-definition") => {
644            "tag_data_loss"
645        }
646        ("ignition", "database-connection" | "opc-connection" | "identity-provider") => {
647            "connection_removed"
648        }
649        ("ignition", "store-and-forward-engine" | "alarm-journal") => "storage_removed",
650        _ if status == PlanStatus::Removed => "resource_removed",
651        _ => "files_removed",
652    }
653}
654
655/// The live tag folders named like a tag member or marker file (CD5d), one
656/// finding per folder in collection and path byte order, or none
657/// (261030-8m8 review WR-04).
658///
659/// The gateway creates such a folder itself (an import or the Designer)
660/// despite its own refusal text, writes it as a directory where the
661/// provider-root `tags.json` file belongs, and loses that file. Render refuses
662/// the shape in a source tree; a live copy would otherwise surface only when
663/// an apply renames a file onto the directory. So `deploy plan`, and through
664/// it `deploy apply`, refuses while one is live in `core` or the mode
665/// collection (`mode`), unreadable paths included. Each finding names the
666/// folder under the data directory (`config/resources/<collection>/...`),
667/// never content. `deploy pull` skips such a unit `render_refused`.
668pub fn live_tag_folder_findings(observed: &ObservedFiles, mode: &str) -> Vec<DeployFinding> {
669    let live = observed
670        .core
671        .keys()
672        .map(|path| ("core", path))
673        .chain(observed.mode.keys().map(|path| (mode, path)))
674        .chain(observed.unreadable.iter().map(|u| {
675            let collection = if u.collection == "core" { "core" } else { mode };
676            (collection, &u.path)
677        }));
678    let folders: BTreeSet<(&str, String)> = live
679        .filter_map(|(collection, path)| member_named_tag_folder(path).map(|dir| (collection, dir)))
680        .collect();
681    folders
682        .into_iter()
683        .map(|(collection, dir)| DeployFinding {
684            file: format!("config/resources/{collection}/{dir}/"),
685            line: None,
686            column: None,
687            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(),
688        })
689        .collect()
690}
691
692/// Compare `rendered` with `observed`. `ledger` is the parsed declared-names
693/// ledger, `None` for a first deploy; `scope` is the manifest's current
694/// allowlist (it bounds what the ledger may delete); `excluded` is the
695/// effective exclusion set ([`super::exclude::effective`]): ledger files
696/// under an excluded tag provider are never deleted.
697pub fn plan(
698    rendered: &RenderedTree,
699    observed: &ObservedFiles,
700    ledger: Option<&Ledger>,
701    scope: &Scope,
702    excluded: &[String],
703) -> Plan {
704    let unreadable_keys = unreadable_unit_keys(observed);
705    let core_layer = unit_key_layer(observed, "core");
706    let mode_layer = unit_key_layer(observed, "mode");
707    let mut desired = group(&rendered.files, |bytes: &Vec<u8>| sha256_hex(bytes));
708    let mut core = group_keyed(&observed.core, &core_layer, blob_digest);
709    let mut mode = group_keyed(&observed.mode, &mode_layer, blob_digest);
710    let layers = KeyLayers {
711        core: &core_layer,
712        mode: &mode_layer,
713    };
714    let (delete, ledger_warnings, ledgered) = delete_set(
715        rendered,
716        observed,
717        ledger,
718        scope,
719        excluded,
720        &layers,
721        &unreadable_keys,
722    );
723    let mut deleted: BTreeMap<String, usize> = BTreeMap::new();
724    for path in &delete {
725        *deleted.entry(unit_key(path, &mode_layer)).or_default() += 1;
726    }
727
728    // No partial units: an unreadable unit leaves every grouping before the
729    // keys are built (key byte order, as the map iterates).
730    let mut unreadable = Vec::with_capacity(unreadable_keys.len());
731    for (key, info) in &unreadable_keys {
732        let declared = desired.remove(key).is_some();
733        let in_core = core.remove(key).is_some() || info.collections.contains("core");
734        let in_mode = mode.remove(key).is_some() || info.collections.contains("mode");
735        let mut live_in = Vec::new();
736        if in_core {
737            live_in.push("core".to_string());
738        }
739        if in_mode {
740            live_in.push("mode".to_string());
741        }
742        unreadable.push(UnreadableUnit {
743            key: key.clone(),
744            live_in,
745            declared,
746            in_ledger: ledgered.contains(key),
747            paths: info.paths.clone(),
748            longest: info.longest,
749        });
750    }
751
752    let keys: BTreeSet<&String> = desired
753        .keys()
754        .chain(core.keys())
755        .chain(mode.keys())
756        .collect();
757    let empty = BTreeMap::new();
758
759    let mut entries = Vec::with_capacity(keys.len());
760    let mut tag_loss_warnings: Vec<String> = Vec::new();
761    for key in keys {
762        let wanted = desired.get(key);
763        let core_copy = core.get(key);
764        let mode_copy = mode.get(key);
765        let effective = mode_copy.or(core_copy);
766
767        let mut live_in = Vec::new();
768        if core_copy.is_some() {
769            live_in.push("core".to_string());
770        }
771        if mode_copy.is_some() {
772            live_in.push("mode".to_string());
773        }
774
775        let deleted_here = deleted.get(key).copied().unwrap_or(0);
776        let status = match (wanted, effective) {
777            (Some(_), None) => PlanStatus::Added,
778            (Some(w), Some(e)) if w == e && deleted_here == 0 => PlanStatus::Same,
779            (Some(_), Some(_)) => PlanStatus::Changed,
780            (None, _) if deleted_here > 0 => PlanStatus::Removed,
781            (None, _) => PlanStatus::Unmanaged,
782        };
783        let rendered_files = wanted.unwrap_or(&empty);
784        let core_relation = match core_copy {
785            None => CoreRelation::None,
786            Some(_) if status == PlanStatus::Removed => CoreRelation::ShadowsRemoval,
787            // CR-02 (261030-8m8): a rendered tag unit always lands in the
788            // mode collection, so it hides `core` on a first deploy too.
789            Some(c)
790                if is_tag_folder(key)
791                    && (mode_copy.is_some() || wanted.is_some())
792                    && hides(c, rendered_files) =>
793            {
794                CoreRelation::Hidden
795            }
796            Some(c) if c == rendered_files => CoreRelation::Identical,
797            Some(_) => CoreRelation::Differs,
798        };
799
800        let paths: BTreeSet<&String> = wanted
801            .into_iter()
802            .chain(effective)
803            .flat_map(BTreeMap::keys)
804            .collect();
805        let files = paths
806            .into_iter()
807            .map(|path| PlanFile {
808                path: path.clone(),
809                live_sha256: effective.and_then(|e| e.get(path)).cloned(),
810                desired_sha256: wanted.and_then(|w| w.get(path)).cloned(),
811            })
812            .collect();
813
814        let parts: Vec<&str> = key.split('/').collect();
815        let module = parts.first().copied().unwrap_or_default();
816        let ty = parts.get(1).copied().unwrap_or_default();
817        // CR-01 (261030-8m8): a tag unit the apply writes can drop live tag
818        // data without deleting a file. A `same` unit is not touched.
819        let dropped = (matches!(status, PlanStatus::Added | PlanStatus::Changed)
820            && is_tag_folder(key))
821        .then(|| tag_data_dropped(key, effective, rendered_files))
822        .flatten();
823        let destructive = status == PlanStatus::Removed || deleted_here > 0 || dropped.is_some();
824        tag_loss_warnings.extend(dropped);
825        let lockout_risk = matches!(
826            status,
827            PlanStatus::Added | PlanStatus::Changed | PlanStatus::Removed
828        ) && module == "ignition"
829            && matches!(ty, "identity-provider" | "security-zone");
830        entries.push(PlanEntry {
831            key: key.clone(),
832            module: module.to_string(),
833            ty: ty.to_string(),
834            name: (parts.len() >= 3).then(|| parts[2..].join("/")),
835            status,
836            live_in,
837            core: core_relation,
838            destructive,
839            destructive_reason: destructive
840                .then(|| destructive_reason(module, ty, status).to_string()),
841            lockout_risk,
842            drift: None,
843            files,
844            diff: None,
845        });
846    }
847
848    let mut summary = PlanSummary::default();
849    for entry in &entries {
850        match entry.status {
851            PlanStatus::Added => summary.added += 1,
852            PlanStatus::Changed => summary.changed += 1,
853            PlanStatus::Removed => summary.removed += 1,
854            PlanStatus::Same => summary.same += 1,
855            PlanStatus::Unmanaged => summary.unmanaged += 1,
856        }
857    }
858    summary.unreadable = unreadable.len();
859    // P6: an unreadable unit the deploy must act on is a change
860    summary.has_changes = summary.added + summary.changed + summary.removed > 0
861        || unreadable.iter().any(|u| u.declared || u.in_ledger);
862
863    let mut warnings: Vec<String> = observed
864        .local_units
865        .iter()
866        .map(|unit| {
867            format!(
868                "{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"
869            )
870        })
871        .collect();
872    warnings.extend(ledger_warnings);
873    warnings.extend(tag_loss_warnings);
874    for unit in unreadable.iter().filter(|u| !u.declared && !u.in_ledger) {
875        warnings.push(format!(
876            "{} 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",
877            unit.key, unit.longest
878        ));
879    }
880
881    let plan_sha256 = sha256_hex(&canonical_text(
882        &rendered.render_sha256(),
883        &entries,
884        &delete,
885        &unreadable,
886    ));
887    Plan {
888        entries,
889        summary,
890        delete_set: DeleteSet { files: delete },
891        plan_sha256,
892        warnings,
893        unreadable,
894    }
895}
896
897/// A previous render (a `deploy render --out` directory), re-hashed from its
898/// real bytes (`--base`, 22-04).
899///
900/// `render.json` is read for identity (`gateway`, `env`, `mode`) and as a
901/// consistency check only: its file list, every per-file sha256 and its
902/// `render_sha256` must agree with what [`BaseTree::read`] recomputes from the
903/// directory, so a crash-unsafe refresh (21-SECURITY R-03 / WR-03), a leftover
904/// `.ign-render-tmp` file, or any hand edit is refused instead of being
905/// trusted. File bytes are hashed while streaming and never kept.
906#[derive(Debug, Clone, PartialEq, Eq)]
907pub struct BaseTree {
908    /// The directory as given.
909    pub dir: String,
910    /// The recomputed `render_sha256` (equals the marker's).
911    pub render_sha256: String,
912    /// Recomputed sha256 per collection path (`render.json` excluded).
913    pub files: BTreeMap<RelPath, String>,
914}
915
916/// `--base <dir>: <problem>[ (first difference: <path>)]`.
917fn base_refusal(dir: &Path, problem: impl std::fmt::Display, first: Option<&str>) -> CoreError {
918    let at = first.map_or_else(String::new, |p| format!(" (first difference: {p})"));
919    CoreError::InvalidInput {
920        reason: format!("--base {}: {problem}{at}", dir.display()),
921    }
922}
923
924/// The first path (byte order) held by exactly one of the two sets.
925fn first_difference<'a>(a: &'a BTreeSet<String>, b: &'a BTreeSet<String>) -> Option<&'a String> {
926    a.symmetric_difference(b)
927        .min_by(|x, y| x.as_bytes().cmp(y.as_bytes()))
928}
929
930impl BaseTree {
931    /// Read and verify `dir` as a clean render of `gateway`, `env`, `mode`.
932    ///
933    /// `dir` must be a real directory (not a symlink). It is walked with
934    /// `symlink_metadata`, capped at [`READ_MAX_ENTRIES`]; a symlink, special
935    /// file or non-UTF-8 name anywhere under it is refused. `render.json`
936    /// must parse as a schema 1 [`RenderMarker`] of the same gateway,
937    /// environment and mode; the walked files must be exactly the marker's
938    /// files; every recomputed sha256, size and the recomputed listing digest
939    /// must equal the marker's. Every refusal is `invalid_input` naming the
940    /// directory, the problem and the first differing path; none echoes file
941    /// content.
942    pub fn read(dir: &Path, gateway: &str, env: &str, mode: &str) -> Result<BaseTree, CoreError> {
943        let meta = match std::fs::symlink_metadata(dir) {
944            Ok(meta) => meta,
945            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
946                return Err(base_refusal(dir, "does not exist", None));
947            }
948            Err(e) => {
949                return Err(base_refusal(
950                    dir,
951                    format!("cannot inspect it ({})", e.kind()),
952                    None,
953                ));
954            }
955        };
956        if meta.file_type().is_symlink() {
957            return Err(base_refusal(
958                dir,
959                "is a symlink; symlinks are refused",
960                None,
961            ));
962        }
963        if !meta.file_type().is_dir() {
964            return Err(base_refusal(dir, "is not a directory", None));
965        }
966
967        let mut walked: BTreeMap<String, std::path::PathBuf> = BTreeMap::new();
968        let mut dirs: BTreeSet<String> = BTreeSet::new();
969        walk_capped(dir, "", &mut walked, &mut dirs, READ_MAX_ENTRIES)
970            .map_err(|why| base_refusal(dir, why, None))?;
971
972        let Some(marker_path) = walked.remove(RENDER_MARKER) else {
973            return Err(base_refusal(
974                dir,
975                format!("holds no {RENDER_MARKER}; it is not a `deploy render --out` directory"),
976                Some(RENDER_MARKER),
977            ));
978        };
979        let marker = read_marker(dir, &marker_path)?;
980        if marker.schema_version != 1 {
981            return Err(base_refusal(
982                dir,
983                format!(
984                    "{RENDER_MARKER} has schema_version {}, expected 1",
985                    marker.schema_version
986                ),
987                None,
988            ));
989        }
990        for (what, found, expected) in [
991            ("gateway", &marker.gateway, gateway),
992            ("env", &marker.env, env),
993            ("mode", &marker.mode, mode),
994        ] {
995            if found != expected {
996                return Err(base_refusal(
997                    dir,
998                    format!("{RENDER_MARKER} {what} is {found}, this plan's is {expected}"),
999                    None,
1000                ));
1001            }
1002        }
1003
1004        let mut listed: BTreeMap<&str, &MarkerFile> = BTreeMap::new();
1005        for entry in &marker.files {
1006            if listed.insert(entry.path.as_str(), entry).is_some() {
1007                return Err(base_refusal(
1008                    dir,
1009                    format!("{RENDER_MARKER} lists a path twice"),
1010                    Some(&entry.path),
1011                ));
1012            }
1013        }
1014        let found: BTreeSet<String> = walked.keys().cloned().collect();
1015        let named: BTreeSet<String> = listed.keys().map(|p| (*p).to_string()).collect();
1016        if let Some(path) = first_difference(&found, &named) {
1017            let problem = if path.ends_with(TMP_SUFFIX) {
1018                "holds a leftover temporary file from an interrupted render".to_string()
1019            } else if found.contains(path) {
1020                format!("holds a file not listed in {RENDER_MARKER}")
1021            } else {
1022                format!("is missing a file listed in {RENDER_MARKER}")
1023            };
1024            return Err(base_refusal(dir, problem, Some(path)));
1025        }
1026        for d in &dirs {
1027            let prefix = format!("{d}/");
1028            if !named.iter().any(|p| p.starts_with(&prefix)) {
1029                return Err(base_refusal(
1030                    dir,
1031                    "holds a directory with no rendered file",
1032                    Some(d),
1033                ));
1034            }
1035        }
1036
1037        let mut files: BTreeMap<RelPath, String> = BTreeMap::new();
1038        let mut total: u64 = 0;
1039        for (rel, path) in &walked {
1040            let entry = listed[rel.as_str()];
1041            let key = RelPath::new(rel).map_err(|why| {
1042                base_refusal(
1043                    dir,
1044                    format!("{RENDER_MARKER} lists an unusable path ({why})"),
1045                    Some(rel),
1046                )
1047            })?;
1048            let (sha256, len) = hash_base_file(dir, rel, path, &mut total)?;
1049            if sha256 != entry.sha256 || len != entry.bytes {
1050                return Err(base_refusal(
1051                    dir,
1052                    format!("a file differs from the sha256 recorded in {RENDER_MARKER}"),
1053                    Some(rel),
1054                ));
1055            }
1056            files.insert(key, sha256);
1057        }
1058
1059        let render_sha256 = listing_digest(&files);
1060        if render_sha256 != marker.render_sha256 {
1061            return Err(base_refusal(
1062                dir,
1063                format!("the files do not match the render_sha256 recorded in {RENDER_MARKER}"),
1064                None,
1065            ));
1066        }
1067        Ok(BaseTree {
1068            dir: dir.to_string_lossy().into_owned(),
1069            render_sha256,
1070            files,
1071        })
1072    }
1073}
1074
1075/// `render.json`, read through the descriptor-relative helper and parsed
1076/// without echoing anything the file says.
1077fn read_marker(dir: &Path, path: &Path) -> Result<RenderMarker, CoreError> {
1078    let before = std::fs::symlink_metadata(path).map_err(|e| {
1079        base_refusal(
1080            dir,
1081            format!("cannot inspect {RENDER_MARKER} ({})", e.kind()),
1082            None,
1083        )
1084    })?;
1085    let (file, held) = open_base_file(dir, RENDER_MARKER, &before)?;
1086    if held.len() > READ_MAX_FILE_BYTES {
1087        return Err(base_refusal(
1088            dir,
1089            format!("{RENDER_MARKER} is over the {READ_MAX_FILE_BYTES} byte cap"),
1090            None,
1091        ));
1092    }
1093    let mut bytes = Vec::new();
1094    file.take(READ_MAX_FILE_BYTES + 1)
1095        .read_to_end(&mut bytes)
1096        .map_err(|e| {
1097            base_refusal(
1098                dir,
1099                format!("cannot read {RENDER_MARKER} ({})", e.kind()),
1100                None,
1101            )
1102        })?;
1103    if bytes.len() as u64 > READ_MAX_FILE_BYTES {
1104        return Err(base_refusal(
1105            dir,
1106            format!("{RENDER_MARKER} is over the {READ_MAX_FILE_BYTES} byte cap"),
1107            None,
1108        ));
1109    }
1110    serde_json::from_slice(&bytes).map_err(|_| {
1111        base_refusal(
1112            dir,
1113            format!("{RENDER_MARKER} is not a valid render marker"),
1114            None,
1115        )
1116    })
1117}
1118
1119fn open_base_file(
1120    dir: &Path,
1121    rel: &str,
1122    before: &std::fs::Metadata,
1123) -> Result<(std::fs::File, std::fs::Metadata), CoreError> {
1124    open_regular_file(dir, Path::new(rel), before).map_err(|e| match e {
1125        OpenError::Changed => base_refusal(dir, "changed while it was read", Some(rel)),
1126        OpenError::Io(e) => {
1127            base_refusal(dir, format!("cannot read a file ({})", e.kind()), Some(rel))
1128        }
1129    })
1130}
1131
1132/// Stream one base file through SHA-256 (never keeping its bytes), enforcing
1133/// the per-file and total byte caps. Returns the digest and the byte count.
1134fn hash_base_file(
1135    dir: &Path,
1136    rel: &str,
1137    path: &Path,
1138    total: &mut u64,
1139) -> Result<(String, u64), CoreError> {
1140    let before = std::fs::symlink_metadata(path).map_err(|e| {
1141        base_refusal(
1142            dir,
1143            format!("cannot inspect a file ({})", e.kind()),
1144            Some(rel),
1145        )
1146    })?;
1147    if !before.file_type().is_file() {
1148        return Err(base_refusal(dir, "is no longer a regular file", Some(rel)));
1149    }
1150    let (file, held) = open_base_file(dir, rel, &before)?;
1151    if held.len() > READ_MAX_FILE_BYTES {
1152        return Err(base_refusal(
1153            dir,
1154            format!("a file is over the {READ_MAX_FILE_BYTES} byte cap"),
1155            Some(rel),
1156        ));
1157    }
1158    *total += held.len();
1159    if *total > READ_MAX_TOTAL_BYTES {
1160        return Err(base_refusal(
1161            dir,
1162            format!("the files exceed {READ_MAX_TOTAL_BYTES} bytes in total"),
1163            Some(rel),
1164        ));
1165    }
1166    let mut hasher = Sha256::new();
1167    let mut buf = [0u8; 64 * 1024];
1168    let mut len: u64 = 0;
1169    let mut reader = file.take(READ_MAX_FILE_BYTES + 1);
1170    loop {
1171        let n = reader.read(&mut buf).map_err(|e| {
1172            base_refusal(dir, format!("cannot read a file ({})", e.kind()), Some(rel))
1173        })?;
1174        if n == 0 {
1175            break;
1176        }
1177        len += n as u64;
1178        if len > READ_MAX_FILE_BYTES {
1179            return Err(base_refusal(
1180                dir,
1181                format!("a file grew past the {READ_MAX_FILE_BYTES} byte cap while it was read"),
1182                Some(rel),
1183            ));
1184        }
1185        hasher.update(&buf[..n]);
1186    }
1187    Ok((format!("{:x}", hasher.finalize()), len))
1188}
1189
1190/// The `render_sha256` listing digest over `path NUL sha256 LF` lines, the
1191/// same text [`RenderedTree::render_sha256`] hashes.
1192fn listing_digest(files: &BTreeMap<RelPath, String>) -> String {
1193    let mut hasher = Sha256::new();
1194    for (path, sha256) in files {
1195        hasher.update(path.as_str().as_bytes());
1196        hasher.update([0u8]);
1197        hasher.update(sha256.as_bytes());
1198        hasher.update(b"\n");
1199    }
1200    format!("{:x}", hasher.finalize())
1201}
1202
1203/// Mark each entry's `drift` against `base` and count them.
1204///
1205/// An entry drifted when any of its files has a live sha256 that differs from
1206/// both the base sha256 and the desired sha256; an absent side counts as its
1207/// own value (`None`). This includes `unmanaged` entries (a unit created on
1208/// the gateway outside the pipeline) and an `added` entry whose live copy was
1209/// deleted outside the pipeline. The flag is not part of `plan_sha256`.
1210pub fn apply_base(plan: &mut Plan, base: &BaseTree) {
1211    let mut drifted = 0;
1212    for entry in &mut plan.entries {
1213        let drift = entry.files.iter().any(|file| {
1214            let base_sha = RelPath::new(&file.path)
1215                .ok()
1216                .and_then(|path| base.files.get(&path));
1217            file.live_sha256.as_ref() != base_sha && file.live_sha256 != file.desired_sha256
1218        });
1219        entry.drift = Some(drift);
1220        drifted += usize::from(drift);
1221    }
1222    plan.summary.drifted = Some(drifted);
1223}
1224
1225/// The longest diff text one entry carries; longer diffs end with a
1226/// truncation marker.
1227pub const DIFF_MAX_BYTES: usize = 65_536;
1228
1229/// Opening words of the line printed in place of a secret-bearing member.
1230pub const DIFF_WITHHELD: &str = "withheld: contains secret-bearing locations";
1231
1232/// Fill `diff` on `added`, `changed` and `removed` entries (`--diff`).
1233///
1234/// Each file of the entry whose live and desired sha256 differ contributes one
1235/// section, in path order:
1236///
1237/// - a `.json` member whose present sides both parse ([`classify`]) and carry
1238///   no secret-bearing location on either side is a unified text diff
1239///   (`similar`, 3 lines of context, headers `live/<path>` and
1240///   `desired/<path>`; an absent side is empty);
1241/// - a `.json` member where [`scan_secrets`] (`config.json`) or
1242///   [`scan_embedded`] (every other `.json`, and tag files) reports any
1243///   refused location or warning on either side, or where the diff-only key
1244///   check ([`sensitive_key_hits`]) finds a non-empty string or non-null
1245///   scalar at or beneath an object key named like `password`, `passwd`,
1246///   `secret`, `token`, `apikey`, `api_key`, `credential`, `privatekey` or
1247///   `private_key` (ASCII case-insensitive substring, any file, with or
1248///   without an OpenAPI description) on either side, is
1249///   `<path>: withheld: contains secret-bearing locations (<n>); live <sha|->,
1250///   desired <sha|->`, where `<n>` counts locations only;
1251/// - any other member, and a `.json` member that does not parse, is
1252///   `<path>: differs (live <sha|->, desired <sha|->)`.
1253///
1254/// The text diff is the only place file bytes reach the plan, and only after
1255/// both sides passed the scan and the key check, so Embedded ciphertext and
1256/// plaintext secrets never leave it. The key check exists because the render
1257/// lints cover the desired (repo) side: the live side is gateway-written and
1258/// never linted, so `--diff` is deliberately stricter than render. It does
1259/// not touch the render lints or what render accepts. An entry's text is capped at [`DIFF_MAX_BYTES`] and then
1260/// ends with a truncation marker. The live side of an entry is its effective
1261/// copy (the mode collection when the unit exists there, else `core`), the
1262/// same basis the digests use.
1263pub fn render_diffs(plan: &mut Plan, rendered: &RenderedTree, observed: &ObservedFiles) {
1264    for entry in &mut plan.entries {
1265        if !matches!(
1266            entry.status,
1267            PlanStatus::Added | PlanStatus::Changed | PlanStatus::Removed
1268        ) {
1269            continue;
1270        }
1271        let layer = if entry.live_in.iter().any(|l| l == "mode") {
1272            &observed.mode
1273        } else {
1274            &observed.core
1275        };
1276        let mut text = String::new();
1277        for file in &entry.files {
1278            if file.live_sha256 == file.desired_sha256 {
1279                continue;
1280            }
1281            let Ok(path) = RelPath::new(&file.path) else {
1282                continue;
1283            };
1284            let live = layer.get(&path).map(|blob| blob.bytes.as_slice());
1285            let desired = rendered.files.get(&path).map(Vec::as_slice);
1286            text.push_str(&file_diff(
1287                &path,
1288                live,
1289                desired,
1290                file.live_sha256.as_deref(),
1291                file.desired_sha256.as_deref(),
1292            ));
1293        }
1294        entry.diff = (!text.is_empty()).then(|| cap_diff(text));
1295    }
1296}
1297
1298/// One file's section of an entry's diff, newline-terminated.
1299fn file_diff(
1300    path: &RelPath,
1301    live: Option<&[u8]>,
1302    desired: Option<&[u8]>,
1303    live_sha: Option<&str>,
1304    desired_sha: Option<&str>,
1305) -> String {
1306    let shas = format!(
1307        "live {}, desired {}",
1308        live_sha.unwrap_or("-"),
1309        desired_sha.unwrap_or("-")
1310    );
1311    let hash_only = || format!("{path}: differs ({shas})\n");
1312    if !path.as_str().ends_with(".json") {
1313        return hash_only();
1314    }
1315    let mut locations = 0;
1316    for bytes in [live, desired].into_iter().flatten() {
1317        let Ok((_, node)) = classify(bytes) else {
1318            return hash_only();
1319        };
1320        locations += secret_locations(path, &node) + sensitive_key_hits(&node);
1321    }
1322    if locations > 0 {
1323        return format!("{path}: {DIFF_WITHHELD} ({locations}); {shas}\n");
1324    }
1325    // Both sides parsed as JSON, so they are valid UTF-8.
1326    let old = live.and_then(|b| std::str::from_utf8(b).ok()).unwrap_or("");
1327    let new = desired
1328        .and_then(|b| std::str::from_utf8(b).ok())
1329        .unwrap_or("");
1330    let diff = TextDiff::from_lines(old, new);
1331    let mut unified = diff.unified_diff();
1332    unified
1333        .context_radius(3)
1334        .header(&format!("live/{path}"), &format!("desired/{path}"));
1335    let mut text = unified.to_string();
1336    if !text.ends_with('\n') {
1337        text.push('\n');
1338    }
1339    text
1340}
1341
1342/// How many secret-bearing locations (refused or warned) the render lints
1343/// find in one parsed member. Counts only; nothing the scan says is kept.
1344fn secret_locations(path: &RelPath, node: &Node) -> usize {
1345    let origin = path.as_str();
1346    let parts: Vec<&str> = path.components().collect();
1347    let tag_file =
1348        parts.first() == Some(&"ignition") && parts.get(1).is_some_and(|ty| TAG_TYPES.contains(ty));
1349    let scan = if tag_file {
1350        scan_embedded(origin, node)
1351    } else {
1352        let key = resource_key(path);
1353        let skip = if key.name.is_some() { 3 } else { 2 };
1354        let rest: Vec<&str> = parts.iter().skip(skip).copied().collect();
1355        scan_secrets(origin, &key, &rest.join("/"), node)
1356    };
1357    scan.refused.len() + scan.warnings.len()
1358}
1359
1360/// Lowercase key-name fragments that mark a value as a secret for `--diff`.
1361const SENSITIVE_KEY_FRAGMENTS: [&str; 9] = [
1362    "password",
1363    "passwd",
1364    "secret",
1365    "token",
1366    "apikey",
1367    "api_key",
1368    "credential",
1369    "privatekey",
1370    "private_key",
1371];
1372
1373/// Diff-only secret check, stricter than the render lints. The render lints
1374/// were designed for the desired (repo) side; the live side is gateway-written
1375/// and never linted, so a plaintext `"password": "x"` in a live non-config
1376/// `.json` or tag file would otherwise print verbatim in a text diff.
1377///
1378/// Walks every object in a parsed member and counts each non-empty string or
1379/// non-null scalar at or beneath an object key whose ASCII-lowercased name
1380/// contains one of [`SENSITIVE_KEY_FRAGMENTS`]. It applies whatever the file
1381/// name or the OpenAPI description says. Counts only: neither the value nor
1382/// the key path is kept.
1383fn sensitive_key_hits(node: &Node) -> usize {
1384    sensitive_walk(node, false)
1385}
1386
1387fn sensitive_walk(node: &Node, under_secret_key: bool) -> usize {
1388    match node {
1389        Node::Obj(members) => members
1390            .iter()
1391            .map(|(key, value)| {
1392                let lowered = key.to_ascii_lowercase();
1393                let secret = under_secret_key
1394                    || SENSITIVE_KEY_FRAGMENTS
1395                        .iter()
1396                        .any(|fragment| lowered.contains(fragment));
1397                sensitive_walk(value, secret)
1398            })
1399            .sum(),
1400        Node::Arr(items) => items
1401            .iter()
1402            .map(|item| sensitive_walk(item, under_secret_key))
1403            .sum(),
1404        Node::Str(text) => usize::from(under_secret_key && !text.is_empty()),
1405        Node::Lit(lexeme) => usize::from(under_secret_key && lexeme != "null"),
1406    }
1407}
1408
1409/// Cap an entry's diff at [`DIFF_MAX_BYTES`], ending a longer one with the
1410/// truncation marker; otherwise drop the trailing newline.
1411fn cap_diff(mut text: String) -> String {
1412    if text.len() > DIFF_MAX_BYTES {
1413        let mut cut = DIFF_MAX_BYTES;
1414        while !text.is_char_boundary(cut) {
1415            cut -= 1;
1416        }
1417        text.truncate(cut);
1418        if !text.ends_with('\n') {
1419            text.push('\n');
1420        }
1421        text.push_str(&format!("... [diff truncated at {DIFF_MAX_BYTES} bytes]"));
1422    } else {
1423        while text.ends_with('\n') {
1424            text.pop();
1425        }
1426    }
1427    text
1428}
1429
1430/// The canonical text `plan_sha256` hashes (see the module doc).
1431fn canonical_text(
1432    render_sha256: &str,
1433    entries: &[PlanEntry],
1434    delete_set: &[RelPath],
1435    unreadable: &[UnreadableUnit],
1436) -> Vec<u8> {
1437    let mut text: Vec<u8> = Vec::new();
1438    text.extend_from_slice(PLAN_HASH_HEADER.as_bytes());
1439    text.push(b'\n');
1440    text.extend_from_slice(b"render_sha256\0");
1441    text.extend_from_slice(render_sha256.as_bytes());
1442    text.push(b'\n');
1443    for entry in entries.iter().filter(|e| e.status != PlanStatus::Unmanaged) {
1444        text.extend_from_slice(b"E\0");
1445        text.extend_from_slice(entry.key.as_bytes());
1446        text.push(0);
1447        text.extend_from_slice(entry.status.word().as_bytes());
1448        text.push(0);
1449        text.extend_from_slice(entry.core.word().as_bytes());
1450        text.push(0);
1451        text.extend_from_slice(entry.live_in.join(",").as_bytes());
1452        text.push(b'\n');
1453        for file in &entry.files {
1454            text.extend_from_slice(b"F\0");
1455            text.extend_from_slice(file.path.as_bytes());
1456            text.push(0);
1457            text.extend_from_slice(file.live_sha256.as_deref().unwrap_or("-").as_bytes());
1458            text.push(0);
1459            text.extend_from_slice(file.desired_sha256.as_deref().unwrap_or("-").as_bytes());
1460            text.push(b'\n');
1461        }
1462    }
1463    for path in delete_set {
1464        text.extend_from_slice(b"D\0");
1465        text.extend_from_slice(path.as_str().as_bytes());
1466        text.push(b'\n');
1467    }
1468    for unit in unreadable {
1469        text.extend_from_slice(b"U\0");
1470        text.extend_from_slice(unit.key.as_bytes());
1471        text.push(0);
1472        text.extend_from_slice(unit.live_in.join(",").as_bytes());
1473        text.push(0);
1474        text.push(if unit.declared { b'1' } else { b'0' });
1475        text.push(0);
1476        text.push(if unit.in_ledger { b'1' } else { b'0' });
1477        text.push(b'\n');
1478    }
1479    text
1480}
1481
1482#[cfg(test)]
1483mod tests {
1484    use super::*;
1485
1486    fn rp(s: &str) -> RelPath {
1487        RelPath::new(s).unwrap()
1488    }
1489
1490    fn layer(paths: &[&str]) -> BTreeMap<RelPath, ()> {
1491        paths.iter().map(|p| (rp(p), ())).collect()
1492    }
1493
1494    #[test]
1495    fn resource_files_group_under_their_resource_directory() {
1496        let l = layer(&[
1497            "ignition/database-translator/POSTGRES/config.json",
1498            "ignition/database-translator/POSTGRES/resource.json",
1499        ]);
1500        let key = unit_key(&rp("ignition/database-translator/POSTGRES/config.json"), &l);
1501        assert_eq!(key, "ignition/database-translator/POSTGRES");
1502    }
1503
1504    #[test]
1505    fn singletons_group_per_type() {
1506        let l = layer(&[
1507            "ignition/general-alarm-settings/config.json",
1508            "ignition/general-alarm-settings/resource.json",
1509        ]);
1510        let key = unit_key(&rp("ignition/general-alarm-settings/config.json"), &l);
1511        assert_eq!(key, "ignition/general-alarm-settings");
1512    }
1513
1514    #[test]
1515    fn nearest_marker_wins_for_tag_folders() {
1516        let l = layer(&[
1517            "ignition/tag-definition/P/unary-resource.json",
1518            "ignition/tag-definition/P/A/unary-resource.json",
1519            "ignition/tag-definition/P/A/tags.json",
1520            "ignition/tag-definition/P/B/tags.json",
1521        ]);
1522        assert_eq!(
1523            unit_key(&rp("ignition/tag-definition/P/A/tags.json"), &l),
1524            "ignition/tag-definition/P/A"
1525        );
1526        // no marker in B itself: the nearest ancestor with one is the provider
1527        assert_eq!(
1528            unit_key(&rp("ignition/tag-definition/P/B/tags.json"), &l),
1529            "ignition/tag-definition/P"
1530        );
1531    }
1532
1533    /// CD5a/CD5b (261030-8m8): root-level tag files join the provider-root
1534    /// unit; each nested type folder with a marker is its own unit, the
1535    /// sub-folder-only `A/` included (Observation CD5b-A: marker).
1536    #[test]
1537    fn cd5_unit_keys() {
1538        let l = layer(&[
1539            "ignition/tag-definition/P/unary-resource.json",
1540            "ignition/tag-definition/P/tags.json",
1541            "ignition/tag-definition/P/udts.json",
1542            "ignition/tag-type-definition/P/unary-resource.json",
1543            "ignition/tag-type-definition/P/udts.json",
1544            "ignition/tag-type-definition/P/A/unary-resource.json",
1545            "ignition/tag-type-definition/P/A/B/unary-resource.json",
1546            "ignition/tag-type-definition/P/A/B/udts.json",
1547        ]);
1548        for (path, key) in [
1549            (
1550                "ignition/tag-definition/P/tags.json",
1551                "ignition/tag-definition/P",
1552            ),
1553            (
1554                "ignition/tag-definition/P/udts.json",
1555                "ignition/tag-definition/P",
1556            ),
1557            (
1558                "ignition/tag-type-definition/P/udts.json",
1559                "ignition/tag-type-definition/P",
1560            ),
1561            (
1562                "ignition/tag-type-definition/P/A/unary-resource.json",
1563                "ignition/tag-type-definition/P/A",
1564            ),
1565            (
1566                "ignition/tag-type-definition/P/A/B/udts.json",
1567                "ignition/tag-type-definition/P/A/B",
1568            ),
1569            (
1570                "ignition/tag-type-definition/P/A/B/unary-resource.json",
1571                "ignition/tag-type-definition/P/A/B",
1572            ),
1573        ] {
1574            assert_eq!(unit_key(&rp(path), &l), key, "{path}");
1575        }
1576        // without an A/B marker the file falls to the nearest marked ancestor
1577        let l = layer(&[
1578            "ignition/tag-type-definition/P/unary-resource.json",
1579            "ignition/tag-type-definition/P/A/unary-resource.json",
1580            "ignition/tag-type-definition/P/A/B/udts.json",
1581        ]);
1582        assert_eq!(
1583            unit_key(&rp("ignition/tag-type-definition/P/A/B/udts.json"), &l),
1584            "ignition/tag-type-definition/P/A"
1585        );
1586        let l = layer(&[
1587            "ignition/tag-type-definition/P/unary-resource.json",
1588            "ignition/tag-type-definition/P/A/B/udts.json",
1589        ]);
1590        assert_eq!(
1591            unit_key(&rp("ignition/tag-type-definition/P/A/B/udts.json"), &l),
1592            "ignition/tag-type-definition/P"
1593        );
1594    }
1595
1596    #[test]
1597    fn no_marker_falls_back_to_the_parent() {
1598        let l = layer(&["ignition/database-translator/X/config.json"]);
1599        assert_eq!(
1600            unit_key(&rp("ignition/database-translator/X/config.json"), &l),
1601            "ignition/database-translator/X"
1602        );
1603    }
1604
1605    #[test]
1606    fn empty_inputs_plan_empty() {
1607        let scope = Scope {
1608            mode: "dev".to_string(),
1609            types: Vec::new(),
1610            tag_providers: Vec::new(),
1611        };
1612        let p = plan(
1613            &RenderedTree::default(),
1614            &ObservedFiles::default(),
1615            None,
1616            &scope,
1617            &["System".to_string()],
1618        );
1619        assert!(p.entries.is_empty());
1620        assert!(!p.summary.has_changes);
1621    }
1622}