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