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//! ## `loaded` is best effort (CV1)
52//!
53//! The gateway logs `will not be loaded` once, shortly after the scan that
54//! found the resource, and not for every type (a uuid-less
55//! `database-translator` is silent), and the REST view still lists an
56//! unloaded resource. A clean window therefore proves only that the last scan
57//! did not warn. The check says so in its detail, answers `warn` (not
58//! observed) while a scan is running, and `fail` with `logs_unavailable` when
59//! the log source cannot be read or is truncated.
60//!
61//! ## The verdict is not an error
62//!
63//! [`VERIFY_FAILED_SLUG`] lives only in the result's `slug` key. It is not a
64//! `CoreError` variant and never joins `CoreError::DEPLOY_SLUGS` or the exit
65//! table: a red verdict is a successful envelope whose `data.ok` is false
66//! (Decision A).
67
68use std::collections::{BTreeMap, BTreeSet};
69use std::fmt;
70use std::time::Duration;
71
72use serde::Serialize;
73use serde_json::{Map, Value};
74
75use super::layout::TAG_TYPES;
76use super::manifest::Manifest;
77use super::plan::{PlanStatus, unit_key};
78use super::render::RenderOutcome;
79use crate::client::scan::ScanStatus;
80
81/// The verdict slug of a red verify. Only ever in `data.slug`.
82pub const VERIFY_FAILED_SLUG: &str = "deploy_verify_failed";
83
84/// How long probes are polled before the verdict (SF-8).
85pub const PROBE_DEADLINE: Duration = Duration::from_secs(30);
86
87/// Time between two probe polls.
88pub const PROBE_INTERVAL: Duration = Duration::from_secs(1);
89
90/// Milliseconds the log window opens before `lastScanTimestamp -
91/// lastScanDuration` (CV1: the entries are stamped after the scan end, so a
92/// full duration plus one second is early under either reading of the field).
93pub const LOG_WINDOW_SLACK_MS: i64 = 1_000;
94
95/// Window used in place of `lastScanDuration` when the gateway omits it.
96pub const LOG_WINDOW_FALLBACK_MS: i64 = 30_000;
97
98/// Milliseconds to wait after a scan completed before an empty log answer
99/// means anything (CV1: the entry became readable 516 to 1450 ms late).
100pub const LOG_SETTLE_MS: i64 = 5_000;
101
102/// The substring of the gateway line (CV1: the REST route returns it verbatim).
103pub const WILL_NOT_BE_LOADED: &str = "will not be loaded";
104
105/// Types whose list and singleton routes both answer 404 on 8.3.6 (CV2): they
106/// cannot be read back and are reported, not compared.
107pub const NO_READ_ROUTE: &[(&str, &str)] = &[
108    ("com.inductiveautomation.opcua", "one-time"),
109    ("ignition", "images"),
110];
111
112/// The collection name of gateway-provided, non-file-backed resources (CV2).
113pub const SYSTEM_COLLECTION: &str = "system";
114
115/// Most differing JSON paths one finding names.
116const MAX_PATHS: usize = 20;
117
118/// Longest logger name or quality code copied into a finding.
119const MAX_CODE_CHARS: usize = 96;
120
121/// The six checks, in report order.
122#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
123#[serde(rename_all = "snake_case")]
124pub enum CheckId {
125    /// No config scan is running.
126    Scan,
127    /// The gateway runs the target's mode.
128    Mode,
129    /// Declared resources read back equal through REST.
130    ReadBack,
131    /// Declared tag units are byte-equal on the host.
132    Files,
133    /// No `will not be loaded` line for a declared resource.
134    Loaded,
135    /// Tag probes read Good.
136    Probes,
137}
138
139impl CheckId {
140    /// The word the check serializes as (`read_back`, not `ReadBack`), for the
141    /// human renderer.
142    pub fn word(self) -> &'static str {
143        match self {
144            Self::Scan => "scan",
145            Self::Mode => "mode",
146            Self::ReadBack => "read_back",
147            Self::Files => "files",
148            Self::Loaded => "loaded",
149            Self::Probes => "probes",
150        }
151    }
152}
153
154/// The outcome of one check. `warn` never fails the verdict.
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
156#[serde(rename_all = "lowercase")]
157pub enum CheckStatus {
158    /// Evaluated and good.
159    Pass,
160    /// Evaluated and bad, or not evaluable.
161    Fail,
162    /// Not a failure, but not a plain pass either.
163    Warn,
164}
165
166impl CheckStatus {
167    /// The word the status serializes as, for the human renderer.
168    pub fn word(self) -> &'static str {
169        match self {
170            Self::Pass => "pass",
171            Self::Fail => "fail",
172            Self::Warn => "warn",
173        }
174    }
175}
176
177/// One value-free observation.
178#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
179pub struct Finding {
180    /// Stable code (`scan_active`, `mode_mismatch`, `resource_missing`,
181    /// `resource_differs`, `files_differ`, `logs_unavailable`,
182    /// `unloaded_resource`, `tag_probes_absent`, `tag_probes_unavailable`,
183    /// `tag_probe_not_good`).
184    pub code: &'static str,
185    /// What it is about: a resource key, unit key, tag path, `mode`, `logs`.
186    pub subject: String,
187    /// Paths, codes and timestamps only.
188    pub detail: String,
189}
190
191/// One check of the report.
192#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
193pub struct Check {
194    /// Which check.
195    pub id: CheckId,
196    /// Its outcome.
197    pub status: CheckStatus,
198    /// One line of what was evaluated.
199    pub detail: String,
200    /// Findings, byte-sorted by subject (probes: manifest order).
201    pub findings: Vec<Finding>,
202}
203
204/// The verify verdict.
205#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
206pub struct VerifyReport {
207    /// True when no check failed.
208    pub ok: bool,
209    /// [`VERIFY_FAILED_SLUG`] when `ok` is false, else null.
210    pub slug: Option<&'static str>,
211    /// All six checks in order.
212    pub checks: Vec<Check>,
213    /// Declared resources compared by `read_back`.
214    pub resources_checked: usize,
215    /// Non-failing notes.
216    pub warnings: Vec<String>,
217}
218
219/// How one declared resource's `config.json` is compared.
220#[derive(Clone, PartialEq)]
221pub enum Compare {
222    /// Equal after N1 (both sides).
223    Exact(Value),
224    /// N2: the file value is a name-keyed subset of the REST value.
225    NameKeyedSubset(Value),
226    /// N3: the `resource.json` attributes the REST config also carries.
227    Attributes(Map<String, Value>),
228}
229
230impl fmt::Debug for Compare {
231    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232        let kind = match self {
233            Compare::Exact(_) => "Exact",
234            Compare::NameKeyedSubset(_) => "NameKeyedSubset",
235            Compare::Attributes(_) => "Attributes",
236        };
237        write!(f, "{kind}(<withheld>)")
238    }
239}
240
241/// A declared non-tag resource that `read_back` compares.
242#[derive(Debug, Clone, PartialEq)]
243pub struct ExpectedResource {
244    /// `module/type/name`, or `module/type` for a singleton.
245    pub key: String,
246    /// Module id.
247    pub module: String,
248    /// Resource type.
249    pub ty: String,
250    /// Name; `None` for a singleton.
251    pub name: Option<String>,
252    /// What to compare.
253    pub compare: Compare,
254}
255
256impl ExpectedResource {
257    /// A resource compared by exact JSON equality (tests and callers).
258    pub fn exact(module: &str, ty: &str, name: Option<&str>, config: Value) -> Self {
259        Self::new(module, ty, name, Compare::Exact(config))
260    }
261
262    /// A resource with an explicit comparison.
263    pub fn new(module: &str, ty: &str, name: Option<&str>, compare: Compare) -> Self {
264        Self {
265            key: resource_id(module, ty, name),
266            module: module.to_string(),
267            ty: ty.to_string(),
268            name: name.map(str::to_string),
269            compare,
270        }
271    }
272}
273
274/// `module/type/name`, or `module/type` for a singleton.
275pub fn resource_id(module: &str, ty: &str, name: Option<&str>) -> String {
276    match name {
277        Some(name) => format!("{module}/{ty}/{name}"),
278        None => format!("{module}/{ty}"),
279    }
280}
281
282/// What the declared tree says verify should find.
283#[derive(Debug, Clone, PartialEq)]
284pub struct Expected {
285    /// The target's deployment mode.
286    pub mode: String,
287    /// Declared non-tag resources `read_back` compares, in key order.
288    pub resources: Vec<ExpectedResource>,
289    /// Declared resources `read_back` cannot compare, with the reason, in key
290    /// order (no REST read route, or a `config.json` that is not JSON).
291    pub not_checked: Vec<(String, String)>,
292    /// Ids of every declared non-tag resource (compared or not), for the log
293    /// match, in key order.
294    pub declared_ids: Vec<String>,
295    /// Unit keys of the declared tag units, in key order.
296    pub tag_units: Vec<String>,
297    /// `verify.tag_probes[]`, in manifest order.
298    pub probes: Vec<String>,
299}
300
301impl Expected {
302    /// Build from a render outcome and its manifest (deploy-layer types only).
303    pub fn from_render(target_mode: &str, outcome: &RenderOutcome, manifest: &Manifest) -> Self {
304        let files = &outcome.tree.files;
305        let is_tag = |module: &str, ty: &str| module == "ignition" && TAG_TYPES.contains(&ty);
306
307        let mut tag_units = BTreeSet::new();
308        for path in files.keys() {
309            let mut parts = path.components();
310            if let (Some(module), Some(ty)) = (parts.next(), parts.next())
311                && is_tag(module, ty)
312            {
313                tag_units.insert(unit_key(path, files));
314            }
315        }
316
317        let mut resources = BTreeMap::new();
318        let mut not_checked = BTreeMap::new();
319        let mut declared_ids = BTreeSet::new();
320        for res in &outcome.resources {
321            if is_tag(&res.module, &res.ty) {
322                continue;
323            }
324            let key = resource_id(&res.module, &res.ty, res.name.as_deref());
325            declared_ids.insert(key.clone());
326            if NO_READ_ROUTE
327                .iter()
328                .any(|(m, t)| *m == res.module && *t == res.ty)
329            {
330                not_checked.insert(key, "no REST read route".to_string());
331                continue;
332            }
333            let file = |name: &str| {
334                super::layout::RelPath::new(&format!("{key}/{name}"))
335                    .ok()
336                    .and_then(|p| files.get(&p))
337            };
338            let compare = if let Some(bytes) = file("config.json") {
339                match serde_json::from_slice::<Value>(bytes) {
340                    Ok(value) if res.module == "ignition" && res.ty == "security-levels" => {
341                        Compare::NameKeyedSubset(value)
342                    }
343                    Ok(value) => Compare::Exact(value),
344                    Err(_) => {
345                        not_checked.insert(key, "config.json is not JSON".to_string());
346                        continue;
347                    }
348                }
349            } else if let Some(attributes) = file("resource.json")
350                .and_then(|bytes| serde_json::from_slice::<Value>(bytes).ok())
351                .and_then(|v| v.get("attributes").and_then(Value::as_object).cloned())
352            {
353                Compare::Attributes(attributes)
354            } else {
355                not_checked.insert(key, "no config.json to compare".to_string());
356                continue;
357            };
358            resources.insert(
359                key.clone(),
360                ExpectedResource {
361                    key,
362                    module: res.module.clone(),
363                    ty: res.ty.clone(),
364                    name: res.name.clone(),
365                    compare,
366                },
367            );
368        }
369
370        Expected {
371            mode: target_mode.to_string(),
372            resources: resources.into_values().collect(),
373            not_checked: not_checked.into_iter().collect(),
374            declared_ids: declared_ids.into_iter().collect(),
375            tag_units: tag_units.into_iter().collect(),
376            probes: manifest
377                .verify
378                .as_ref()
379                .map(|v| v.tag_probes.clone())
380                .unwrap_or_default(),
381        }
382    }
383}
384
385/// What the gateway said about one declared resource.
386#[derive(Clone, PartialEq)]
387pub enum Remote {
388    /// Not in the effective view.
389    Missing,
390    /// In the effective view with this `config`.
391    Config(Value),
392    /// In the effective view, but from the `system` collection.
393    System,
394}
395
396impl fmt::Debug for Remote {
397    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
398        match self {
399            Remote::Missing => f.write_str("Missing"),
400            Remote::Config(_) => f.write_str("Config(<withheld>)"),
401            Remote::System => f.write_str("System"),
402        }
403    }
404}
405
406/// One gateway log entry kept for matching.
407#[derive(Clone, PartialEq, Eq)]
408pub struct LogLine {
409    /// Epoch ms on the gateway clock.
410    pub timestamp: i64,
411    /// Logger name.
412    pub logger: String,
413    /// The message. Matched against, never serialized, never in `Debug`.
414    pub message: String,
415}
416
417impl fmt::Debug for LogLine {
418    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
419        f.debug_struct("LogLine")
420            .field("timestamp", &self.timestamp)
421            .field("logger", &self.logger)
422            .field("message", &"<withheld>")
423            .finish()
424    }
425}
426
427/// One tag probe reading. The value is never carried.
428#[derive(Clone, PartialEq, Eq)]
429pub struct ProbeReading {
430    /// The tag path read.
431    pub path: String,
432    /// The quality string (`Good`, `Bad_NotFound`, ...).
433    pub quality: String,
434}
435
436impl fmt::Debug for ProbeReading {
437    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
438        f.debug_struct("ProbeReading")
439            .field("path", &self.path)
440            .field("quality", &quality_code(&self.quality))
441            .finish()
442    }
443}
444
445/// What the action read.
446#[derive(Debug, Clone)]
447pub struct ObservedState {
448    /// The config scan status (gateway clock).
449    pub scan: ScanStatus,
450    /// The normalised active deployment mode.
451    pub mode: Option<String>,
452    /// Per declared, compared resource key.
453    pub read_back: BTreeMap<String, Remote>,
454    /// Plan status per declared tag unit key.
455    pub files: Vec<(String, PlanStatus)>,
456    /// The log lines since the last scan, or the slug of why they could not
457    /// be read.
458    pub logs: Result<Vec<LogLine>, String>,
459    /// The probe readings, or the slug of why they could not be read; `None`
460    /// when no probe read was made.
461    pub probes: Option<Result<Vec<ProbeReading>, String>>,
462}
463
464/// The start of the log window for the last scan (CV1), epoch ms on the
465/// gateway clock.
466pub fn log_window_start(scan: &ScanStatus) -> i64 {
467    let duration = scan.last_scan_duration.unwrap_or(LOG_WINDOW_FALLBACK_MS);
468    (scan.last_scan_timestamp - duration - LOG_WINDOW_SLACK_MS).max(0)
469}
470
471/// Evaluate `observed` against `expected`.
472pub fn evaluate(expected: &Expected, observed: &ObservedState) -> VerifyReport {
473    let mut warnings = Vec::new();
474    let (read_back, resources_checked) = check_read_back(expected, observed, &mut warnings);
475    let checks = vec![
476        check_scan(observed),
477        check_mode(expected, observed),
478        read_back,
479        check_files(expected, observed),
480        check_loaded(expected, observed),
481        check_probes(expected, observed),
482    ];
483    for check in &checks {
484        if check.status == CheckStatus::Warn {
485            warnings.push(format!("{}: {}", check_word(check.id), check.detail));
486        }
487    }
488    let ok = checks.iter().all(|c| c.status != CheckStatus::Fail);
489    VerifyReport {
490        ok,
491        slug: (!ok).then_some(VERIFY_FAILED_SLUG),
492        checks,
493        resources_checked,
494        warnings,
495    }
496}
497
498fn check_word(id: CheckId) -> &'static str {
499    match id {
500        CheckId::Scan => "scan",
501        CheckId::Mode => "mode",
502        CheckId::ReadBack => "read_back",
503        CheckId::Files => "files",
504        CheckId::Loaded => "loaded",
505        CheckId::Probes => "probes",
506    }
507}
508
509fn finding(code: &'static str, subject: impl Into<String>, detail: impl Into<String>) -> Finding {
510    Finding {
511        code,
512        subject: subject.into(),
513        detail: detail.into(),
514    }
515}
516
517/// Sort findings by subject bytes, then code, so the report does not depend
518/// on input order.
519fn sort_findings(findings: &mut [Finding]) {
520    findings.sort_by(|a, b| {
521        a.subject
522            .as_bytes()
523            .cmp(b.subject.as_bytes())
524            .then_with(|| a.code.cmp(b.code))
525    });
526}
527
528fn finished(id: CheckId, detail: String, findings: Vec<Finding>) -> Check {
529    Check {
530        id,
531        status: if findings.is_empty() {
532            CheckStatus::Pass
533        } else {
534            CheckStatus::Fail
535        },
536        detail,
537        findings,
538    }
539}
540
541// ---- 1. scan ------------------------------------------------------------------
542
543fn check_scan(observed: &ObservedState) -> Check {
544    if observed.scan.scan_active {
545        return finished(
546            CheckId::Scan,
547            "a config scan is running".to_string(),
548            vec![finding(
549                "scan_active",
550                "config",
551                "the gateway reports an active config scan; verify reads settled state only",
552            )],
553        );
554    }
555    finished(
556        CheckId::Scan,
557        "no config scan is running".to_string(),
558        Vec::new(),
559    )
560}
561
562// ---- 2. mode ------------------------------------------------------------------
563
564fn check_mode(expected: &Expected, observed: &ObservedState) -> Check {
565    mode_check(expected, observed.mode.as_deref())
566}
567
568/// The `mode` check over an already-read, normalised mode. The backup node's
569/// check shares it with the master's (24-05).
570pub(crate) fn mode_check(expected: &Expected, mode: Option<&str>) -> Check {
571    if mode == Some(expected.mode.as_str()) {
572        return finished(
573            CheckId::Mode,
574            format!("the gateway runs mode `{}`", expected.mode),
575            Vec::new(),
576        );
577    }
578    let seen = match mode {
579        Some(mode) => format!("`{}`", printable(mode)),
580        None => "no mode".to_string(),
581    };
582    finished(
583        CheckId::Mode,
584        "the gateway does not run the target's mode".to_string(),
585        vec![finding(
586            "mode_mismatch",
587            "mode",
588            format!("expected `{}`, the gateway reports {seen}", expected.mode),
589        )],
590    )
591}
592
593// ---- 3. read_back -------------------------------------------------------------
594
595fn check_read_back(
596    expected: &Expected,
597    observed: &ObservedState,
598    warnings: &mut Vec<String>,
599) -> (Check, usize) {
600    read_back_check(expected, &observed.read_back, warnings)
601}
602
603/// The `read_back` check over already-read resources. The backup node's check
604/// shares it with the master's (24-05).
605pub(crate) fn read_back_check(
606    expected: &Expected,
607    read_back: &BTreeMap<String, Remote>,
608    warnings: &mut Vec<String>,
609) -> (Check, usize) {
610    let mut findings = Vec::new();
611    let mut checked = 0usize;
612    for res in &expected.resources {
613        match read_back.get(&res.key).unwrap_or(&Remote::Missing) {
614            Remote::Missing => {
615                checked += 1;
616                findings.push(finding(
617                    "resource_missing",
618                    res.key.clone(),
619                    "not in the gateway's effective view",
620                ));
621            }
622            Remote::System => warnings.push(format!(
623                "read_back: {} not compared: the effective copy is in the `{SYSTEM_COLLECTION}` collection (gateway-provided, not file-backed)",
624                res.key
625            )),
626            Remote::Config(rest) => {
627                checked += 1;
628                let mut paths = Vec::new();
629                match &res.compare {
630                    Compare::Exact(file) => {
631                        let rest = strip_nulls(rest);
632                        diff_exact(&strip_nulls(file), &rest, "", &mut paths);
633                    }
634                    Compare::NameKeyedSubset(file) => {
635                        let rest = strip_nulls(rest);
636                        diff_subset(&strip_nulls(file), &rest, "", &mut paths);
637                    }
638                    // the key lookup needs the RAW map (see `diff_attributes`)
639                    Compare::Attributes(attrs) => diff_attributes(attrs, rest, &mut paths),
640                }
641                if !paths.is_empty() {
642                    findings.push(finding(
643                        "resource_differs",
644                        res.key.clone(),
645                        format!("differs at: {}", join_paths(paths)),
646                    ));
647                }
648            }
649        }
650    }
651    for (key, reason) in &expected.not_checked {
652        warnings.push(format!("read_back: {key} not compared: {reason}"));
653    }
654    if checked == 0 && findings.is_empty() {
655        warnings.push(
656            "read_back: no declared non-tag resource was compared (nothing to read back)"
657                .to_string(),
658        );
659    }
660    sort_findings(&mut findings);
661    let detail = format!("{checked} declared resource(s) compared with the REST effective view");
662    (finished(CheckId::ReadBack, detail, findings), checked)
663}
664
665/// `value` with every JSON `null` object member removed, at every depth (N1).
666/// Applied to the file value and to the REST value alike, so a null member
667/// equals an absent one on either side.
668fn strip_nulls(value: &Value) -> Value {
669    match value {
670        Value::Object(map) => Value::Object(
671            map.iter()
672                .filter(|(_, v)| !v.is_null())
673                .map(|(k, v)| (k.clone(), strip_nulls(v)))
674                .collect(),
675        ),
676        Value::Array(items) => Value::Array(items.iter().map(strip_nulls).collect()),
677        other => other.clone(),
678    }
679}
680
681fn child_path(path: &str, key: &str) -> String {
682    // the key may be gateway-supplied (REST body): no control characters and
683    // a bounded length, like every other gateway string in a finding
684    let key = printable(key);
685    if path.is_empty() {
686        key
687    } else {
688        format!("{path}.{key}")
689    }
690}
691
692fn root_or(path: &str) -> String {
693    if path.is_empty() {
694        "(root)".to_string()
695    } else {
696        path.to_string()
697    }
698}
699
700/// JSON number equality that does not tell `1` from `1.0`.
701fn same_scalar(a: &Value, b: &Value) -> bool {
702    match (a, b) {
703        (Value::Number(x), Value::Number(y)) => {
704            // `1` equals `1.0`, but two integers are compared exactly: above
705            // 2^53 distinct i64/u64 values collapse to one f64 (WR-06).
706            if x.is_f64() || y.is_f64() {
707                matches!((x.as_f64(), y.as_f64()), (Some(x), Some(y)) if x == y)
708            } else {
709                x == y
710            }
711        }
712        _ => a == b,
713    }
714}
715
716/// The JSON paths at which `file` and `rest` differ (key order ignored).
717fn diff_exact(file: &Value, rest: &Value, path: &str, out: &mut Vec<String>) {
718    match (file, rest) {
719        (Value::Object(f), Value::Object(r)) => {
720            for (key, fv) in f {
721                match r.get(key) {
722                    Some(rv) => diff_exact(fv, rv, &child_path(path, key), out),
723                    None => out.push(child_path(path, key)),
724                }
725            }
726            for key in r.keys().filter(|k| !f.contains_key(*k)) {
727                out.push(child_path(path, key));
728            }
729        }
730        (Value::Array(f), Value::Array(r)) if f.len() == r.len() => {
731            for (i, (fv, rv)) in f.iter().zip(r).enumerate() {
732                diff_exact(fv, rv, &child_path(path, &i.to_string()), out);
733            }
734        }
735        (Value::Object(_) | Value::Array(_), _) | (_, Value::Object(_) | Value::Array(_)) => {
736            out.push(root_or(path));
737        }
738        (f, r) => {
739            if !same_scalar(f, r) {
740                out.push(root_or(path));
741            }
742        }
743    }
744}
745
746/// N2: every part of `file` must appear in `rest`; arrays of named objects
747/// are matched by `name`, other array elements must appear somewhere.
748fn diff_subset(file: &Value, rest: &Value, path: &str, out: &mut Vec<String>) {
749    match (file, rest) {
750        (Value::Object(f), Value::Object(r)) => {
751            for (key, fv) in f {
752                match r.get(key) {
753                    Some(rv) => diff_subset(fv, rv, &child_path(path, key), out),
754                    None => out.push(child_path(path, key)),
755                }
756            }
757        }
758        (Value::Array(f), Value::Array(r)) => {
759            for (i, fv) in f.iter().enumerate() {
760                let name = fv.get("name").and_then(Value::as_str);
761                if let (Some(name), true) = (name, fv.is_object()) {
762                    let at = format!("{path}[{}]", printable(name));
763                    match r
764                        .iter()
765                        .find(|rv| rv.get("name").and_then(Value::as_str) == Some(name))
766                    {
767                        Some(rv) => diff_subset(fv, rv, &at, out),
768                        None => out.push(at),
769                    }
770                } else {
771                    let found = r.iter().any(|rv| {
772                        let mut inner = Vec::new();
773                        diff_exact(fv, rv, "", &mut inner);
774                        inner.is_empty()
775                    });
776                    if !found {
777                        out.push(child_path(path, &i.to_string()));
778                    }
779                }
780            }
781        }
782        (Value::Object(_) | Value::Array(_), _) | (_, Value::Object(_) | Value::Array(_)) => {
783            out.push(root_or(path));
784        }
785        (f, r) => {
786            if !same_scalar(f, r) {
787                out.push(root_or(path));
788            }
789        }
790    }
791}
792
793/// N3: the attribute keys the REST config also carries must be equal.
794///
795/// The key lookup is on the RAW REST map: a REST `null` for a declared key is
796/// a present key, so it is compared (and differs from a declared value), not
797/// skipped as absent. Only the values are null-stripped (N1).
798fn diff_attributes(attrs: &Map<String, Value>, rest: &Value, out: &mut Vec<String>) {
799    let Some(rest) = rest.as_object() else {
800        out.push("(root)".to_string());
801        return;
802    };
803    for (key, expected) in attrs {
804        if let Some(seen) = rest.get(key) {
805            let mut inner = Vec::new();
806            diff_exact(&strip_nulls(expected), &strip_nulls(seen), "", &mut inner);
807            if !inner.is_empty() {
808                out.push(key.clone());
809            }
810        }
811    }
812}
813
814fn join_paths(mut paths: Vec<String>) -> String {
815    paths.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
816    paths.dedup();
817    let more = paths.len().saturating_sub(MAX_PATHS);
818    paths.truncate(MAX_PATHS);
819    let mut text = paths.join(", ");
820    if more > 0 {
821        text.push_str(&format!(" (+{more} more)"));
822    }
823    text
824}
825
826// ---- 4. files -----------------------------------------------------------------
827
828fn check_files(expected: &Expected, observed: &ObservedState) -> Check {
829    let mut findings = Vec::new();
830    for unit in &expected.tag_units {
831        let status = observed
832            .files
833            .iter()
834            .find(|(key, _)| key == unit)
835            .map(|(_, status)| *status);
836        match status {
837            Some(PlanStatus::Same) => {}
838            Some(other) => findings.push(finding(
839                "files_differ",
840                unit.clone(),
841                format!(
842                    "the effective live copy is `{}` against the render",
843                    other.word()
844                ),
845            )),
846            None => findings.push(finding(
847                "files_differ",
848                unit.clone(),
849                "the unit was not found among the live files",
850            )),
851        }
852    }
853    sort_findings(&mut findings);
854    finished(
855        CheckId::Files,
856        format!(
857            "{} declared tag unit(s) compared byte for byte with the live files",
858            expected.tag_units.len()
859        ),
860        findings,
861    )
862}
863
864// ---- 5. loaded ----------------------------------------------------------------
865
866fn check_loaded(expected: &Expected, observed: &ObservedState) -> Check {
867    if observed.scan.scan_active {
868        return Check {
869            id: CheckId::Loaded,
870            status: CheckStatus::Warn,
871            detail: "not observed: a scan is running, so its log lines are not readable yet"
872                .to_string(),
873            findings: Vec::new(),
874        };
875    }
876    let lines = match &observed.logs {
877        Ok(lines) => lines,
878        Err(slug) => {
879            return finished(
880                CheckId::Loaded,
881                "the gateway log could not be read".to_string(),
882                vec![finding("logs_unavailable", "logs", printable(slug))],
883            );
884        }
885    };
886
887    // The longest declared id that prefixes the resource text wins the line,
888    // so `Prod` does not claim a line about `Prod Backup`.
889    let mut hits: BTreeMap<&str, (i64, &str)> = BTreeMap::new();
890    for line in lines {
891        if !line.message.contains(WILL_NOT_BE_LOADED) {
892            continue;
893        }
894        let owner = expected
895            .declared_ids
896            .iter()
897            .filter(|id| line.message.contains(&format!("Resource {id} ")))
898            .max_by_key(|id| id.len());
899        if let Some(id) = owner {
900            let entry = hits
901                .entry(id.as_str())
902                .or_insert((line.timestamp, line.logger.as_str()));
903            if line.timestamp < entry.0 {
904                *entry = (line.timestamp, line.logger.as_str());
905            }
906        }
907    }
908    let mut findings: Vec<Finding> = hits
909        .into_iter()
910        .map(|(id, (timestamp, logger))| {
911            finding(
912                "unloaded_resource",
913                id,
914                format!("logged at {timestamp} by `{}`", printable(logger)),
915            )
916        })
917        .collect();
918    sort_findings(&mut findings);
919    let detail = if findings.is_empty() {
920        format!(
921            "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)"
922        )
923    } else {
924        format!("{} declared resource(s) were not loaded", findings.len())
925    };
926    finished(CheckId::Loaded, detail, findings)
927}
928
929// ---- 6. probes ----------------------------------------------------------------
930
931fn check_probes(expected: &Expected, observed: &ObservedState) -> Check {
932    if expected.probes.is_empty() {
933        return Check {
934            id: CheckId::Probes,
935            status: CheckStatus::Warn,
936            detail: "the manifest declares no `verify.tag_probes`; nothing was probed".to_string(),
937            findings: vec![finding(
938                "tag_probes_absent",
939                "verify.tag_probes",
940                "add tag paths to the manifest to probe them after a deploy",
941            )],
942        };
943    }
944    let readings = match &observed.probes {
945        Some(Ok(readings)) => readings,
946        Some(Err(slug)) => {
947            return finished(
948                CheckId::Probes,
949                "the probes could not be read".to_string(),
950                vec![finding("tag_probes_unavailable", "tags", printable(slug))],
951            );
952        }
953        None => {
954            return finished(
955                CheckId::Probes,
956                "the probes could not be read".to_string(),
957                vec![finding("tag_probes_unavailable", "tags", "not read")],
958            );
959        }
960    };
961    let mut findings = Vec::new();
962    for path in &expected.probes {
963        match readings.iter().find(|r| &r.path == path) {
964            Some(r) if r.quality.starts_with("Good") => {}
965            Some(r) => findings.push(finding(
966                "tag_probe_not_good",
967                path.clone(),
968                format!("quality {}", quality_code(&r.quality)),
969            )),
970            None => findings.push(finding("tag_probe_not_good", path.clone(), "no reading")),
971        }
972    }
973    // Manifest order, not sorted.
974    finished(
975        CheckId::Probes,
976        format!("{} tag probe(s) read", expected.probes.len()),
977        findings,
978    )
979}
980
981/// A quality string reduced to a code that cannot carry free text: the class
982/// (`Good`, `Bad`, `Uncertain`, `Error`) and, when the rest is plain letters,
983/// the Ignition suffix (`Bad_NotFound`).
984fn quality_code(quality: &str) -> String {
985    if quality.is_empty() {
986        return "empty".to_string();
987    }
988    let (class, rest) = match quality.split_once('_') {
989        Some((class, rest)) => (class, Some(rest)),
990        None => (quality, None),
991    };
992    if !matches!(class, "Good" | "Bad" | "Uncertain" | "Error") {
993        return "unrecognised".to_string();
994    }
995    match rest {
996        Some(rest)
997            if !rest.is_empty()
998                && rest.len() <= 48
999                && rest.chars().all(|c| c.is_ascii_alphabetic() || c == '_') =>
1000        {
1001            format!("{class}_{rest}")
1002        }
1003        _ => class.to_string(),
1004    }
1005}
1006
1007/// `text` limited to printable characters and [`MAX_CODE_CHARS`], for the few
1008/// gateway-supplied names that reach a finding (logger names, error slugs,
1009/// the active mode).
1010fn printable(text: &str) -> String {
1011    let mut out: String = text
1012        .chars()
1013        .filter(|c| !c.is_control())
1014        .take(MAX_CODE_CHARS)
1015        .collect();
1016    if text.chars().count() > MAX_CODE_CHARS {
1017        out.push_str("...");
1018    }
1019    out
1020}
1021
1022#[cfg(test)]
1023mod tests {
1024    use super::*;
1025    use serde_json::json;
1026
1027    /// The renderer words are the serialized words, so the human and JSON
1028    /// forms never drift.
1029    #[test]
1030    fn words_equal_the_serialized_forms() {
1031        for id in [
1032            CheckId::Scan,
1033            CheckId::Mode,
1034            CheckId::ReadBack,
1035            CheckId::Files,
1036            CheckId::Loaded,
1037            CheckId::Probes,
1038        ] {
1039            assert_eq!(json!(id), json!(id.word()));
1040        }
1041        for status in [CheckStatus::Pass, CheckStatus::Fail, CheckStatus::Warn] {
1042            assert_eq!(json!(status), json!(status.word()));
1043        }
1044    }
1045
1046    #[test]
1047    fn quality_codes_carry_no_free_text() {
1048        assert_eq!(quality_code("Good"), "Good");
1049        assert_eq!(quality_code("Good_Provisional"), "Good_Provisional");
1050        assert_eq!(quality_code("Bad_NotFound"), "Bad_NotFound");
1051        assert_eq!(quality_code("Bad_secret-123"), "Bad");
1052        assert_eq!(quality_code("Weird"), "unrecognised");
1053        assert_eq!(quality_code(""), "empty");
1054    }
1055
1056    #[test]
1057    fn printable_drops_controls_and_caps_length() {
1058        assert_eq!(printable("a\nb\u{7}c"), "abc");
1059        let long = "x".repeat(MAX_CODE_CHARS + 10);
1060        assert_eq!(printable(&long).len(), MAX_CODE_CHARS + 3);
1061    }
1062
1063    #[test]
1064    fn gateway_supplied_keys_are_sanitised_in_diff_paths() {
1065        // WR-05: a REST key with an escape sequence or a huge length must not
1066        // reach a finding raw.
1067        let hostile = format!("evil\u{1b}[31m{}", "k".repeat(MAX_CODE_CHARS * 2));
1068        let mut rest = serde_json::Map::new();
1069        rest.insert(hostile, json!(1));
1070        let mut out = Vec::new();
1071        diff_exact(&json!({}), &Value::Object(rest), "", &mut out);
1072        assert_eq!(out.len(), 1);
1073        assert!(!out[0].contains('\u{1b}'), "{:?}", out[0]);
1074        assert!(
1075            out[0].chars().count() <= MAX_CODE_CHARS + 3,
1076            "{}",
1077            out[0].len()
1078        );
1079    }
1080
1081    #[test]
1082    fn large_integers_compare_exactly() {
1083        // WR-06: 2^53 + 1 and 2^53 are the same f64 but different integers.
1084        let mut out = Vec::new();
1085        diff_exact(
1086            &json!({"id": 9_007_199_254_740_993_i64}),
1087            &json!({"id": 9_007_199_254_740_992_i64}),
1088            "",
1089            &mut out,
1090        );
1091        assert_eq!(out, vec!["id".to_string()]);
1092        let mut out = Vec::new();
1093        diff_exact(
1094            &json!({"id": u64::MAX}),
1095            &json!({"id": u64::MAX - 1}),
1096            "",
1097            &mut out,
1098        );
1099        assert_eq!(out, vec!["id".to_string()]);
1100        let mut out = Vec::new();
1101        diff_exact(
1102            &json!({"id": 9_007_199_254_740_993_i64}),
1103            &json!({"id": 9_007_199_254_740_993_i64}),
1104            "",
1105            &mut out,
1106        );
1107        assert!(out.is_empty());
1108    }
1109
1110    #[test]
1111    fn numbers_compare_by_value() {
1112        let mut out = Vec::new();
1113        diff_exact(
1114            &json!({"a": 1, "b": 2.5}),
1115            &json!({"a": 1.0, "b": 2.5}),
1116            "",
1117            &mut out,
1118        );
1119        assert!(out.is_empty(), "{out:?}");
1120        diff_exact(&json!({"a": 1}), &json!({"a": 2}), "", &mut out);
1121        assert_eq!(out, vec!["a".to_string()]);
1122    }
1123
1124    #[test]
1125    fn path_list_is_sorted_deduplicated_and_capped() {
1126        let paths: Vec<String> = (0..MAX_PATHS + 3)
1127            .rev()
1128            .map(|i| format!("p{i:02}"))
1129            .collect();
1130        let text = join_paths(paths);
1131        assert!(text.starts_with("p00, p01"), "{text}");
1132        assert!(text.ends_with("(+3 more)"), "{text}");
1133    }
1134}