Skip to main content

ignition_core/deploy/
verify.rs

1//! Pure evaluation of `ign deploy verify` (22-10, DPLN-07, DPLN-08, DPLN-09).
2//!
3//! [`evaluate`] takes what the declared tree says ([`Expected`]) and what the
4//! action read from the gateway ([`ObservedState`]) and returns a
5//! [`VerifyReport`]. It does no I/O, reads no clock and prints nothing. The
6//! six checks always appear, in this order:
7//!
8//! | # | id | passes when |
9//! |---|----|-------------|
10//! | 1 | `scan` | no config scan is running |
11//! | 2 | `mode` | the normalised `deploymentMode` equals the target's mode |
12//! | 3 | `read_back` | every declared non-tag resource is in the REST effective view with an equal `config` (CV2 table below) |
13//! | 4 | `files` | every declared tag unit's effective live copy is byte-equal to the render, and on a pair no declared unit has a copy in the master's mode collection |
14//! | 5 | `loaded` | no gateway log line since the last scan says `will not be loaded` for a declared resource (CV1) |
15//! | 6 | `probes` | every `verify.tag_probes[]` tag reads quality `Good...` |
16//!
17//! ## Value-free by construction
18//!
19//! A [`Finding`] carries resource keys, JSON paths, tag paths, quality codes,
20//! log timestamps and logger names. A log message is held in a [`LogLine`]
21//! for matching only and is never serialized; a config, a tag value and a
22//! file byte never enter the report. `Debug` of the observed types withholds
23//! the same content.
24//!
25//! ## The read-back table (`22-LIVE-CAPTURES.md`, Decision CV2)
26//!
27//! Equality is JSON value equality (key order ignored; `1` equals `1.0`).
28//!
29//! - **N1** members whose value is JSON `null` are dropped from the file value
30//!   and from the REST value, at every depth, for every type, before
31//!   comparing. A null member therefore equals an absent one on either side.
32//!   Some 8.3.6 types omit null members from REST (`database-translator`) and
33//!   others echo them (`ignition/cobranding`'s unset image members). A null on
34//!   one side against a non-null value on the other still differs, because
35//!   after the drop the key exists on one side only.
36//! - **N2** `ignition/security-levels`: the file must be a name-keyed subset
37//!   of the REST tree (the gateway merges its own levels in, and orders
38//!   differ).
39//! - **N3** a unit with no `config.json` (its `resource.json` has
40//!   `files: []`, `ignition/translations`): the keys of `resource.json`
41//!   `attributes` that the REST `config` also carries must be equal; REST-only
42//!   keys (`terms`) are ignored.
43//! - **skip** a REST item whose effective collection is `system` is
44//!   gateway-provided, not file-backed; the types in [`NO_READ_ROUTE`] answer
45//!   404 on every read route. Both are listed as warnings, never counted as
46//!   checked and never a pass for that resource.
47//!
48//! A false red on a type the rig did not exercise is fixed by adding a row
49//! here, not by loosening equality (Pitfall 6).
50//!
51//! ## Who judges a difference (quick 261031-bbs)
52//!
53//! The table decides whether a resource differs; a [`ReadBackJudge`] decides
54//! whether that difference is red. [`evaluate`] is strict. After an apply,
55//! [`evaluate_with`] and [`ReadBackJudge::Baseline`] judge against the REST
56//! view read before the write ([`ReadBackBaseline`]): only what the run wrote
57//! wrongly or disturbed is red, and a difference that was already there is a
58//! warning: a [`Finding`] with `status: warn` plus a `warnings` line. Paths are
59//! compared as uncapped sets, never through a finding's capped detail.
60//!
61//! ## `loaded` is best effort (CV1)
62//!
63//! The gateway logs `will not be loaded` once, shortly after the scan that
64//! found the resource, and not for every type (a uuid-less
65//! `database-translator` is silent), and the REST view still lists an
66//! unloaded resource. A clean window therefore proves only that the last scan
67//! did not warn. The check says so in its detail, answers `warn` (not
68//! observed) while a scan is running, and `fail` with `logs_unavailable` when
69//! the log source cannot be read or is truncated.
70//!
71//! ## The verdict is not an error
72//!
73//! [`VERIFY_FAILED_SLUG`] lives only in the result's `slug` key. It is not a
74//! `CoreError` variant and never joins `CoreError::DEPLOY_SLUGS` or the exit
75//! table: a red verdict is a successful envelope whose `data.ok` is false
76//! (Decision A).
77
78use std::collections::{BTreeMap, BTreeSet};
79use std::fmt;
80use std::time::Duration;
81
82use serde::Serialize;
83use serde_json::{Map, Value};
84
85use super::layout::TAG_TYPES;
86use super::manifest::Manifest;
87use super::plan::{PlanStatus, unit_key};
88use super::render::RenderOutcome;
89use crate::client::scan::ScanStatus;
90use crate::error::APPLY_LIST_SHOWN;
91
92/// The verdict slug of a red verify. Only ever in `data.slug`.
93pub const VERIFY_FAILED_SLUG: &str = "deploy_verify_failed";
94
95/// How long probes are polled before the verdict (SF-8).
96pub const PROBE_DEADLINE: Duration = Duration::from_secs(30);
97
98/// Time between two probe polls.
99pub const PROBE_INTERVAL: Duration = Duration::from_secs(1);
100
101/// Milliseconds the log window opens before `lastScanTimestamp -
102/// lastScanDuration` (CV1: the entries are stamped after the scan end, so a
103/// full duration plus one second is early under either reading of the field).
104pub const LOG_WINDOW_SLACK_MS: i64 = 1_000;
105
106/// Window used in place of `lastScanDuration` when the gateway omits it.
107pub const LOG_WINDOW_FALLBACK_MS: i64 = 30_000;
108
109/// Milliseconds to wait after a scan completed before an empty log answer
110/// means anything (CV1: the entry became readable 516 to 1450 ms late).
111pub const LOG_SETTLE_MS: i64 = 5_000;
112
113/// The substring of the gateway line (CV1: the REST route returns it verbatim).
114pub const WILL_NOT_BE_LOADED: &str = "will not be loaded";
115
116/// Types whose list and singleton routes both answer 404 on 8.3.6 (CV2): they
117/// cannot be read back and are reported, not compared.
118pub const NO_READ_ROUTE: &[(&str, &str)] = &[
119    ("com.inductiveautomation.opcua", "one-time"),
120    ("ignition", "images"),
121];
122
123/// The collection name of gateway-provided, non-file-backed resources (CV2).
124pub const SYSTEM_COLLECTION: &str = "system";
125
126/// Most differing JSON paths one finding names.
127const MAX_PATHS: usize = 20;
128
129/// The path of a difference at the top of a value.
130const ROOT_PATH: &str = "(root)";
131
132/// Longest logger name or quality code copied into a finding.
133const MAX_CODE_CHARS: usize = 96;
134
135/// The six checks, in report order.
136#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
137#[serde(rename_all = "snake_case")]
138pub enum CheckId {
139    /// No config scan is running.
140    Scan,
141    /// The gateway runs the target's mode.
142    Mode,
143    /// Declared resources read back equal through REST.
144    ReadBack,
145    /// Declared tag units are byte-equal on the host.
146    Files,
147    /// No `will not be loaded` line for a declared resource.
148    Loaded,
149    /// Tag probes read Good.
150    Probes,
151}
152
153impl CheckId {
154    /// The word the check serializes as (`read_back`, not `ReadBack`), for the
155    /// human renderer.
156    pub fn word(self) -> &'static str {
157        match self {
158            Self::Scan => "scan",
159            Self::Mode => "mode",
160            Self::ReadBack => "read_back",
161            Self::Files => "files",
162            Self::Loaded => "loaded",
163            Self::Probes => "probes",
164        }
165    }
166}
167
168/// The outcome of one check. `warn` never fails the verdict.
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
170#[serde(rename_all = "lowercase")]
171pub enum CheckStatus {
172    /// Evaluated and good.
173    Pass,
174    /// Evaluated and bad, or not evaluable.
175    Fail,
176    /// Not a failure, but not a plain pass either.
177    Warn,
178}
179
180impl CheckStatus {
181    /// The word the status serializes as, for the human renderer.
182    pub fn word(self) -> &'static str {
183        match self {
184            Self::Pass => "pass",
185            Self::Fail => "fail",
186            Self::Warn => "warn",
187        }
188    }
189}
190
191/// One value-free observation.
192#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
193pub struct Finding {
194    /// Stable code (`scan_active`, `mode_mismatch`, `resource_missing`,
195    /// `resource_differs`, `files_differ`, `unreadable`, `logs_unavailable`,
196    /// `unloaded_resource`, `tag_probes_absent`, `tag_probes_unavailable`,
197    /// `tag_probe_not_good`).
198    pub code: &'static str,
199    /// What it is about: a resource key, unit key, tag path, `mode`, `logs`.
200    pub subject: String,
201    /// Paths, codes and timestamps only.
202    pub detail: String,
203    /// `Some(Warn)` only on a finding that does not count against its check
204    /// (quick 261031-bbs); `None`, the default, counts, and is not
205    /// serialized, so every envelope without a warn finding is unchanged.
206    #[serde(skip_serializing_if = "Option::is_none")]
207    pub status: Option<CheckStatus>,
208}
209
210/// One check of the report.
211#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
212pub struct Check {
213    /// Which check.
214    pub id: CheckId,
215    /// Its outcome.
216    pub status: CheckStatus,
217    /// One line of what was evaluated.
218    pub detail: String,
219    /// Findings, byte-sorted by subject (probes: manifest order).
220    pub findings: Vec<Finding>,
221}
222
223/// The verify verdict.
224#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
225pub struct VerifyReport {
226    /// True when no check failed.
227    pub ok: bool,
228    /// [`VERIFY_FAILED_SLUG`] when `ok` is false, else null.
229    pub slug: Option<&'static str>,
230    /// All six checks in order.
231    pub checks: Vec<Check>,
232    /// Declared resources compared by `read_back`.
233    pub resources_checked: usize,
234    /// Non-failing notes.
235    pub warnings: Vec<String>,
236}
237
238/// How one declared resource's `config.json` is compared.
239#[derive(Clone, PartialEq)]
240pub enum Compare {
241    /// Equal after N1 (both sides).
242    Exact(Value),
243    /// N2: the file value is a name-keyed subset of the REST value.
244    NameKeyedSubset(Value),
245    /// N3: the `resource.json` attributes the REST config also carries.
246    Attributes(Map<String, Value>),
247}
248
249impl fmt::Debug for Compare {
250    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
251        let kind = match self {
252            Compare::Exact(_) => "Exact",
253            Compare::NameKeyedSubset(_) => "NameKeyedSubset",
254            Compare::Attributes(_) => "Attributes",
255        };
256        write!(f, "{kind}(<withheld>)")
257    }
258}
259
260/// A declared non-tag resource that `read_back` compares.
261#[derive(Debug, Clone, PartialEq)]
262pub struct ExpectedResource {
263    /// `module/type/name`, or `module/type` for a singleton.
264    pub key: String,
265    /// Module id.
266    pub module: String,
267    /// Resource type.
268    pub ty: String,
269    /// Name; `None` for a singleton.
270    pub name: Option<String>,
271    /// What to compare.
272    pub compare: Compare,
273}
274
275impl ExpectedResource {
276    /// A resource compared by exact JSON equality (tests and callers).
277    pub fn exact(module: &str, ty: &str, name: Option<&str>, config: Value) -> Self {
278        Self::new(module, ty, name, Compare::Exact(config))
279    }
280
281    /// A resource with an explicit comparison.
282    pub fn new(module: &str, ty: &str, name: Option<&str>, compare: Compare) -> Self {
283        Self {
284            key: resource_id(module, ty, name),
285            module: module.to_string(),
286            ty: ty.to_string(),
287            name: name.map(str::to_string),
288            compare,
289        }
290    }
291}
292
293/// `module/type/name`, or `module/type` for a singleton.
294pub fn resource_id(module: &str, ty: &str, name: Option<&str>) -> String {
295    match name {
296        Some(name) => format!("{module}/{ty}/{name}"),
297        None => format!("{module}/{ty}"),
298    }
299}
300
301/// What the declared tree says verify should find.
302#[derive(Debug, Clone, PartialEq)]
303pub struct Expected {
304    /// The target's deployment mode.
305    pub mode: String,
306    /// Declared non-tag resources `read_back` compares, in key order.
307    pub resources: Vec<ExpectedResource>,
308    /// Declared resources `read_back` cannot compare, with the reason, in key
309    /// order (no REST read route, or a `config.json` that is not JSON).
310    pub not_checked: Vec<(String, String)>,
311    /// Ids of every declared non-tag resource (compared or not), for the log
312    /// match, in key order.
313    pub declared_ids: Vec<String>,
314    /// Unit keys of the declared tag units, in key order.
315    pub tag_units: Vec<String>,
316    /// `verify.tag_probes[]`, in manifest order.
317    pub probes: Vec<String>,
318}
319
320impl Expected {
321    /// Build from a render outcome and its manifest (deploy-layer types only).
322    pub fn from_render(target_mode: &str, outcome: &RenderOutcome, manifest: &Manifest) -> Self {
323        let files = &outcome.tree.files;
324        let is_tag = |module: &str, ty: &str| module == "ignition" && TAG_TYPES.contains(&ty);
325
326        let mut tag_units = BTreeSet::new();
327        for path in files.keys() {
328            let mut parts = path.components();
329            if let (Some(module), Some(ty)) = (parts.next(), parts.next())
330                && is_tag(module, ty)
331            {
332                tag_units.insert(unit_key(path, files));
333            }
334        }
335
336        let mut resources = BTreeMap::new();
337        let mut not_checked = BTreeMap::new();
338        let mut declared_ids = BTreeSet::new();
339        for res in &outcome.resources {
340            if is_tag(&res.module, &res.ty) {
341                continue;
342            }
343            let key = resource_id(&res.module, &res.ty, res.name.as_deref());
344            declared_ids.insert(key.clone());
345            if NO_READ_ROUTE
346                .iter()
347                .any(|(m, t)| *m == res.module && *t == res.ty)
348            {
349                not_checked.insert(key, "no REST read route".to_string());
350                continue;
351            }
352            let file = |name: &str| {
353                super::layout::RelPath::new(&format!("{key}/{name}"))
354                    .ok()
355                    .and_then(|p| files.get(&p))
356                    .map(Vec::as_slice)
357            };
358            let compare = match compare_for(&res.module, &res.ty, file) {
359                Ok(compare) => compare,
360                Err(reason) => {
361                    not_checked.insert(key, reason.to_string());
362                    continue;
363                }
364            };
365            resources.insert(
366                key.clone(),
367                ExpectedResource {
368                    key,
369                    module: res.module.clone(),
370                    ty: res.ty.clone(),
371                    name: res.name.clone(),
372                    compare,
373                },
374            );
375        }
376
377        Expected {
378            mode: target_mode.to_string(),
379            resources: resources.into_values().collect(),
380            not_checked: not_checked.into_iter().collect(),
381            declared_ids: declared_ids.into_iter().collect(),
382            tag_units: tag_units.into_iter().collect(),
383            probes: manifest
384                .verify
385                .as_ref()
386                .map(|v| v.tag_probes.clone())
387                .unwrap_or_default(),
388        }
389    }
390}
391
392/// How `read_back` compares one declared resource of `module`/`ty`, from its
393/// unit's files (`file` answers a file name such as `config.json`): its
394/// `config.json` as JSON (a name-keyed subset for `ignition/security-levels`,
395/// exact otherwise), else the `attributes` of its `resource.json`. `Err` is
396/// the reason it cannot be compared. Shared by [`Expected::from_render`] (the
397/// declared files) and the apply's live-file basis (quick 261031-bbs), so
398/// both build the comparison the same way.
399pub fn compare_for<'f>(
400    module: &str,
401    ty: &str,
402    file: impl Fn(&str) -> Option<&'f [u8]>,
403) -> Result<Compare, &'static str> {
404    if let Some(bytes) = file("config.json") {
405        match serde_json::from_slice::<Value>(bytes) {
406            Ok(value) if module == "ignition" && ty == "security-levels" => {
407                Ok(Compare::NameKeyedSubset(value))
408            }
409            Ok(value) => Ok(Compare::Exact(value)),
410            Err(_) => Err("config.json is not JSON"),
411        }
412    } else if let Some(attributes) = file("resource.json")
413        .and_then(|bytes| serde_json::from_slice::<Value>(bytes).ok())
414        .and_then(|v| v.get("attributes").and_then(Value::as_object).cloned())
415    {
416        Ok(Compare::Attributes(attributes))
417    } else {
418        Err("no config.json to compare")
419    }
420}
421
422/// What the gateway said about one declared resource.
423#[derive(Clone, PartialEq)]
424pub enum Remote {
425    /// Not in the effective view.
426    Missing,
427    /// In the effective view with this `config`.
428    Config(Value),
429    /// In the effective view, but from the `system` collection.
430    System,
431}
432
433impl fmt::Debug for Remote {
434    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
435        match self {
436            Remote::Missing => f.write_str("Missing"),
437            Remote::Config(_) => f.write_str("Config(<withheld>)"),
438            Remote::System => f.write_str("System"),
439        }
440    }
441}
442
443/// One gateway log entry kept for matching.
444#[derive(Clone, PartialEq, Eq)]
445pub struct LogLine {
446    /// Epoch ms on the gateway clock.
447    pub timestamp: i64,
448    /// Logger name.
449    pub logger: String,
450    /// The message. Matched against, never serialized, never in `Debug`.
451    pub message: String,
452}
453
454impl fmt::Debug for LogLine {
455    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
456        f.debug_struct("LogLine")
457            .field("timestamp", &self.timestamp)
458            .field("logger", &self.logger)
459            .field("message", &"<withheld>")
460            .finish()
461    }
462}
463
464/// One tag probe reading. The value is never carried.
465#[derive(Clone, PartialEq, Eq)]
466pub struct ProbeReading {
467    /// The tag path read.
468    pub path: String,
469    /// The quality string (`Good`, `Bad_NotFound`, ...).
470    pub quality: String,
471}
472
473impl fmt::Debug for ProbeReading {
474    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
475        f.debug_struct("ProbeReading")
476            .field("path", &self.path)
477            .field("quality", &quality_code(&self.quality))
478            .finish()
479    }
480}
481
482/// What the action read.
483#[derive(Debug, Clone)]
484pub struct ObservedState {
485    /// The config scan status (gateway clock).
486    pub scan: ScanStatus,
487    /// The normalised active deployment mode.
488    pub mode: Option<String>,
489    /// Per declared, compared resource key.
490    pub read_back: BTreeMap<String, Remote>,
491    /// Plan status per declared tag unit key.
492    pub files: Vec<(String, PlanStatus)>,
493    /// The log lines since the last scan, or the slug of why they could not
494    /// be read.
495    pub logs: Result<Vec<LogLine>, String>,
496    /// The probe readings, or the slug of why they could not be read; `None`
497    /// when no probe read was made.
498    pub probes: Option<Result<Vec<ProbeReading>, String>>,
499    /// The units with a live path the host's sftp server cannot read
500    /// (quick-261029-ms3): unit key and whether it is declared. Never
501    /// treated as absent: a declared one is a red `unreadable` finding, an
502    /// undeclared one a warning.
503    pub unreadable: Vec<(String, bool)>,
504    /// A redundant pair's declared tag units that also have files in the
505    /// master's mode collection (quick 261103-ell, F2): unit key to those
506    /// mode paths, byte-sorted. Filled only on a pair; empty on a standalone
507    /// target.
508    pub mode_copies: BTreeMap<String, Vec<String>>,
509}
510
511/// The start of the log window for the last scan (CV1), epoch ms on the
512/// gateway clock.
513pub fn log_window_start(scan: &ScanStatus) -> i64 {
514    let duration = scan.last_scan_duration.unwrap_or(LOG_WINDOW_FALLBACK_MS);
515    (scan.last_scan_timestamp - duration - LOG_WINDOW_SLACK_MS).max(0)
516}
517
518/// How `read_back` judges a difference between a declared file and the REST
519/// effective view (quick 261031-bbs).
520#[derive(Debug, Clone, Copy)]
521pub enum ReadBackJudge<'a> {
522    /// Every difference is red (the CV2 table as is).
523    Strict,
524    /// Standalone `deploy verify`: a difference on a resource whose plan
525    /// status is `same` is a warning.
526    Standalone {
527        /// Plan status per declared non-tag resource key.
528        file_status: &'a BTreeMap<String, PlanStatus>,
529    },
530    /// After an apply: judged against the REST view read before the write.
531    Baseline(&'a ReadBackBaseline),
532}
533
534/// What a resource's pre-apply REST view was measured against.
535#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
536#[serde(rename_all = "snake_case")]
537pub enum BaselineBasis {
538    /// Not written by the run: its declared file (equal to the live one).
539    DeclaredFile,
540    /// Written (`changed`): the live file before the apply.
541    LiveFile,
542    /// Judged as [`ReadBackJudge::Strict`] does.
543    Strict,
544}
545
546/// How the run writes a declared resource.
547#[derive(Debug, Clone, PartialEq)]
548pub enum Written {
549    /// New: no live copy before the apply.
550    Added,
551    /// Changed: the comparison built from the pre-apply live file, when one
552    /// could be built.
553    Changed(Option<Compare>),
554}
555
556/// One resource of a [`ReadBackBaseline`].
557#[derive(Debug, Clone, PartialEq)]
558pub struct BaselineEntry {
559    basis: BaselineBasis,
560    before: Remote,
561    differs_at: BTreeSet<String>,
562    writes_at: BTreeSet<String>,
563    /// The written paths at which the pre-apply REST view already showed the
564    /// desired value: the gateway shows what the write asks for, so no move
565    /// is expected there.
566    shown_at: BTreeSet<String>,
567}
568
569impl BaselineEntry {
570    /// What the pre-apply view was measured against.
571    pub fn basis(&self) -> BaselineBasis {
572        self.basis
573    }
574
575    /// The uncapped paths at which the pre-apply view differed from the
576    /// basis.
577    pub fn differs_at(&self) -> &BTreeSet<String> {
578        &self.differs_at
579    }
580
581    /// The uncapped paths the run's write changes: where the desired file
582    /// differs from the pre-apply live file ([`BaselineBasis::LiveFile`]
583    /// only; empty otherwise).
584    pub fn writes_at(&self) -> &BTreeSet<String> {
585        &self.writes_at
586    }
587
588    /// The written paths REST cannot show: absent, with every ancestor and
589    /// descendant, from both the pre-apply view `r0` and the post-apply view
590    /// `r1`, or the whole view has no config (`null`). A write there cannot
591    /// be verified through REST.
592    fn unobservable(&self, r0: &Value, r1: &Value) -> BTreeSet<String> {
593        self.writes_at
594            .iter()
595            .filter(|w| !shows(r0, "", w) && !shows(r1, "", w))
596            .cloned()
597            .collect()
598    }
599}
600
601/// The REST effective view of every declared, compared resource of one
602/// node, read before an apply writes anything. `Debug` withholds every value.
603#[derive(Debug, Clone, PartialEq)]
604pub struct ReadBackBaseline {
605    node: String,
606    resources: BTreeMap<String, BaselineEntry>,
607}
608
609impl ReadBackBaseline {
610    /// The baseline of `node` from its pre-apply read `before`. A key in
611    /// `written` is one the run writes. Per declared, compared resource:
612    ///
613    /// - not written: [`BaselineBasis::DeclaredFile`], its pre-apply REST
614    ///   view measured against the declared file (a `same` unit's live file
615    ///   is the declared file);
616    /// - written `changed` with a comparison built from the pre-apply live
617    ///   file, and a pre-apply REST `config`: [`BaselineBasis::LiveFile`],
618    ///   measured against that live file. Measured against the desired file
619    ///   instead, every path the apply changes would read as pre-existing, and
620    ///   a write that never took effect would go green. The entry also keeps
621    ///   the paths the write changes (the desired file against the live one),
622    ///   so each written path can be judged on its own, and those of them at
623    ///   which the pre-apply REST view already showed the desired value;
624    /// - anything else (`added`, a `changed` unit with no live comparison, a
625    ///   pre-apply view that is missing or `system`): [`BaselineBasis::Strict`].
626    ///
627    /// The path sets are uncapped: never derived from a finding's detail,
628    /// which names at most 20.
629    pub fn build(
630        node: &str,
631        expected: &Expected,
632        before: &BTreeMap<String, Remote>,
633        written: &BTreeMap<String, Written>,
634    ) -> ReadBackBaseline {
635        let against = |remote: &Remote, compare: &Compare| match remote {
636            Remote::Config(rest) => read_back_paths(compare, rest),
637            Remote::Missing | Remote::System => BTreeSet::new(),
638        };
639        let resources = expected
640            .resources
641            .iter()
642            .map(|res| {
643                let r0 = before.get(&res.key).cloned().unwrap_or(Remote::Missing);
644                let (basis, differs_at, writes_at, shown_at) = match (written.get(&res.key), &r0) {
645                    (None, _) => (
646                        BaselineBasis::DeclaredFile,
647                        against(&r0, &res.compare),
648                        BTreeSet::new(),
649                        BTreeSet::new(),
650                    ),
651                    (Some(Written::Changed(Some(live))), Remote::Config(rest)) => {
652                        let writes_at = write_paths(&res.compare, live);
653                        let shown_at = already_shown(&writes_at, &res.compare, rest);
654                        (
655                            BaselineBasis::LiveFile,
656                            read_back_paths(live, rest),
657                            writes_at,
658                            shown_at,
659                        )
660                    }
661                    (Some(_), _) => (
662                        BaselineBasis::Strict,
663                        against(&r0, &res.compare),
664                        BTreeSet::new(),
665                        BTreeSet::new(),
666                    ),
667                };
668                (
669                    res.key.clone(),
670                    BaselineEntry {
671                        basis,
672                        before: r0,
673                        differs_at,
674                        writes_at,
675                        shown_at,
676                    },
677                )
678            })
679            .collect();
680        ReadBackBaseline {
681            node: node.to_string(),
682            resources,
683        }
684    }
685
686    /// The node it was read from.
687    pub fn node(&self) -> &str {
688        &self.node
689    }
690
691    /// The entry of `key`, when it was declared.
692    pub fn entry(&self, key: &str) -> Option<&BaselineEntry> {
693        self.resources.get(key)
694    }
695
696    /// The value-free record of this baseline, for `read_back_baseline.json`.
697    /// Per resource: the state of its pre-apply view, the basis and the
698    /// uncapped, byte-sorted paths (where it differed, and what the run
699    /// writes). Never a value and no hash of one (R5, IN-01): a hash of the
700    /// REST view, with the repo's file beside it, would let anyone holding
701    /// the run directory test guesses for a low-entropy field offline. The
702    /// judge compares the in-memory view instead.
703    pub fn record(&self) -> BaselineRecord {
704        let resources = self
705            .resources
706            .iter()
707            .map(|(key, entry)| {
708                let state = match &entry.before {
709                    Remote::Config(_) => "config",
710                    Remote::Missing => "missing",
711                    Remote::System => "system",
712                };
713                (
714                    key.clone(),
715                    BaselineRecordEntry {
716                        state,
717                        basis: entry.basis,
718                        differs_at: entry.differs_at.iter().cloned().collect(),
719                        writes_at: entry.writes_at.iter().cloned().collect(),
720                    },
721                )
722            })
723            .collect();
724        BaselineRecord {
725            node: self.node.clone(),
726            resources,
727        }
728    }
729}
730
731/// The value-free record of one node's [`ReadBackBaseline`]
732/// (`read_back_baseline.json`, quick 261031-bbs P5).
733#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
734pub struct BaselineRecord {
735    /// `master` or `backup`.
736    pub node: String,
737    /// Per declared, compared resource key.
738    pub resources: BTreeMap<String, BaselineRecordEntry>,
739}
740
741/// One resource of a [`BaselineRecord`]: never a value.
742#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
743pub struct BaselineRecordEntry {
744    /// `config`, `missing` or `system`.
745    pub state: &'static str,
746    /// What the pre-apply view was measured against.
747    pub basis: BaselineBasis,
748    /// The uncapped, byte-sorted paths at which it differed.
749    pub differs_at: Vec<String>,
750    /// The uncapped, byte-sorted paths the run's write changes
751    /// ([`BaselineBasis::LiveFile`] only; empty otherwise). Paths only, never
752    /// the old or the new value.
753    pub writes_at: Vec<String>,
754}
755
756/// Whether `path` is covered by `by`: `by` holds `(root)`, the path itself,
757/// or an ancestor of it (the next character after the ancestor is `.` or
758/// `[`).
759pub fn path_covered(path: &str, by: &BTreeSet<String>) -> bool {
760    by.iter().any(|ancestor| path_under(path, ancestor))
761}
762
763/// Whether `path` is `ancestor`, lies under it (the next character after it
764/// is `.` or `[`), or `ancestor` is `(root)`.
765fn path_under(path: &str, ancestor: &str) -> bool {
766    ancestor == ROOT_PATH
767        || path == ancestor
768        || (path.len() > ancestor.len()
769            && path.starts_with(ancestor)
770            && matches!(path.as_bytes()[ancestor.len()], b'.' | b'['))
771}
772
773/// Whether two paths name overlapping parts of a document: equal, or one is
774/// an ancestor of the other (`(root)` overlaps everything).
775fn paths_overlap(a: &str, b: &str) -> bool {
776    path_under(a, b) || path_under(b, a)
777}
778
779/// Whether the REST value `rest`, found at path `at` (`""` for the root),
780/// shows anything at `target`, which is `at` or lies under it: the value at
781/// `target` itself (and so its descendants), or an ancestor the gateway
782/// reports as one opaque value (a string standing for a structured member).
783/// A `null`, like an absent member, shows nothing (N1). Paths are built with
784/// [`child_path`], as the diffs build them.
785fn shows(rest: &Value, at: &str, target: &str) -> bool {
786    if rest.is_null() {
787        return false;
788    }
789    if target == ROOT_PATH || at == target {
790        return true;
791    }
792    let below =
793        |child: &str, value: &Value| path_under(target, child) && shows(value, child, target);
794    match rest {
795        Value::Object(map) => map
796            .iter()
797            .any(|(key, value)| below(&child_path(at, key), value)),
798        Value::Array(items) => items
799            .iter()
800            .enumerate()
801            .any(|(i, value)| below(&child_path(at, &i.to_string()), value)),
802        _ => true,
803    }
804}
805
806/// Evaluate `observed` against `expected`.
807pub fn evaluate(expected: &Expected, observed: &ObservedState) -> VerifyReport {
808    evaluate_with(expected, observed, &ReadBackJudge::Strict)
809}
810
811/// [`evaluate`], with `read_back` judged by `judge`.
812pub fn evaluate_with(
813    expected: &Expected,
814    observed: &ObservedState,
815    judge: &ReadBackJudge<'_>,
816) -> VerifyReport {
817    let mut warnings = Vec::new();
818    let (read_back, resources_checked) =
819        read_back_check_with(expected, &observed.read_back, judge, &mut warnings);
820    let checks = vec![
821        check_scan(observed),
822        check_mode(expected, observed),
823        read_back,
824        check_files(expected, observed, &mut warnings),
825        check_loaded(expected, observed),
826        check_probes(expected, observed),
827    ];
828    for check in &checks {
829        if check.status == CheckStatus::Warn {
830            warnings.push(format!("{}: {}", check_word(check.id), check.detail));
831        }
832    }
833    let ok = checks.iter().all(|c| c.status != CheckStatus::Fail);
834    VerifyReport {
835        ok,
836        slug: (!ok).then_some(VERIFY_FAILED_SLUG),
837        checks,
838        resources_checked,
839        warnings,
840    }
841}
842
843fn check_word(id: CheckId) -> &'static str {
844    match id {
845        CheckId::Scan => "scan",
846        CheckId::Mode => "mode",
847        CheckId::ReadBack => "read_back",
848        CheckId::Files => "files",
849        CheckId::Loaded => "loaded",
850        CheckId::Probes => "probes",
851    }
852}
853
854fn finding(code: &'static str, subject: impl Into<String>, detail: impl Into<String>) -> Finding {
855    Finding {
856        code,
857        subject: subject.into(),
858        detail: detail.into(),
859        status: None,
860    }
861}
862
863/// Sort findings by subject bytes, then code, so the report does not depend
864/// on input order.
865fn sort_findings(findings: &mut [Finding]) {
866    findings.sort_by(|a, b| {
867        a.subject
868            .as_bytes()
869            .cmp(b.subject.as_bytes())
870            .then_with(|| a.code.cmp(b.code))
871    });
872}
873
874fn finished(id: CheckId, detail: String, findings: Vec<Finding>) -> Check {
875    Check {
876        id,
877        status: if findings.is_empty() {
878            CheckStatus::Pass
879        } else {
880            CheckStatus::Fail
881        },
882        detail,
883        findings,
884    }
885}
886
887// ---- 1. scan ------------------------------------------------------------------
888
889fn check_scan(observed: &ObservedState) -> Check {
890    if observed.scan.scan_active {
891        return finished(
892            CheckId::Scan,
893            "a config scan is running".to_string(),
894            vec![finding(
895                "scan_active",
896                "config",
897                "the gateway reports an active config scan; verify reads settled state only",
898            )],
899        );
900    }
901    finished(
902        CheckId::Scan,
903        "no config scan is running".to_string(),
904        Vec::new(),
905    )
906}
907
908// ---- 2. mode ------------------------------------------------------------------
909
910fn check_mode(expected: &Expected, observed: &ObservedState) -> Check {
911    mode_check(expected, observed.mode.as_deref())
912}
913
914/// The `mode` check over an already-read, normalised mode. The backup node's
915/// check shares it with the master's (24-05).
916pub(crate) fn mode_check(expected: &Expected, mode: Option<&str>) -> Check {
917    if mode == Some(expected.mode.as_str()) {
918        return finished(
919            CheckId::Mode,
920            format!("the gateway runs mode `{}`", expected.mode),
921            Vec::new(),
922        );
923    }
924    let seen = match mode {
925        Some(mode) => format!("`{}`", printable(mode)),
926        None => "no mode".to_string(),
927    };
928    finished(
929        CheckId::Mode,
930        "the gateway does not run the target's mode".to_string(),
931        vec![finding(
932            "mode_mismatch",
933            "mode",
934            format!("expected `{}`, the gateway reports {seen}", expected.mode),
935        )],
936    )
937}
938
939// ---- 3. read_back -------------------------------------------------------------
940
941/// The `read_back` check judged by `judge` (quick 261031-bbs).
942///
943/// [`ReadBackJudge::Strict`] is the CV2 table: every difference and every
944/// missing resource is red. [`ReadBackJudge::Standalone`] turns the
945/// difference or absence of a resource whose plan status is `same` into a
946/// warning (the live files equal the repo; only the gateway's REST view
947/// differs); any other status, or none, stays red. [`ReadBackJudge::Baseline`]
948/// judges each resource against the REST view read before the apply wrote
949/// anything:
950///
951/// - not written ([`BaselineBasis::DeclaredFile`]): red only when its REST
952///   view moved since the baseline AND still differs from its file, or when
953///   it disappeared; a difference or absence that was already there is a
954///   warning, and a move toward the file passes;
955/// - written `changed` ([`BaselineBasis::LiveFile`]): red when the post-apply
956///   difference holds a path not covered by the pre-apply difference from
957///   the live file (an ancestor covers its descendants, `(root)` covers
958///   everything); when the desired file differs from the live one but the
959///   REST view did not move at all (the write did not take effect,
960///   amendment A1); or, per written path, when the post-apply difference
961///   still overlaps a path the write changes (that path, an ancestor or a
962///   descendant) and the REST view did not move anywhere overlapping that
963///   path (that part of the write did not take effect, CR-01); otherwise a
964///   warning. A written path REST cannot show (absent, with its ancestors
965///   and descendants, from both views, or a view with no config at all;
966///   an explicit default REST leaves out is one) is left out of the
967///   new-path rule, A1 and the per-path rule and reported as its own warning:
968///   `<path> is not visible in the gateway's REST view, so the write could
969///   not be verified` (production: OPC UA device settings, historian
970///   profiles, Cirrus Link namespace sets). A written path at which the
971///   pre-apply REST view already showed the desired value is left out of
972///   A1 and the per-path rule too: no move is expected there;
973/// - everything else ([`BaselineBasis::Strict`], or no baseline entry): as
974///   strict;
975/// - an effective copy in the `system` collection after the apply: red
976///   `resource_missing` for a unit the run wrote (the written file is not
977///   what the gateway runs, so the write did not take effect) and for an
978///   unwritten one that was file-backed before (it dropped out during the
979///   apply); otherwise, and under the other judges, it is not compared and
980///   gets the warning line, as before (WR-01).
981///
982/// A warning is a finding with `status: Some(Warn)` plus one `warnings` line
983/// naming the key and its paths; the check is `fail` when any finding has
984/// no warn status, `warn` when only warn findings are left, `pass` otherwise.
985///
986/// Residual risks, accepted and documented: a written path whose REST view
987/// moved during the apply, but to a value that still differs from the file in
988/// a pre-existing way, is covered (REST moved there, so the write reached it);
989/// a written path the gateway normalises back to the value it already showed
990/// reads as not taken (a false red); an operator change, or a pending
991/// unscanned edit, between the baseline and the post-scan read reads as this
992/// run's disturbance (a false red that rolls back); an `added` unit is judged
993/// strictly, so a hand-written minimal `config.json` the gateway fills with
994/// defaults rolls back (`deploy pull` writes the gateway's own shape);
995/// written members collapse into one `writes_at` path where a whole subtree
996/// differs (a key the live file lacks, an array length change, a member REST
997/// reports as one opaque value), so a REST move anywhere under it covers a
998/// member that did not land (a false green; collapse-aware path sets are a
999/// follow-up, IN-03); removing a member set to a default REST keeps
1000/// reporting, or reordering a list REST reports in its own order, trips the
1001/// new-path rule (a false red).
1002pub(crate) fn read_back_check_with(
1003    expected: &Expected,
1004    read_back: &BTreeMap<String, Remote>,
1005    judge: &ReadBackJudge<'_>,
1006    warnings: &mut Vec<String>,
1007) -> (Check, usize) {
1008    let mut findings = Vec::new();
1009    let mut checked = 0usize;
1010    let mut warned = 0usize;
1011    for res in &expected.resources {
1012        let after = read_back.get(&res.key).unwrap_or(&Remote::Missing);
1013        let paths = match after {
1014            Remote::System => {
1015                match system_red(&res.key, judge) {
1016                    Some(f) => {
1017                        checked += 1;
1018                        findings.push(f);
1019                    }
1020                    None => warnings.push(format!(
1021                        "read_back: {} not compared: the effective copy is in the `{SYSTEM_COLLECTION}` collection (gateway-provided, not file-backed)",
1022                        res.key
1023                    )),
1024                }
1025                continue;
1026            }
1027            Remote::Missing => None,
1028            Remote::Config(rest) => Some(read_back_paths(&res.compare, rest)),
1029        };
1030        checked += 1;
1031        for verdict in judge_resource(&res.key, after, paths.as_ref(), judge) {
1032            match verdict {
1033                Verdict::Pass => {}
1034                Verdict::Red(f) => findings.push(f),
1035                Verdict::Warn(mut f, line) => {
1036                    f.status = Some(CheckStatus::Warn);
1037                    findings.push(f);
1038                    warnings.push(line);
1039                    warned += 1;
1040                }
1041            }
1042        }
1043    }
1044    for (key, reason) in &expected.not_checked {
1045        warnings.push(format!("read_back: {key} not compared: {reason}"));
1046    }
1047    if checked == 0 && findings.is_empty() {
1048        warnings.push(
1049            "read_back: no declared non-tag resource was compared (nothing to read back)"
1050                .to_string(),
1051        );
1052    }
1053    sort_findings(&mut findings);
1054    let mut detail =
1055        format!("{checked} declared resource(s) compared with the REST effective view");
1056    let status = if findings.iter().any(|f| f.status.is_none()) {
1057        CheckStatus::Fail
1058    } else if findings.is_empty() {
1059        CheckStatus::Pass
1060    } else {
1061        detail.push_str(&format!("; {warned} difference(s) reported as warnings"));
1062        CheckStatus::Warn
1063    };
1064    (
1065        Check {
1066            id: CheckId::ReadBack,
1067            status,
1068            detail,
1069            findings,
1070        },
1071        checked,
1072    )
1073}
1074
1075/// The `read_back` check over already-read resources, judged strictly. The
1076/// backup node's check shares it with the master's (24-05).
1077pub(crate) fn read_back_check(
1078    expected: &Expected,
1079    read_back: &BTreeMap<String, Remote>,
1080    warnings: &mut Vec<String>,
1081) -> (Check, usize) {
1082    read_back_check_with(expected, read_back, &ReadBackJudge::Strict, warnings)
1083}
1084
1085/// The red finding for a resource whose effective copy is in the `system`
1086/// collection after an apply, judged against its baseline entry (WR-01).
1087/// `None` keeps today's "not compared" warning: under the strict and
1088/// standalone judges, with no baseline entry, or for an unwritten resource
1089/// that was not file-backed before either.
1090fn system_red(key: &str, judge: &ReadBackJudge<'_>) -> Option<Finding> {
1091    let ReadBackJudge::Baseline(baseline) = judge else {
1092        return None;
1093    };
1094    let entry = baseline.entry(key)?;
1095    let why = match (entry.basis, &entry.before) {
1096        (BaselineBasis::DeclaredFile, Remote::Config(_)) => {
1097            "it was file-backed before this apply and dropped out of the effective view during this apply"
1098        }
1099        (BaselineBasis::DeclaredFile, _) => return None,
1100        (BaselineBasis::LiveFile | BaselineBasis::Strict, _) => {
1101            "this apply wrote it, so the write did not take effect"
1102        }
1103    };
1104    Some(finding(
1105        "resource_missing",
1106        key,
1107        format!(
1108            "{MISSING_DETAIL} as a file-backed copy: the effective copy is in the `{SYSTEM_COLLECTION}` collection ({why})"
1109        ),
1110    ))
1111}
1112
1113/// One verdict on a compared resource.
1114enum Verdict {
1115    Pass,
1116    Red(Finding),
1117    /// The finding and the `warnings` line.
1118    Warn(Finding, String),
1119}
1120
1121/// The detail of a missing resource.
1122const MISSING_DETAIL: &str = "not in the gateway's effective view";
1123
1124/// The end of the detail of a written path REST does not show.
1125const UNOBSERVABLE_DETAIL: &str =
1126    "not visible in the gateway's REST view, so the write could not be verified";
1127
1128/// The warning for a difference at `p` that `why` explains, or a pass when
1129/// `p` is empty.
1130fn warn_differs(key: &str, p: &BTreeSet<String>, why: &str) -> Verdict {
1131    if p.is_empty() {
1132        return Verdict::Pass;
1133    }
1134    let at = join_set(p);
1135    Verdict::Warn(
1136        finding("resource_differs", key, format!("differs at: {at} ({why})")),
1137        format!("read_back: {key} differs from its file at: {at} ({why})"),
1138    )
1139}
1140
1141/// The warning for the written paths `unseen` REST does not show, or a pass
1142/// when there are none.
1143fn warn_unobservable(key: &str, unseen: &BTreeSet<String>) -> Verdict {
1144    if unseen.is_empty() {
1145        return Verdict::Pass;
1146    }
1147    let verb = if unseen.len() == 1 { "is" } else { "are" };
1148    let detail = format!("{} {verb} {UNOBSERVABLE_DETAIL}", join_set(unseen));
1149    Verdict::Warn(
1150        finding("resource_differs", key, detail.clone()),
1151        format!("read_back: {key}: {detail}"),
1152    )
1153}
1154
1155/// Judge one compared resource: `after` is its post read (never `System`),
1156/// `paths` its uncapped difference from the declared file (`None` when it is
1157/// missing). A written unit with paths REST cannot show gets a warning for
1158/// those beside its verdict on the rest.
1159fn judge_resource(
1160    key: &str,
1161    after: &Remote,
1162    paths: Option<&BTreeSet<String>>,
1163    judge: &ReadBackJudge<'_>,
1164) -> Vec<Verdict> {
1165    let strict = || match paths {
1166        None => Verdict::Red(finding("resource_missing", key, MISSING_DETAIL)),
1167        Some(p) if p.is_empty() => Verdict::Pass,
1168        Some(p) => Verdict::Red(finding(
1169            "resource_differs",
1170            key,
1171            format!("differs at: {}", join_set(p)),
1172        )),
1173    };
1174    let warn = |why: &str| match paths {
1175        None => Verdict::Warn(
1176            finding("resource_missing", key, format!("{MISSING_DETAIL} ({why})")),
1177            format!("read_back: {key} is {MISSING_DETAIL} ({why})"),
1178        ),
1179        Some(p) => warn_differs(key, p, why),
1180    };
1181    match judge {
1182        ReadBackJudge::Strict => vec![strict()],
1183        ReadBackJudge::Standalone { file_status } => {
1184            vec![if file_status.get(key) == Some(&PlanStatus::Same) {
1185                match paths {
1186                    None => warn("the live files equal the repo"),
1187                    Some(_) => {
1188                        warn("the live files equal the repo; the gateway's REST view differs")
1189                    }
1190                }
1191            } else {
1192                strict()
1193            }]
1194        }
1195        ReadBackJudge::Baseline(baseline) => {
1196            let Some(entry) = baseline.entry(key) else {
1197                return vec![strict()];
1198            };
1199            let Some(p) = paths else {
1200                // Missing now: a warning only when it was missing before too
1201                // and the run did not write it.
1202                return vec![match (entry.basis, &entry.before) {
1203                    (BaselineBasis::DeclaredFile, Remote::Missing) => {
1204                        warn("it was not there before this apply either")
1205                    }
1206                    _ => strict(),
1207                }];
1208            };
1209            judge_baseline(key, entry, after, p)
1210        }
1211    }
1212}
1213
1214/// [`judge_resource`] under [`ReadBackJudge::Baseline`] for a resource
1215/// present after the apply, whose difference from its declared file is `p`.
1216fn judge_baseline(
1217    key: &str,
1218    entry: &BaselineEntry,
1219    after: &Remote,
1220    p: &BTreeSet<String>,
1221) -> Vec<Verdict> {
1222    let red = |detail: String| Verdict::Red(finding("resource_differs", key, detail));
1223    // UNOBSERVABLE: written paths REST shows neither before nor after are
1224    // reported, and left out of the new-path rule, A1 and the per-path rule
1225    // below.
1226    let unseen = match (entry.basis, &entry.before, after) {
1227        (BaselineBasis::LiveFile, Remote::Config(r0), Remote::Config(r1)) => {
1228            entry.unobservable(r0, r1)
1229        }
1230        _ => BTreeSet::new(),
1231    };
1232    let mut out = vec![warn_unobservable(key, &unseen)];
1233    if p.is_empty() {
1234        return out;
1235    }
1236    out.push(match (entry.basis, &entry.before, after) {
1237        (BaselineBasis::DeclaredFile, Remote::Config(r0), Remote::Config(r1)) => {
1238            let moved = rest_moved(r0, r1);
1239            if moved.is_empty() {
1240                warn_differs(key, p, "already different before this apply")
1241            } else {
1242                red(format!(
1243                    "differs at: {}; its REST view changed during this apply at: {}",
1244                    join_set(p),
1245                    join_set(&moved)
1246                ))
1247            }
1248        }
1249        (BaselineBasis::DeclaredFile, _, _) => red(format!(
1250            "differs at: {}; its REST view changed during this apply at: {ROOT_PATH}",
1251            join_set(p)
1252        )),
1253        (BaselineBasis::LiveFile, Remote::Config(r0), Remote::Config(r1)) => {
1254            judge_written(key, entry, r0, r1, p, &unseen)
1255        }
1256        _ => red(format!("differs at: {}", join_set(p))),
1257    });
1258    out
1259}
1260
1261/// A written `changed` unit on the live-file basis, read as `r0` before and
1262/// `r1` after the apply, whose difference from its desired file is the
1263/// non-empty `p`. `unseen` holds the written paths REST cannot show, which
1264/// are left out of the new-path rule, A1 and the per-path rule.
1265fn judge_written(
1266    key: &str,
1267    entry: &BaselineEntry,
1268    r0: &Value,
1269    r1: &Value,
1270    p: &BTreeSet<String>,
1271    unseen: &BTreeSet<String>,
1272) -> Verdict {
1273    let red = |detail: String| Verdict::Red(finding("resource_differs", key, detail));
1274    // A path REST cannot show (an explicit default it omits, an added member
1275    // the gateway drops) is reported only by the not-visible warning, so it
1276    // is left out of the new-path rule too.
1277    let new: BTreeSet<String> = p
1278        .iter()
1279        .filter(|path| !path_covered(path, &entry.differs_at))
1280        .filter(|path| !unseen.iter().any(|u| paths_overlap(path, u)))
1281        .cloned()
1282        .collect();
1283    if !new.is_empty() {
1284        return red(format!(
1285            "differs at: {} (not different before this apply)",
1286            join_set(&new)
1287        ));
1288    }
1289    let moved = rest_moved(r0, r1);
1290    // ALREADY-SHOWN: REST showed the desired value there before the apply.
1291    let judged: BTreeSet<&String> = entry
1292        .writes_at
1293        .iter()
1294        .filter(|w| !unseen.contains(*w) && !entry.shown_at.contains(*w))
1295        .collect();
1296    if !judged.is_empty() && moved.is_empty() {
1297        // A1: the whole view stood still.
1298        return red(format!(
1299            "differs at: {} (its REST view did not change during this apply, so the write did not take effect)",
1300            join_set(p)
1301        ));
1302    }
1303    // CR-01: per written path, a difference still there and no REST movement
1304    // there.
1305    let stuck: BTreeSet<String> = judged
1306        .into_iter()
1307        .filter(|w| p.iter().any(|q| paths_overlap(q, w)))
1308        .filter(|w| !moved.iter().any(|m| paths_overlap(m, w)))
1309        .cloned()
1310        .collect();
1311    if !stuck.is_empty() {
1312        return red(format!(
1313            "differs at: {} (its REST view did not change there during this apply, so that part of the write did not take effect)",
1314            join_set(&stuck)
1315        ));
1316    }
1317    // What is left once the unobservable writes are accounted for.
1318    let left: BTreeSet<String> = p
1319        .iter()
1320        .filter(|q| !unseen.iter().any(|u| paths_overlap(q, u)))
1321        .cloned()
1322        .collect();
1323    warn_differs(key, &left, "already different before this apply")
1324}
1325
1326/// The uncapped, deduplicated paths at which `rest` differs from `compare`
1327/// (N1 to N3, unchanged).
1328fn read_back_paths(compare: &Compare, rest: &Value) -> BTreeSet<String> {
1329    let mut paths = Vec::new();
1330    match compare {
1331        Compare::Exact(file) => {
1332            diff_exact(&strip_nulls(file), &strip_nulls(rest), "", &mut paths);
1333        }
1334        Compare::NameKeyedSubset(file) => {
1335            diff_subset(&strip_nulls(file), &strip_nulls(rest), "", &mut paths);
1336        }
1337        // the key lookup needs the RAW map (see `diff_attributes`)
1338        Compare::Attributes(attrs) => diff_attributes(attrs, rest, &mut paths),
1339    }
1340    paths.into_iter().collect()
1341}
1342
1343/// The paths at which two REST views differ, nulls stripped.
1344fn rest_moved(before: &Value, after: &Value) -> BTreeSet<String> {
1345    let mut paths = Vec::new();
1346    diff_exact(&strip_nulls(before), &strip_nulls(after), "", &mut paths);
1347    paths.into_iter().collect()
1348}
1349
1350/// The uncapped paths at which a written unit's desired comparison differs
1351/// from its pre-apply live one (what the write changes), nulls stripped, by
1352/// [`diff_exact`] on the two documents. Comparisons of different kinds differ
1353/// at the root.
1354fn write_paths(desired: &Compare, live: &Compare) -> BTreeSet<String> {
1355    let ((kd, vd), (kl, vl)) = (compare_doc(desired), compare_doc(live));
1356    if kd != kl {
1357        return BTreeSet::from([ROOT_PATH.to_string()]);
1358    }
1359    let mut paths = Vec::new();
1360    diff_exact(&vd, &vl, "", &mut paths);
1361    paths.into_iter().collect()
1362}
1363
1364/// A comparison's kind and its document, nulls stripped.
1365fn compare_doc(c: &Compare) -> (u8, Value) {
1366    match c {
1367        Compare::Exact(v) => (0, strip_nulls(v)),
1368        Compare::NameKeyedSubset(v) => (1, strip_nulls(v)),
1369        Compare::Attributes(m) => (2, strip_nulls(&Value::Object(m.clone()))),
1370    }
1371}
1372
1373/// The written paths at which the pre-apply REST view `r0` resolves to a
1374/// value equal to the desired comparison's value there (nulls stripped).
1375fn already_shown(writes_at: &BTreeSet<String>, desired: &Compare, r0: &Value) -> BTreeSet<String> {
1376    let (_, doc) = compare_doc(desired);
1377    let r0 = strip_nulls(r0);
1378    writes_at
1379        .iter()
1380        .filter(|w| match (resolve(&r0, "", w), resolve(&doc, "", w)) {
1381            (Some(seen), Some(wanted)) => {
1382                let mut out = Vec::new();
1383                diff_exact(wanted, seen, "", &mut out);
1384                out.is_empty()
1385            }
1386            _ => false,
1387        })
1388        .cloned()
1389        .collect()
1390}
1391
1392/// The value at `target` in `value`, found at path `at` (`""` for the
1393/// root), when the path resolves; `None` when a member or index on the way
1394/// is absent or an ancestor is not a container. Paths are built with
1395/// [`child_path`], as the diffs build them.
1396fn resolve<'v>(value: &'v Value, at: &str, target: &str) -> Option<&'v Value> {
1397    if target == ROOT_PATH || at == target {
1398        return Some(value);
1399    }
1400    let below = |child: String, v: &'v Value| {
1401        path_under(target, &child)
1402            .then(|| resolve(v, &child, target))
1403            .flatten()
1404    };
1405    match value {
1406        Value::Object(map) => map
1407            .iter()
1408            .find_map(|(key, v)| below(child_path(at, key), v)),
1409        Value::Array(items) => items
1410            .iter()
1411            .enumerate()
1412            .find_map(|(i, v)| below(child_path(at, &i.to_string()), v)),
1413        _ => None,
1414    }
1415}
1416
1417fn join_set(paths: &BTreeSet<String>) -> String {
1418    join_paths(paths.iter().cloned().collect())
1419}
1420
1421/// `value` with every JSON `null` object member removed, at every depth (N1).
1422/// Applied to the file value and to the REST value alike, so a null member
1423/// equals an absent one on either side.
1424fn strip_nulls(value: &Value) -> Value {
1425    match value {
1426        Value::Object(map) => Value::Object(
1427            map.iter()
1428                .filter(|(_, v)| !v.is_null())
1429                .map(|(k, v)| (k.clone(), strip_nulls(v)))
1430                .collect(),
1431        ),
1432        Value::Array(items) => Value::Array(items.iter().map(strip_nulls).collect()),
1433        other => other.clone(),
1434    }
1435}
1436
1437fn child_path(path: &str, key: &str) -> String {
1438    // the key may be gateway-supplied (REST body): no control characters and
1439    // a bounded length, like every other gateway string in a finding
1440    let key = printable(key);
1441    if path.is_empty() {
1442        key
1443    } else {
1444        format!("{path}.{key}")
1445    }
1446}
1447
1448fn root_or(path: &str) -> String {
1449    if path.is_empty() {
1450        ROOT_PATH.to_string()
1451    } else {
1452        path.to_string()
1453    }
1454}
1455
1456/// JSON number equality that does not tell `1` from `1.0`.
1457fn same_scalar(a: &Value, b: &Value) -> bool {
1458    match (a, b) {
1459        (Value::Number(x), Value::Number(y)) => {
1460            // `1` equals `1.0`, but two integers are compared exactly: above
1461            // 2^53 distinct i64/u64 values collapse to one f64 (WR-06).
1462            if x.is_f64() || y.is_f64() {
1463                matches!((x.as_f64(), y.as_f64()), (Some(x), Some(y)) if x == y)
1464            } else {
1465                x == y
1466            }
1467        }
1468        _ => a == b,
1469    }
1470}
1471
1472/// The JSON paths at which `file` and `rest` differ (key order ignored).
1473fn diff_exact(file: &Value, rest: &Value, path: &str, out: &mut Vec<String>) {
1474    match (file, rest) {
1475        (Value::Object(f), Value::Object(r)) => {
1476            for (key, fv) in f {
1477                match r.get(key) {
1478                    Some(rv) => diff_exact(fv, rv, &child_path(path, key), out),
1479                    None => out.push(child_path(path, key)),
1480                }
1481            }
1482            for key in r.keys().filter(|k| !f.contains_key(*k)) {
1483                out.push(child_path(path, key));
1484            }
1485        }
1486        (Value::Array(f), Value::Array(r)) if f.len() == r.len() => {
1487            for (i, (fv, rv)) in f.iter().zip(r).enumerate() {
1488                diff_exact(fv, rv, &child_path(path, &i.to_string()), out);
1489            }
1490        }
1491        (Value::Object(_) | Value::Array(_), _) | (_, Value::Object(_) | Value::Array(_)) => {
1492            out.push(root_or(path));
1493        }
1494        (f, r) => {
1495            if !same_scalar(f, r) {
1496                out.push(root_or(path));
1497            }
1498        }
1499    }
1500}
1501
1502/// N2: every part of `file` must appear in `rest`; arrays of named objects
1503/// are matched by `name`, other array elements must appear somewhere.
1504fn diff_subset(file: &Value, rest: &Value, path: &str, out: &mut Vec<String>) {
1505    match (file, rest) {
1506        (Value::Object(f), Value::Object(r)) => {
1507            for (key, fv) in f {
1508                match r.get(key) {
1509                    Some(rv) => diff_subset(fv, rv, &child_path(path, key), out),
1510                    None => out.push(child_path(path, key)),
1511                }
1512            }
1513        }
1514        (Value::Array(f), Value::Array(r)) => {
1515            for (i, fv) in f.iter().enumerate() {
1516                let name = fv.get("name").and_then(Value::as_str);
1517                if let (Some(name), true) = (name, fv.is_object()) {
1518                    let at = format!("{path}[{}]", printable(name));
1519                    match r
1520                        .iter()
1521                        .find(|rv| rv.get("name").and_then(Value::as_str) == Some(name))
1522                    {
1523                        Some(rv) => diff_subset(fv, rv, &at, out),
1524                        None => out.push(at),
1525                    }
1526                } else {
1527                    let found = r.iter().any(|rv| {
1528                        let mut inner = Vec::new();
1529                        diff_exact(fv, rv, "", &mut inner);
1530                        inner.is_empty()
1531                    });
1532                    if !found {
1533                        out.push(child_path(path, &i.to_string()));
1534                    }
1535                }
1536            }
1537        }
1538        (Value::Object(_) | Value::Array(_), _) | (_, Value::Object(_) | Value::Array(_)) => {
1539            out.push(root_or(path));
1540        }
1541        (f, r) => {
1542            if !same_scalar(f, r) {
1543                out.push(root_or(path));
1544            }
1545        }
1546    }
1547}
1548
1549/// N3: the attribute keys the REST config also carries must be equal.
1550///
1551/// The key lookup is on the RAW REST map: a REST `null` for a declared key is
1552/// a present key, so it is compared (and differs from a declared value), not
1553/// skipped as absent. Only the values are null-stripped (N1).
1554fn diff_attributes(attrs: &Map<String, Value>, rest: &Value, out: &mut Vec<String>) {
1555    let Some(rest) = rest.as_object() else {
1556        out.push("(root)".to_string());
1557        return;
1558    };
1559    for (key, expected) in attrs {
1560        if let Some(seen) = rest.get(key) {
1561            let mut inner = Vec::new();
1562            diff_exact(&strip_nulls(expected), &strip_nulls(seen), "", &mut inner);
1563            if !inner.is_empty() {
1564                out.push(key.clone());
1565            }
1566        }
1567    }
1568}
1569
1570fn join_paths(mut paths: Vec<String>) -> String {
1571    paths.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
1572    paths.dedup();
1573    let more = paths.len().saturating_sub(MAX_PATHS);
1574    paths.truncate(MAX_PATHS);
1575    let mut text = paths.join(", ");
1576    if more > 0 {
1577        text.push_str(&format!(" (+{more} more)"));
1578    }
1579    text
1580}
1581
1582// ---- 4. files -----------------------------------------------------------------
1583
1584/// The `unreadable` finding's detail.
1585const UNREADABLE_DETAIL: &str =
1586    "the unit has a live path the host's sftp server cannot read; its live copy is unknown";
1587
1588/// The `files_differ` detail of a declared unit with a copy in a pair
1589/// master's mode collection: the collection, the count and the first
1590/// [`APPLY_LIST_SHOWN`] paths, byte-sorted.
1591fn mode_copy_detail(mode: &str, paths: &[String]) -> String {
1592    let mut paths = paths.to_vec();
1593    paths.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
1594    paths.dedup();
1595    let shown = paths.len().min(APPLY_LIST_SHOWN);
1596    let mut named = paths[..shown].join(", ");
1597    if paths.len() > shown {
1598        named.push_str(&format!(" and {} more", paths.len() - shown));
1599    }
1600    format!(
1601        "the master's {mode} collection holds a copy of the unit ({} file(s): {named}), which \
1602         overrides `core` and blocks the backup's configuration sync",
1603        paths.len()
1604    )
1605}
1606
1607/// Check 4. Every declared tag unit must be byte-equal to the render (its
1608/// plan status `same`). An unreadable unit is a red `unreadable` finding,
1609/// never "not found". On a redundant pair, a declared unit with a copy in the
1610/// master's mode collection ([`ObservedState::mode_copies`], quick
1611/// 261103-ell, F2) is one `files_differ` finding naming that collection and
1612/// the paths, in place of its status line: the mode copy overrides `core`,
1613/// and Ignition's redundancy sync cannot carry it.
1614fn check_files(expected: &Expected, observed: &ObservedState, warnings: &mut Vec<String>) -> Check {
1615    let mut findings = Vec::new();
1616    for unit in &expected.tag_units {
1617        // Never absent: an unreadable unit is not "not found".
1618        if observed.unreadable.iter().any(|(key, _)| key == unit) {
1619            findings.push(finding("unreadable", unit.clone(), UNREADABLE_DETAIL));
1620            continue;
1621        }
1622        if let Some(paths) = observed.mode_copies.get(unit).filter(|p| !p.is_empty()) {
1623            findings.push(finding(
1624                "files_differ",
1625                unit.clone(),
1626                mode_copy_detail(&expected.mode, paths),
1627            ));
1628            continue;
1629        }
1630        let status = observed
1631            .files
1632            .iter()
1633            .find(|(key, _)| key == unit)
1634            .map(|(_, status)| *status);
1635        match status {
1636            Some(PlanStatus::Same) => {}
1637            Some(other) => findings.push(finding(
1638                "files_differ",
1639                unit.clone(),
1640                format!(
1641                    "the effective live copy is `{}` against the render",
1642                    other.word()
1643                ),
1644            )),
1645            None => findings.push(finding(
1646                "files_differ",
1647                unit.clone(),
1648                "the unit was not found among the live files",
1649            )),
1650        }
1651    }
1652    for (key, declared) in &observed.unreadable {
1653        if expected.tag_units.contains(key) {
1654            continue;
1655        }
1656        if *declared {
1657            findings.push(finding("unreadable", key.clone(), UNREADABLE_DETAIL));
1658        } else {
1659            warnings.push(format!(
1660                "files: {key} is unreadable on the host and is not declared; it was not compared"
1661            ));
1662        }
1663    }
1664    sort_findings(&mut findings);
1665    finished(
1666        CheckId::Files,
1667        format!(
1668            "{} declared tag unit(s) compared byte for byte with the live files",
1669            expected.tag_units.len()
1670        ),
1671        findings,
1672    )
1673}
1674
1675// ---- 5. loaded ----------------------------------------------------------------
1676
1677fn check_loaded(expected: &Expected, observed: &ObservedState) -> Check {
1678    if observed.scan.scan_active {
1679        return Check {
1680            id: CheckId::Loaded,
1681            status: CheckStatus::Warn,
1682            detail: "not observed: a scan is running, so its log lines are not readable yet"
1683                .to_string(),
1684            findings: Vec::new(),
1685        };
1686    }
1687    let lines = match &observed.logs {
1688        Ok(lines) => lines,
1689        Err(slug) => {
1690            return finished(
1691                CheckId::Loaded,
1692                "the gateway log could not be read".to_string(),
1693                vec![finding("logs_unavailable", "logs", printable(slug))],
1694            );
1695        }
1696    };
1697
1698    // The longest declared id that prefixes the resource text wins the line,
1699    // so `Prod` does not claim a line about `Prod Backup`.
1700    let mut hits: BTreeMap<&str, (i64, &str)> = BTreeMap::new();
1701    for line in lines {
1702        if !line.message.contains(WILL_NOT_BE_LOADED) {
1703            continue;
1704        }
1705        let owner = expected
1706            .declared_ids
1707            .iter()
1708            .filter(|id| line.message.contains(&format!("Resource {id} ")))
1709            .max_by_key(|id| id.len());
1710        if let Some(id) = owner {
1711            let entry = hits
1712                .entry(id.as_str())
1713                .or_insert((line.timestamp, line.logger.as_str()));
1714            if line.timestamp < entry.0 {
1715                *entry = (line.timestamp, line.logger.as_str());
1716            }
1717        }
1718    }
1719    let mut findings: Vec<Finding> = hits
1720        .into_iter()
1721        .map(|(id, (timestamp, logger))| {
1722            finding(
1723                "unloaded_resource",
1724                id,
1725                format!("logged at {timestamp} by `{}`", printable(logger)),
1726            )
1727        })
1728        .collect();
1729    sort_findings(&mut findings);
1730    let detail = if findings.is_empty() {
1731        format!(
1732            "no `{WILL_NOT_BE_LOADED}` line for a declared resource since the last scan (the gateway logs it once per scan that finds the resource, and not for every type, so this covers the last scan only)"
1733        )
1734    } else {
1735        format!("{} declared resource(s) were not loaded", findings.len())
1736    };
1737    finished(CheckId::Loaded, detail, findings)
1738}
1739
1740// ---- 6. probes ----------------------------------------------------------------
1741
1742fn check_probes(expected: &Expected, observed: &ObservedState) -> Check {
1743    if expected.probes.is_empty() {
1744        return Check {
1745            id: CheckId::Probes,
1746            status: CheckStatus::Warn,
1747            detail: "the manifest declares no `verify.tag_probes`; nothing was probed".to_string(),
1748            findings: vec![finding(
1749                "tag_probes_absent",
1750                "verify.tag_probes",
1751                "add tag paths to the manifest to probe them after a deploy",
1752            )],
1753        };
1754    }
1755    let readings = match &observed.probes {
1756        Some(Ok(readings)) => readings,
1757        Some(Err(slug)) => {
1758            return finished(
1759                CheckId::Probes,
1760                "the probes could not be read".to_string(),
1761                vec![finding("tag_probes_unavailable", "tags", printable(slug))],
1762            );
1763        }
1764        None => {
1765            return finished(
1766                CheckId::Probes,
1767                "the probes could not be read".to_string(),
1768                vec![finding("tag_probes_unavailable", "tags", "not read")],
1769            );
1770        }
1771    };
1772    let mut findings = Vec::new();
1773    for path in &expected.probes {
1774        match readings.iter().find(|r| &r.path == path) {
1775            Some(r) if r.quality.starts_with("Good") => {}
1776            Some(r) => findings.push(finding(
1777                "tag_probe_not_good",
1778                path.clone(),
1779                format!("quality {}", quality_code(&r.quality)),
1780            )),
1781            None => findings.push(finding("tag_probe_not_good", path.clone(), "no reading")),
1782        }
1783    }
1784    // Manifest order, not sorted.
1785    finished(
1786        CheckId::Probes,
1787        format!("{} tag probe(s) read", expected.probes.len()),
1788        findings,
1789    )
1790}
1791
1792/// A quality string reduced to a code that cannot carry free text: the class
1793/// (`Good`, `Bad`, `Uncertain`, `Error`) and, when the rest is plain letters,
1794/// the Ignition suffix (`Bad_NotFound`).
1795fn quality_code(quality: &str) -> String {
1796    if quality.is_empty() {
1797        return "empty".to_string();
1798    }
1799    let (class, rest) = match quality.split_once('_') {
1800        Some((class, rest)) => (class, Some(rest)),
1801        None => (quality, None),
1802    };
1803    if !matches!(class, "Good" | "Bad" | "Uncertain" | "Error") {
1804        return "unrecognised".to_string();
1805    }
1806    match rest {
1807        Some(rest)
1808            if !rest.is_empty()
1809                && rest.len() <= 48
1810                && rest.chars().all(|c| c.is_ascii_alphabetic() || c == '_') =>
1811        {
1812            format!("{class}_{rest}")
1813        }
1814        _ => class.to_string(),
1815    }
1816}
1817
1818/// `text` limited to printable characters and [`MAX_CODE_CHARS`], for the few
1819/// gateway-supplied names that reach a finding (logger names, error slugs,
1820/// the active mode).
1821fn printable(text: &str) -> String {
1822    let mut out: String = text
1823        .chars()
1824        .filter(|c| !c.is_control())
1825        .take(MAX_CODE_CHARS)
1826        .collect();
1827    if text.chars().count() > MAX_CODE_CHARS {
1828        out.push_str("...");
1829    }
1830    out
1831}
1832
1833#[cfg(test)]
1834mod tests {
1835    use super::*;
1836    use serde_json::json;
1837
1838    /// The renderer words are the serialized words, so the human and JSON
1839    /// forms never drift.
1840    #[test]
1841    fn words_equal_the_serialized_forms() {
1842        for id in [
1843            CheckId::Scan,
1844            CheckId::Mode,
1845            CheckId::ReadBack,
1846            CheckId::Files,
1847            CheckId::Loaded,
1848            CheckId::Probes,
1849        ] {
1850            assert_eq!(json!(id), json!(id.word()));
1851        }
1852        for status in [CheckStatus::Pass, CheckStatus::Fail, CheckStatus::Warn] {
1853            assert_eq!(json!(status), json!(status.word()));
1854        }
1855    }
1856
1857    #[test]
1858    fn quality_codes_carry_no_free_text() {
1859        assert_eq!(quality_code("Good"), "Good");
1860        assert_eq!(quality_code("Good_Provisional"), "Good_Provisional");
1861        assert_eq!(quality_code("Bad_NotFound"), "Bad_NotFound");
1862        assert_eq!(quality_code("Bad_secret-123"), "Bad");
1863        assert_eq!(quality_code("Weird"), "unrecognised");
1864        assert_eq!(quality_code(""), "empty");
1865    }
1866
1867    #[test]
1868    fn printable_drops_controls_and_caps_length() {
1869        assert_eq!(printable("a\nb\u{7}c"), "abc");
1870        let long = "x".repeat(MAX_CODE_CHARS + 10);
1871        assert_eq!(printable(&long).len(), MAX_CODE_CHARS + 3);
1872    }
1873
1874    #[test]
1875    fn gateway_supplied_keys_are_sanitised_in_diff_paths() {
1876        // WR-05: a REST key with an escape sequence or a huge length must not
1877        // reach a finding raw.
1878        let hostile = format!("evil\u{1b}[31m{}", "k".repeat(MAX_CODE_CHARS * 2));
1879        let mut rest = serde_json::Map::new();
1880        rest.insert(hostile, json!(1));
1881        let mut out = Vec::new();
1882        diff_exact(&json!({}), &Value::Object(rest), "", &mut out);
1883        assert_eq!(out.len(), 1);
1884        assert!(!out[0].contains('\u{1b}'), "{:?}", out[0]);
1885        assert!(
1886            out[0].chars().count() <= MAX_CODE_CHARS + 3,
1887            "{}",
1888            out[0].len()
1889        );
1890    }
1891
1892    #[test]
1893    fn large_integers_compare_exactly() {
1894        // WR-06: 2^53 + 1 and 2^53 are the same f64 but different integers.
1895        let mut out = Vec::new();
1896        diff_exact(
1897            &json!({"id": 9_007_199_254_740_993_i64}),
1898            &json!({"id": 9_007_199_254_740_992_i64}),
1899            "",
1900            &mut out,
1901        );
1902        assert_eq!(out, vec!["id".to_string()]);
1903        let mut out = Vec::new();
1904        diff_exact(
1905            &json!({"id": u64::MAX}),
1906            &json!({"id": u64::MAX - 1}),
1907            "",
1908            &mut out,
1909        );
1910        assert_eq!(out, vec!["id".to_string()]);
1911        let mut out = Vec::new();
1912        diff_exact(
1913            &json!({"id": 9_007_199_254_740_993_i64}),
1914            &json!({"id": 9_007_199_254_740_993_i64}),
1915            "",
1916            &mut out,
1917        );
1918        assert!(out.is_empty());
1919    }
1920
1921    #[test]
1922    fn numbers_compare_by_value() {
1923        let mut out = Vec::new();
1924        diff_exact(
1925            &json!({"a": 1, "b": 2.5}),
1926            &json!({"a": 1.0, "b": 2.5}),
1927            "",
1928            &mut out,
1929        );
1930        assert!(out.is_empty(), "{out:?}");
1931        diff_exact(&json!({"a": 1}), &json!({"a": 2}), "", &mut out);
1932        assert_eq!(out, vec!["a".to_string()]);
1933    }
1934
1935    #[test]
1936    fn shows_resolves_paths_ancestors_and_opaque_values() {
1937        let rest = json!({
1938            "settings": {"enabled": true, "blob": "opaque", "empty": {}, "gone": null},
1939            "list": [{"host": "a"}]
1940        });
1941        // The path itself, and a path whose descendants are present.
1942        assert!(shows(&rest, "", "settings.enabled"));
1943        assert!(shows(&rest, "", "settings"));
1944        assert!(shows(&rest, "", "list.0.host"));
1945        assert!(shows(&rest, "", ROOT_PATH));
1946        // An opaque ancestor stands for every member under it.
1947        assert!(shows(&rest, "", "settings.blob.hostname"));
1948        // Absent: a member the object lacks, a null member, an index past the
1949        // end, a member of an empty object.
1950        assert!(!shows(&rest, "", "settings.deviceSettings"));
1951        assert!(!shows(&rest, "", "settings.gone"));
1952        assert!(!shows(&rest, "", "list.1"));
1953        assert!(!shows(&rest, "", "settings.empty.x"));
1954        assert!(!shows(&rest, "", "settingsX"));
1955        // No config at all shows nothing, not even the root.
1956        assert!(!shows(&Value::Null, "", ROOT_PATH));
1957        assert!(!shows(&Value::Null, "", "namespace"));
1958    }
1959
1960    #[test]
1961    fn paths_overlap_is_symmetric_on_segment_boundaries() {
1962        // Equal, ancestor and descendant overlap, in either order.
1963        for (a, b) in [
1964            ("a", "a"),
1965            ("a", "a.b"),
1966            ("a", "a.b.c"),
1967            ("a", "a[n]"),
1968            ("s", "s.0.a"),
1969        ] {
1970            assert!(paths_overlap(a, b), "{a} {b}");
1971            assert!(paths_overlap(b, a), "{b} {a}");
1972        }
1973        // `(root)` overlaps everything, itself included.
1974        for p in ["a", "a.b", "x[y]", ROOT_PATH] {
1975            assert!(paths_overlap(ROOT_PATH, p), "{p}");
1976            assert!(paths_overlap(p, ROOT_PATH), "{p}");
1977        }
1978        // A shared prefix that is not a segment boundary, and siblings, do not.
1979        for (a, b) in [
1980            ("a", "ab"),
1981            ("a.b", "a.bc"),
1982            ("a.b", "a.c"),
1983            ("s.0", "s.1"),
1984            ("b.a", "a"),
1985        ] {
1986            assert!(!paths_overlap(a, b), "{a} {b}");
1987            assert!(!paths_overlap(b, a), "{b} {a}");
1988        }
1989    }
1990
1991    #[test]
1992    fn resolve_finds_present_paths_only() {
1993        let doc = json!({"s": {"a": 1, "blob": "opaque"}, "l": [{"h": "x"}]});
1994        assert_eq!(resolve(&doc, "", "s.a"), Some(&json!(1)));
1995        assert_eq!(resolve(&doc, "", "l.0.h"), Some(&json!("x")));
1996        assert_eq!(resolve(&doc, "", ROOT_PATH), Some(&doc));
1997        assert_eq!(resolve(&doc, "", "s"), doc.get("s"));
1998        // Absent, past the end, or under an opaque value: not resolvable.
1999        assert_eq!(resolve(&doc, "", "s.b"), None);
2000        assert_eq!(resolve(&doc, "", "l.1"), None);
2001        assert_eq!(resolve(&doc, "", "s.blob.hostname"), None);
2002        assert_eq!(resolve(&doc, "", "sa"), None);
2003    }
2004
2005    /// quick 261103-ell, F2: a declared tag unit whose status is `same` but
2006    /// which also has a copy in a pair master's mode collection fails
2007    /// `files`: one `files_differ` finding for that key, naming the mode
2008    /// collection and both paths. Without the mode copy it passes.
2009    #[test]
2010    fn files_check_fails_a_declared_unit_with_a_mode_copy() {
2011        const UNIT: &str = "ignition/tag-definition/default/Plant";
2012        let expected = Expected {
2013            mode: "production".to_string(),
2014            resources: Vec::new(),
2015            not_checked: Vec::new(),
2016            declared_ids: Vec::new(),
2017            tag_units: vec![UNIT.to_string()],
2018            probes: Vec::new(),
2019        };
2020        let mut observed = ObservedState {
2021            scan: ScanStatus {
2022                scan_active: false,
2023                last_scan_timestamp: 1_792_000_000_000,
2024                last_scan_duration: Some(400),
2025                extra: BTreeMap::new(),
2026            },
2027            mode: Some("production".to_string()),
2028            read_back: BTreeMap::new(),
2029            files: vec![(UNIT.to_string(), PlanStatus::Same)],
2030            logs: Ok(Vec::new()),
2031            probes: None,
2032            unreadable: Vec::new(),
2033            mode_copies: BTreeMap::new(),
2034        };
2035        let mut warnings = Vec::new();
2036        let clean = check_files(&expected, &observed, &mut warnings);
2037        assert_eq!(clean.status, CheckStatus::Pass, "{clean:?}");
2038        assert!(clean.findings.is_empty(), "{clean:?}");
2039
2040        let paths = vec![
2041            format!("{UNIT}/tags.json"),
2042            format!("{UNIT}/unary-resource.json"),
2043        ];
2044        observed.mode_copies.insert(UNIT.to_string(), paths.clone());
2045        let check = check_files(&expected, &observed, &mut warnings);
2046        assert_eq!(check.status, CheckStatus::Fail, "{check:?}");
2047        assert_eq!(check.findings.len(), 1, "{check:?}");
2048        let found = &check.findings[0];
2049        assert_eq!(found.code, "files_differ");
2050        assert_eq!(found.subject, UNIT);
2051        assert!(
2052            found.detail.contains("production collection"),
2053            "{}",
2054            found.detail
2055        );
2056        for path in &paths {
2057            assert!(found.detail.contains(path.as_str()), "{}", found.detail);
2058        }
2059        assert!(warnings.is_empty(), "{warnings:?}");
2060    }
2061
2062    #[test]
2063    fn path_list_is_sorted_deduplicated_and_capped() {
2064        let paths: Vec<String> = (0..MAX_PATHS + 3)
2065            .rev()
2066            .map(|i| format!("p{i:02}"))
2067            .collect();
2068        let text = join_paths(paths);
2069        assert!(text.starts_with("p00, p01"), "{text}");
2070        assert!(text.ends_with("(+3 more)"), "{text}");
2071    }
2072}