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`, `unreadable`, `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    /// The units with a live path the host's sftp server cannot read
463    /// (quick-261029-ms3): unit key and whether it is declared. Never
464    /// treated as absent: a declared one is a red `unreadable` finding, an
465    /// undeclared one a warning.
466    pub unreadable: Vec<(String, bool)>,
467}
468
469/// The start of the log window for the last scan (CV1), epoch ms on the
470/// gateway clock.
471pub fn log_window_start(scan: &ScanStatus) -> i64 {
472    let duration = scan.last_scan_duration.unwrap_or(LOG_WINDOW_FALLBACK_MS);
473    (scan.last_scan_timestamp - duration - LOG_WINDOW_SLACK_MS).max(0)
474}
475
476/// Evaluate `observed` against `expected`.
477pub fn evaluate(expected: &Expected, observed: &ObservedState) -> VerifyReport {
478    let mut warnings = Vec::new();
479    let (read_back, resources_checked) = check_read_back(expected, observed, &mut warnings);
480    let checks = vec![
481        check_scan(observed),
482        check_mode(expected, observed),
483        read_back,
484        check_files(expected, observed, &mut warnings),
485        check_loaded(expected, observed),
486        check_probes(expected, observed),
487    ];
488    for check in &checks {
489        if check.status == CheckStatus::Warn {
490            warnings.push(format!("{}: {}", check_word(check.id), check.detail));
491        }
492    }
493    let ok = checks.iter().all(|c| c.status != CheckStatus::Fail);
494    VerifyReport {
495        ok,
496        slug: (!ok).then_some(VERIFY_FAILED_SLUG),
497        checks,
498        resources_checked,
499        warnings,
500    }
501}
502
503fn check_word(id: CheckId) -> &'static str {
504    match id {
505        CheckId::Scan => "scan",
506        CheckId::Mode => "mode",
507        CheckId::ReadBack => "read_back",
508        CheckId::Files => "files",
509        CheckId::Loaded => "loaded",
510        CheckId::Probes => "probes",
511    }
512}
513
514fn finding(code: &'static str, subject: impl Into<String>, detail: impl Into<String>) -> Finding {
515    Finding {
516        code,
517        subject: subject.into(),
518        detail: detail.into(),
519    }
520}
521
522/// Sort findings by subject bytes, then code, so the report does not depend
523/// on input order.
524fn sort_findings(findings: &mut [Finding]) {
525    findings.sort_by(|a, b| {
526        a.subject
527            .as_bytes()
528            .cmp(b.subject.as_bytes())
529            .then_with(|| a.code.cmp(b.code))
530    });
531}
532
533fn finished(id: CheckId, detail: String, findings: Vec<Finding>) -> Check {
534    Check {
535        id,
536        status: if findings.is_empty() {
537            CheckStatus::Pass
538        } else {
539            CheckStatus::Fail
540        },
541        detail,
542        findings,
543    }
544}
545
546// ---- 1. scan ------------------------------------------------------------------
547
548fn check_scan(observed: &ObservedState) -> Check {
549    if observed.scan.scan_active {
550        return finished(
551            CheckId::Scan,
552            "a config scan is running".to_string(),
553            vec![finding(
554                "scan_active",
555                "config",
556                "the gateway reports an active config scan; verify reads settled state only",
557            )],
558        );
559    }
560    finished(
561        CheckId::Scan,
562        "no config scan is running".to_string(),
563        Vec::new(),
564    )
565}
566
567// ---- 2. mode ------------------------------------------------------------------
568
569fn check_mode(expected: &Expected, observed: &ObservedState) -> Check {
570    mode_check(expected, observed.mode.as_deref())
571}
572
573/// The `mode` check over an already-read, normalised mode. The backup node's
574/// check shares it with the master's (24-05).
575pub(crate) fn mode_check(expected: &Expected, mode: Option<&str>) -> Check {
576    if mode == Some(expected.mode.as_str()) {
577        return finished(
578            CheckId::Mode,
579            format!("the gateway runs mode `{}`", expected.mode),
580            Vec::new(),
581        );
582    }
583    let seen = match mode {
584        Some(mode) => format!("`{}`", printable(mode)),
585        None => "no mode".to_string(),
586    };
587    finished(
588        CheckId::Mode,
589        "the gateway does not run the target's mode".to_string(),
590        vec![finding(
591            "mode_mismatch",
592            "mode",
593            format!("expected `{}`, the gateway reports {seen}", expected.mode),
594        )],
595    )
596}
597
598// ---- 3. read_back -------------------------------------------------------------
599
600fn check_read_back(
601    expected: &Expected,
602    observed: &ObservedState,
603    warnings: &mut Vec<String>,
604) -> (Check, usize) {
605    read_back_check(expected, &observed.read_back, warnings)
606}
607
608/// The `read_back` check over already-read resources. The backup node's check
609/// shares it with the master's (24-05).
610pub(crate) fn read_back_check(
611    expected: &Expected,
612    read_back: &BTreeMap<String, Remote>,
613    warnings: &mut Vec<String>,
614) -> (Check, usize) {
615    let mut findings = Vec::new();
616    let mut checked = 0usize;
617    for res in &expected.resources {
618        match read_back.get(&res.key).unwrap_or(&Remote::Missing) {
619            Remote::Missing => {
620                checked += 1;
621                findings.push(finding(
622                    "resource_missing",
623                    res.key.clone(),
624                    "not in the gateway's effective view",
625                ));
626            }
627            Remote::System => warnings.push(format!(
628                "read_back: {} not compared: the effective copy is in the `{SYSTEM_COLLECTION}` collection (gateway-provided, not file-backed)",
629                res.key
630            )),
631            Remote::Config(rest) => {
632                checked += 1;
633                let mut paths = Vec::new();
634                match &res.compare {
635                    Compare::Exact(file) => {
636                        let rest = strip_nulls(rest);
637                        diff_exact(&strip_nulls(file), &rest, "", &mut paths);
638                    }
639                    Compare::NameKeyedSubset(file) => {
640                        let rest = strip_nulls(rest);
641                        diff_subset(&strip_nulls(file), &rest, "", &mut paths);
642                    }
643                    // the key lookup needs the RAW map (see `diff_attributes`)
644                    Compare::Attributes(attrs) => diff_attributes(attrs, rest, &mut paths),
645                }
646                if !paths.is_empty() {
647                    findings.push(finding(
648                        "resource_differs",
649                        res.key.clone(),
650                        format!("differs at: {}", join_paths(paths)),
651                    ));
652                }
653            }
654        }
655    }
656    for (key, reason) in &expected.not_checked {
657        warnings.push(format!("read_back: {key} not compared: {reason}"));
658    }
659    if checked == 0 && findings.is_empty() {
660        warnings.push(
661            "read_back: no declared non-tag resource was compared (nothing to read back)"
662                .to_string(),
663        );
664    }
665    sort_findings(&mut findings);
666    let detail = format!("{checked} declared resource(s) compared with the REST effective view");
667    (finished(CheckId::ReadBack, detail, findings), checked)
668}
669
670/// `value` with every JSON `null` object member removed, at every depth (N1).
671/// Applied to the file value and to the REST value alike, so a null member
672/// equals an absent one on either side.
673fn strip_nulls(value: &Value) -> Value {
674    match value {
675        Value::Object(map) => Value::Object(
676            map.iter()
677                .filter(|(_, v)| !v.is_null())
678                .map(|(k, v)| (k.clone(), strip_nulls(v)))
679                .collect(),
680        ),
681        Value::Array(items) => Value::Array(items.iter().map(strip_nulls).collect()),
682        other => other.clone(),
683    }
684}
685
686fn child_path(path: &str, key: &str) -> String {
687    // the key may be gateway-supplied (REST body): no control characters and
688    // a bounded length, like every other gateway string in a finding
689    let key = printable(key);
690    if path.is_empty() {
691        key
692    } else {
693        format!("{path}.{key}")
694    }
695}
696
697fn root_or(path: &str) -> String {
698    if path.is_empty() {
699        "(root)".to_string()
700    } else {
701        path.to_string()
702    }
703}
704
705/// JSON number equality that does not tell `1` from `1.0`.
706fn same_scalar(a: &Value, b: &Value) -> bool {
707    match (a, b) {
708        (Value::Number(x), Value::Number(y)) => {
709            // `1` equals `1.0`, but two integers are compared exactly: above
710            // 2^53 distinct i64/u64 values collapse to one f64 (WR-06).
711            if x.is_f64() || y.is_f64() {
712                matches!((x.as_f64(), y.as_f64()), (Some(x), Some(y)) if x == y)
713            } else {
714                x == y
715            }
716        }
717        _ => a == b,
718    }
719}
720
721/// The JSON paths at which `file` and `rest` differ (key order ignored).
722fn diff_exact(file: &Value, rest: &Value, path: &str, out: &mut Vec<String>) {
723    match (file, rest) {
724        (Value::Object(f), Value::Object(r)) => {
725            for (key, fv) in f {
726                match r.get(key) {
727                    Some(rv) => diff_exact(fv, rv, &child_path(path, key), out),
728                    None => out.push(child_path(path, key)),
729                }
730            }
731            for key in r.keys().filter(|k| !f.contains_key(*k)) {
732                out.push(child_path(path, key));
733            }
734        }
735        (Value::Array(f), Value::Array(r)) if f.len() == r.len() => {
736            for (i, (fv, rv)) in f.iter().zip(r).enumerate() {
737                diff_exact(fv, rv, &child_path(path, &i.to_string()), out);
738            }
739        }
740        (Value::Object(_) | Value::Array(_), _) | (_, Value::Object(_) | Value::Array(_)) => {
741            out.push(root_or(path));
742        }
743        (f, r) => {
744            if !same_scalar(f, r) {
745                out.push(root_or(path));
746            }
747        }
748    }
749}
750
751/// N2: every part of `file` must appear in `rest`; arrays of named objects
752/// are matched by `name`, other array elements must appear somewhere.
753fn diff_subset(file: &Value, rest: &Value, path: &str, out: &mut Vec<String>) {
754    match (file, rest) {
755        (Value::Object(f), Value::Object(r)) => {
756            for (key, fv) in f {
757                match r.get(key) {
758                    Some(rv) => diff_subset(fv, rv, &child_path(path, key), out),
759                    None => out.push(child_path(path, key)),
760                }
761            }
762        }
763        (Value::Array(f), Value::Array(r)) => {
764            for (i, fv) in f.iter().enumerate() {
765                let name = fv.get("name").and_then(Value::as_str);
766                if let (Some(name), true) = (name, fv.is_object()) {
767                    let at = format!("{path}[{}]", printable(name));
768                    match r
769                        .iter()
770                        .find(|rv| rv.get("name").and_then(Value::as_str) == Some(name))
771                    {
772                        Some(rv) => diff_subset(fv, rv, &at, out),
773                        None => out.push(at),
774                    }
775                } else {
776                    let found = r.iter().any(|rv| {
777                        let mut inner = Vec::new();
778                        diff_exact(fv, rv, "", &mut inner);
779                        inner.is_empty()
780                    });
781                    if !found {
782                        out.push(child_path(path, &i.to_string()));
783                    }
784                }
785            }
786        }
787        (Value::Object(_) | Value::Array(_), _) | (_, Value::Object(_) | Value::Array(_)) => {
788            out.push(root_or(path));
789        }
790        (f, r) => {
791            if !same_scalar(f, r) {
792                out.push(root_or(path));
793            }
794        }
795    }
796}
797
798/// N3: the attribute keys the REST config also carries must be equal.
799///
800/// The key lookup is on the RAW REST map: a REST `null` for a declared key is
801/// a present key, so it is compared (and differs from a declared value), not
802/// skipped as absent. Only the values are null-stripped (N1).
803fn diff_attributes(attrs: &Map<String, Value>, rest: &Value, out: &mut Vec<String>) {
804    let Some(rest) = rest.as_object() else {
805        out.push("(root)".to_string());
806        return;
807    };
808    for (key, expected) in attrs {
809        if let Some(seen) = rest.get(key) {
810            let mut inner = Vec::new();
811            diff_exact(&strip_nulls(expected), &strip_nulls(seen), "", &mut inner);
812            if !inner.is_empty() {
813                out.push(key.clone());
814            }
815        }
816    }
817}
818
819fn join_paths(mut paths: Vec<String>) -> String {
820    paths.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
821    paths.dedup();
822    let more = paths.len().saturating_sub(MAX_PATHS);
823    paths.truncate(MAX_PATHS);
824    let mut text = paths.join(", ");
825    if more > 0 {
826        text.push_str(&format!(" (+{more} more)"));
827    }
828    text
829}
830
831// ---- 4. files -----------------------------------------------------------------
832
833/// The `unreadable` finding's detail.
834const UNREADABLE_DETAIL: &str =
835    "the unit has a live path the host's sftp server cannot read; its live copy is unknown";
836
837fn check_files(expected: &Expected, observed: &ObservedState, warnings: &mut Vec<String>) -> Check {
838    let mut findings = Vec::new();
839    for unit in &expected.tag_units {
840        // Never absent: an unreadable unit is not "not found".
841        if observed.unreadable.iter().any(|(key, _)| key == unit) {
842            findings.push(finding("unreadable", unit.clone(), UNREADABLE_DETAIL));
843            continue;
844        }
845        let status = observed
846            .files
847            .iter()
848            .find(|(key, _)| key == unit)
849            .map(|(_, status)| *status);
850        match status {
851            Some(PlanStatus::Same) => {}
852            Some(other) => findings.push(finding(
853                "files_differ",
854                unit.clone(),
855                format!(
856                    "the effective live copy is `{}` against the render",
857                    other.word()
858                ),
859            )),
860            None => findings.push(finding(
861                "files_differ",
862                unit.clone(),
863                "the unit was not found among the live files",
864            )),
865        }
866    }
867    for (key, declared) in &observed.unreadable {
868        if expected.tag_units.contains(key) {
869            continue;
870        }
871        if *declared {
872            findings.push(finding("unreadable", key.clone(), UNREADABLE_DETAIL));
873        } else {
874            warnings.push(format!(
875                "files: {key} is unreadable on the host and is not declared; it was not compared"
876            ));
877        }
878    }
879    sort_findings(&mut findings);
880    finished(
881        CheckId::Files,
882        format!(
883            "{} declared tag unit(s) compared byte for byte with the live files",
884            expected.tag_units.len()
885        ),
886        findings,
887    )
888}
889
890// ---- 5. loaded ----------------------------------------------------------------
891
892fn check_loaded(expected: &Expected, observed: &ObservedState) -> Check {
893    if observed.scan.scan_active {
894        return Check {
895            id: CheckId::Loaded,
896            status: CheckStatus::Warn,
897            detail: "not observed: a scan is running, so its log lines are not readable yet"
898                .to_string(),
899            findings: Vec::new(),
900        };
901    }
902    let lines = match &observed.logs {
903        Ok(lines) => lines,
904        Err(slug) => {
905            return finished(
906                CheckId::Loaded,
907                "the gateway log could not be read".to_string(),
908                vec![finding("logs_unavailable", "logs", printable(slug))],
909            );
910        }
911    };
912
913    // The longest declared id that prefixes the resource text wins the line,
914    // so `Prod` does not claim a line about `Prod Backup`.
915    let mut hits: BTreeMap<&str, (i64, &str)> = BTreeMap::new();
916    for line in lines {
917        if !line.message.contains(WILL_NOT_BE_LOADED) {
918            continue;
919        }
920        let owner = expected
921            .declared_ids
922            .iter()
923            .filter(|id| line.message.contains(&format!("Resource {id} ")))
924            .max_by_key(|id| id.len());
925        if let Some(id) = owner {
926            let entry = hits
927                .entry(id.as_str())
928                .or_insert((line.timestamp, line.logger.as_str()));
929            if line.timestamp < entry.0 {
930                *entry = (line.timestamp, line.logger.as_str());
931            }
932        }
933    }
934    let mut findings: Vec<Finding> = hits
935        .into_iter()
936        .map(|(id, (timestamp, logger))| {
937            finding(
938                "unloaded_resource",
939                id,
940                format!("logged at {timestamp} by `{}`", printable(logger)),
941            )
942        })
943        .collect();
944    sort_findings(&mut findings);
945    let detail = if findings.is_empty() {
946        format!(
947            "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)"
948        )
949    } else {
950        format!("{} declared resource(s) were not loaded", findings.len())
951    };
952    finished(CheckId::Loaded, detail, findings)
953}
954
955// ---- 6. probes ----------------------------------------------------------------
956
957fn check_probes(expected: &Expected, observed: &ObservedState) -> Check {
958    if expected.probes.is_empty() {
959        return Check {
960            id: CheckId::Probes,
961            status: CheckStatus::Warn,
962            detail: "the manifest declares no `verify.tag_probes`; nothing was probed".to_string(),
963            findings: vec![finding(
964                "tag_probes_absent",
965                "verify.tag_probes",
966                "add tag paths to the manifest to probe them after a deploy",
967            )],
968        };
969    }
970    let readings = match &observed.probes {
971        Some(Ok(readings)) => readings,
972        Some(Err(slug)) => {
973            return finished(
974                CheckId::Probes,
975                "the probes could not be read".to_string(),
976                vec![finding("tag_probes_unavailable", "tags", printable(slug))],
977            );
978        }
979        None => {
980            return finished(
981                CheckId::Probes,
982                "the probes could not be read".to_string(),
983                vec![finding("tag_probes_unavailable", "tags", "not read")],
984            );
985        }
986    };
987    let mut findings = Vec::new();
988    for path in &expected.probes {
989        match readings.iter().find(|r| &r.path == path) {
990            Some(r) if r.quality.starts_with("Good") => {}
991            Some(r) => findings.push(finding(
992                "tag_probe_not_good",
993                path.clone(),
994                format!("quality {}", quality_code(&r.quality)),
995            )),
996            None => findings.push(finding("tag_probe_not_good", path.clone(), "no reading")),
997        }
998    }
999    // Manifest order, not sorted.
1000    finished(
1001        CheckId::Probes,
1002        format!("{} tag probe(s) read", expected.probes.len()),
1003        findings,
1004    )
1005}
1006
1007/// A quality string reduced to a code that cannot carry free text: the class
1008/// (`Good`, `Bad`, `Uncertain`, `Error`) and, when the rest is plain letters,
1009/// the Ignition suffix (`Bad_NotFound`).
1010fn quality_code(quality: &str) -> String {
1011    if quality.is_empty() {
1012        return "empty".to_string();
1013    }
1014    let (class, rest) = match quality.split_once('_') {
1015        Some((class, rest)) => (class, Some(rest)),
1016        None => (quality, None),
1017    };
1018    if !matches!(class, "Good" | "Bad" | "Uncertain" | "Error") {
1019        return "unrecognised".to_string();
1020    }
1021    match rest {
1022        Some(rest)
1023            if !rest.is_empty()
1024                && rest.len() <= 48
1025                && rest.chars().all(|c| c.is_ascii_alphabetic() || c == '_') =>
1026        {
1027            format!("{class}_{rest}")
1028        }
1029        _ => class.to_string(),
1030    }
1031}
1032
1033/// `text` limited to printable characters and [`MAX_CODE_CHARS`], for the few
1034/// gateway-supplied names that reach a finding (logger names, error slugs,
1035/// the active mode).
1036fn printable(text: &str) -> String {
1037    let mut out: String = text
1038        .chars()
1039        .filter(|c| !c.is_control())
1040        .take(MAX_CODE_CHARS)
1041        .collect();
1042    if text.chars().count() > MAX_CODE_CHARS {
1043        out.push_str("...");
1044    }
1045    out
1046}
1047
1048#[cfg(test)]
1049mod tests {
1050    use super::*;
1051    use serde_json::json;
1052
1053    /// The renderer words are the serialized words, so the human and JSON
1054    /// forms never drift.
1055    #[test]
1056    fn words_equal_the_serialized_forms() {
1057        for id in [
1058            CheckId::Scan,
1059            CheckId::Mode,
1060            CheckId::ReadBack,
1061            CheckId::Files,
1062            CheckId::Loaded,
1063            CheckId::Probes,
1064        ] {
1065            assert_eq!(json!(id), json!(id.word()));
1066        }
1067        for status in [CheckStatus::Pass, CheckStatus::Fail, CheckStatus::Warn] {
1068            assert_eq!(json!(status), json!(status.word()));
1069        }
1070    }
1071
1072    #[test]
1073    fn quality_codes_carry_no_free_text() {
1074        assert_eq!(quality_code("Good"), "Good");
1075        assert_eq!(quality_code("Good_Provisional"), "Good_Provisional");
1076        assert_eq!(quality_code("Bad_NotFound"), "Bad_NotFound");
1077        assert_eq!(quality_code("Bad_secret-123"), "Bad");
1078        assert_eq!(quality_code("Weird"), "unrecognised");
1079        assert_eq!(quality_code(""), "empty");
1080    }
1081
1082    #[test]
1083    fn printable_drops_controls_and_caps_length() {
1084        assert_eq!(printable("a\nb\u{7}c"), "abc");
1085        let long = "x".repeat(MAX_CODE_CHARS + 10);
1086        assert_eq!(printable(&long).len(), MAX_CODE_CHARS + 3);
1087    }
1088
1089    #[test]
1090    fn gateway_supplied_keys_are_sanitised_in_diff_paths() {
1091        // WR-05: a REST key with an escape sequence or a huge length must not
1092        // reach a finding raw.
1093        let hostile = format!("evil\u{1b}[31m{}", "k".repeat(MAX_CODE_CHARS * 2));
1094        let mut rest = serde_json::Map::new();
1095        rest.insert(hostile, json!(1));
1096        let mut out = Vec::new();
1097        diff_exact(&json!({}), &Value::Object(rest), "", &mut out);
1098        assert_eq!(out.len(), 1);
1099        assert!(!out[0].contains('\u{1b}'), "{:?}", out[0]);
1100        assert!(
1101            out[0].chars().count() <= MAX_CODE_CHARS + 3,
1102            "{}",
1103            out[0].len()
1104        );
1105    }
1106
1107    #[test]
1108    fn large_integers_compare_exactly() {
1109        // WR-06: 2^53 + 1 and 2^53 are the same f64 but different integers.
1110        let mut out = Vec::new();
1111        diff_exact(
1112            &json!({"id": 9_007_199_254_740_993_i64}),
1113            &json!({"id": 9_007_199_254_740_992_i64}),
1114            "",
1115            &mut out,
1116        );
1117        assert_eq!(out, vec!["id".to_string()]);
1118        let mut out = Vec::new();
1119        diff_exact(
1120            &json!({"id": u64::MAX}),
1121            &json!({"id": u64::MAX - 1}),
1122            "",
1123            &mut out,
1124        );
1125        assert_eq!(out, vec!["id".to_string()]);
1126        let mut out = Vec::new();
1127        diff_exact(
1128            &json!({"id": 9_007_199_254_740_993_i64}),
1129            &json!({"id": 9_007_199_254_740_993_i64}),
1130            "",
1131            &mut out,
1132        );
1133        assert!(out.is_empty());
1134    }
1135
1136    #[test]
1137    fn numbers_compare_by_value() {
1138        let mut out = Vec::new();
1139        diff_exact(
1140            &json!({"a": 1, "b": 2.5}),
1141            &json!({"a": 1.0, "b": 2.5}),
1142            "",
1143            &mut out,
1144        );
1145        assert!(out.is_empty(), "{out:?}");
1146        diff_exact(&json!({"a": 1}), &json!({"a": 2}), "", &mut out);
1147        assert_eq!(out, vec!["a".to_string()]);
1148    }
1149
1150    #[test]
1151    fn path_list_is_sorted_deduplicated_and_capped() {
1152        let paths: Vec<String> = (0..MAX_PATHS + 3)
1153            .rev()
1154            .map(|i| format!("p{i:02}"))
1155            .collect();
1156        let text = join_paths(paths);
1157        assert!(text.starts_with("p00, p01"), "{text}");
1158        assert!(text.ends_with("(+3 more)"), "{text}");
1159    }
1160}