1use std::collections::{BTreeMap, BTreeSet};
79use std::fmt;
80use std::time::Duration;
81
82use serde::Serialize;
83use serde_json::{Map, Value};
84
85use super::layout::TAG_TYPES;
86use super::manifest::Manifest;
87use super::plan::{PlanStatus, unit_key};
88use super::render::RenderOutcome;
89use crate::client::scan::ScanStatus;
90use crate::error::APPLY_LIST_SHOWN;
91
92pub const VERIFY_FAILED_SLUG: &str = "deploy_verify_failed";
94
95pub const PROBE_DEADLINE: Duration = Duration::from_secs(30);
97
98pub const PROBE_INTERVAL: Duration = Duration::from_secs(1);
100
101pub const LOG_WINDOW_SLACK_MS: i64 = 1_000;
105
106pub const LOG_WINDOW_FALLBACK_MS: i64 = 30_000;
108
109pub const LOG_SETTLE_MS: i64 = 5_000;
112
113pub const WILL_NOT_BE_LOADED: &str = "will not be loaded";
115
116pub const NO_READ_ROUTE: &[(&str, &str)] = &[
119 ("com.inductiveautomation.opcua", "one-time"),
120 ("ignition", "images"),
121];
122
123pub const SYSTEM_COLLECTION: &str = "system";
125
126const MAX_PATHS: usize = 20;
128
129const ROOT_PATH: &str = "(root)";
131
132const MAX_CODE_CHARS: usize = 96;
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
137#[serde(rename_all = "snake_case")]
138pub enum CheckId {
139 Scan,
141 Mode,
143 ReadBack,
145 Files,
147 Loaded,
149 Probes,
151}
152
153impl CheckId {
154 pub fn word(self) -> &'static str {
157 match self {
158 Self::Scan => "scan",
159 Self::Mode => "mode",
160 Self::ReadBack => "read_back",
161 Self::Files => "files",
162 Self::Loaded => "loaded",
163 Self::Probes => "probes",
164 }
165 }
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
170#[serde(rename_all = "lowercase")]
171pub enum CheckStatus {
172 Pass,
174 Fail,
176 Warn,
178}
179
180impl CheckStatus {
181 pub fn word(self) -> &'static str {
183 match self {
184 Self::Pass => "pass",
185 Self::Fail => "fail",
186 Self::Warn => "warn",
187 }
188 }
189}
190
191#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
193pub struct Finding {
194 pub code: &'static str,
199 pub subject: String,
201 pub detail: String,
203 #[serde(skip_serializing_if = "Option::is_none")]
207 pub status: Option<CheckStatus>,
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
212pub struct Check {
213 pub id: CheckId,
215 pub status: CheckStatus,
217 pub detail: String,
219 pub findings: Vec<Finding>,
221}
222
223#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
225pub struct VerifyReport {
226 pub ok: bool,
228 pub slug: Option<&'static str>,
230 pub checks: Vec<Check>,
232 pub resources_checked: usize,
234 pub warnings: Vec<String>,
236}
237
238#[derive(Clone, PartialEq)]
240pub enum Compare {
241 Exact(Value),
243 NameKeyedSubset(Value),
245 Attributes(Map<String, Value>),
247}
248
249impl fmt::Debug for Compare {
250 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
251 let kind = match self {
252 Compare::Exact(_) => "Exact",
253 Compare::NameKeyedSubset(_) => "NameKeyedSubset",
254 Compare::Attributes(_) => "Attributes",
255 };
256 write!(f, "{kind}(<withheld>)")
257 }
258}
259
260#[derive(Debug, Clone, PartialEq)]
262pub struct ExpectedResource {
263 pub key: String,
265 pub module: String,
267 pub ty: String,
269 pub name: Option<String>,
271 pub compare: Compare,
273}
274
275impl ExpectedResource {
276 pub fn exact(module: &str, ty: &str, name: Option<&str>, config: Value) -> Self {
278 Self::new(module, ty, name, Compare::Exact(config))
279 }
280
281 pub fn new(module: &str, ty: &str, name: Option<&str>, compare: Compare) -> Self {
283 Self {
284 key: resource_id(module, ty, name),
285 module: module.to_string(),
286 ty: ty.to_string(),
287 name: name.map(str::to_string),
288 compare,
289 }
290 }
291}
292
293pub fn resource_id(module: &str, ty: &str, name: Option<&str>) -> String {
295 match name {
296 Some(name) => format!("{module}/{ty}/{name}"),
297 None => format!("{module}/{ty}"),
298 }
299}
300
301#[derive(Debug, Clone, PartialEq)]
303pub struct Expected {
304 pub mode: String,
306 pub resources: Vec<ExpectedResource>,
308 pub not_checked: Vec<(String, String)>,
311 pub declared_ids: Vec<String>,
314 pub tag_units: Vec<String>,
316 pub probes: Vec<String>,
318}
319
320impl Expected {
321 pub fn from_render(target_mode: &str, outcome: &RenderOutcome, manifest: &Manifest) -> Self {
323 let files = &outcome.tree.files;
324 let is_tag = |module: &str, ty: &str| module == "ignition" && TAG_TYPES.contains(&ty);
325
326 let mut tag_units = BTreeSet::new();
327 for path in files.keys() {
328 let mut parts = path.components();
329 if let (Some(module), Some(ty)) = (parts.next(), parts.next())
330 && is_tag(module, ty)
331 {
332 tag_units.insert(unit_key(path, files));
333 }
334 }
335
336 let mut resources = BTreeMap::new();
337 let mut not_checked = BTreeMap::new();
338 let mut declared_ids = BTreeSet::new();
339 for res in &outcome.resources {
340 if is_tag(&res.module, &res.ty) {
341 continue;
342 }
343 let key = resource_id(&res.module, &res.ty, res.name.as_deref());
344 declared_ids.insert(key.clone());
345 if NO_READ_ROUTE
346 .iter()
347 .any(|(m, t)| *m == res.module && *t == res.ty)
348 {
349 not_checked.insert(key, "no REST read route".to_string());
350 continue;
351 }
352 let file = |name: &str| {
353 super::layout::RelPath::new(&format!("{key}/{name}"))
354 .ok()
355 .and_then(|p| files.get(&p))
356 .map(Vec::as_slice)
357 };
358 let compare = match compare_for(&res.module, &res.ty, file) {
359 Ok(compare) => compare,
360 Err(reason) => {
361 not_checked.insert(key, reason.to_string());
362 continue;
363 }
364 };
365 resources.insert(
366 key.clone(),
367 ExpectedResource {
368 key,
369 module: res.module.clone(),
370 ty: res.ty.clone(),
371 name: res.name.clone(),
372 compare,
373 },
374 );
375 }
376
377 Expected {
378 mode: target_mode.to_string(),
379 resources: resources.into_values().collect(),
380 not_checked: not_checked.into_iter().collect(),
381 declared_ids: declared_ids.into_iter().collect(),
382 tag_units: tag_units.into_iter().collect(),
383 probes: manifest
384 .verify
385 .as_ref()
386 .map(|v| v.tag_probes.clone())
387 .unwrap_or_default(),
388 }
389 }
390}
391
392pub fn compare_for<'f>(
400 module: &str,
401 ty: &str,
402 file: impl Fn(&str) -> Option<&'f [u8]>,
403) -> Result<Compare, &'static str> {
404 if let Some(bytes) = file("config.json") {
405 match serde_json::from_slice::<Value>(bytes) {
406 Ok(value) if module == "ignition" && ty == "security-levels" => {
407 Ok(Compare::NameKeyedSubset(value))
408 }
409 Ok(value) => Ok(Compare::Exact(value)),
410 Err(_) => Err("config.json is not JSON"),
411 }
412 } else if let Some(attributes) = file("resource.json")
413 .and_then(|bytes| serde_json::from_slice::<Value>(bytes).ok())
414 .and_then(|v| v.get("attributes").and_then(Value::as_object).cloned())
415 {
416 Ok(Compare::Attributes(attributes))
417 } else {
418 Err("no config.json to compare")
419 }
420}
421
422#[derive(Clone, PartialEq)]
424pub enum Remote {
425 Missing,
427 Config(Value),
429 System,
431}
432
433impl fmt::Debug for Remote {
434 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
435 match self {
436 Remote::Missing => f.write_str("Missing"),
437 Remote::Config(_) => f.write_str("Config(<withheld>)"),
438 Remote::System => f.write_str("System"),
439 }
440 }
441}
442
443#[derive(Clone, PartialEq, Eq)]
445pub struct LogLine {
446 pub timestamp: i64,
448 pub logger: String,
450 pub message: String,
452}
453
454impl fmt::Debug for LogLine {
455 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
456 f.debug_struct("LogLine")
457 .field("timestamp", &self.timestamp)
458 .field("logger", &self.logger)
459 .field("message", &"<withheld>")
460 .finish()
461 }
462}
463
464#[derive(Clone, PartialEq, Eq)]
466pub struct ProbeReading {
467 pub path: String,
469 pub quality: String,
471}
472
473impl fmt::Debug for ProbeReading {
474 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
475 f.debug_struct("ProbeReading")
476 .field("path", &self.path)
477 .field("quality", &quality_code(&self.quality))
478 .finish()
479 }
480}
481
482#[derive(Debug, Clone)]
484pub struct ObservedState {
485 pub scan: ScanStatus,
487 pub mode: Option<String>,
489 pub read_back: BTreeMap<String, Remote>,
491 pub files: Vec<(String, PlanStatus)>,
493 pub logs: Result<Vec<LogLine>, String>,
496 pub probes: Option<Result<Vec<ProbeReading>, String>>,
499 pub unreadable: Vec<(String, bool)>,
504 pub mode_copies: BTreeMap<String, Vec<String>>,
509}
510
511pub fn log_window_start(scan: &ScanStatus) -> i64 {
514 let duration = scan.last_scan_duration.unwrap_or(LOG_WINDOW_FALLBACK_MS);
515 (scan.last_scan_timestamp - duration - LOG_WINDOW_SLACK_MS).max(0)
516}
517
518#[derive(Debug, Clone, Copy)]
521pub enum ReadBackJudge<'a> {
522 Strict,
524 Standalone {
527 file_status: &'a BTreeMap<String, PlanStatus>,
529 },
530 Baseline(&'a ReadBackBaseline),
532}
533
534#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
536#[serde(rename_all = "snake_case")]
537pub enum BaselineBasis {
538 DeclaredFile,
540 LiveFile,
542 Strict,
544}
545
546#[derive(Debug, Clone, PartialEq)]
548pub enum Written {
549 Added,
551 Changed(Option<Compare>),
554}
555
556#[derive(Debug, Clone, PartialEq)]
558pub struct BaselineEntry {
559 basis: BaselineBasis,
560 before: Remote,
561 differs_at: BTreeSet<String>,
562 writes_at: BTreeSet<String>,
563 shown_at: BTreeSet<String>,
567}
568
569impl BaselineEntry {
570 pub fn basis(&self) -> BaselineBasis {
572 self.basis
573 }
574
575 pub fn differs_at(&self) -> &BTreeSet<String> {
578 &self.differs_at
579 }
580
581 pub fn writes_at(&self) -> &BTreeSet<String> {
585 &self.writes_at
586 }
587
588 fn unobservable(&self, r0: &Value, r1: &Value) -> BTreeSet<String> {
593 self.writes_at
594 .iter()
595 .filter(|w| !shows(r0, "", w) && !shows(r1, "", w))
596 .cloned()
597 .collect()
598 }
599}
600
601#[derive(Debug, Clone, PartialEq)]
604pub struct ReadBackBaseline {
605 node: String,
606 resources: BTreeMap<String, BaselineEntry>,
607}
608
609impl ReadBackBaseline {
610 pub fn build(
630 node: &str,
631 expected: &Expected,
632 before: &BTreeMap<String, Remote>,
633 written: &BTreeMap<String, Written>,
634 ) -> ReadBackBaseline {
635 let against = |remote: &Remote, compare: &Compare| match remote {
636 Remote::Config(rest) => read_back_paths(compare, rest),
637 Remote::Missing | Remote::System => BTreeSet::new(),
638 };
639 let resources = expected
640 .resources
641 .iter()
642 .map(|res| {
643 let r0 = before.get(&res.key).cloned().unwrap_or(Remote::Missing);
644 let (basis, differs_at, writes_at, shown_at) = match (written.get(&res.key), &r0) {
645 (None, _) => (
646 BaselineBasis::DeclaredFile,
647 against(&r0, &res.compare),
648 BTreeSet::new(),
649 BTreeSet::new(),
650 ),
651 (Some(Written::Changed(Some(live))), Remote::Config(rest)) => {
652 let writes_at = write_paths(&res.compare, live);
653 let shown_at = already_shown(&writes_at, &res.compare, rest);
654 (
655 BaselineBasis::LiveFile,
656 read_back_paths(live, rest),
657 writes_at,
658 shown_at,
659 )
660 }
661 (Some(_), _) => (
662 BaselineBasis::Strict,
663 against(&r0, &res.compare),
664 BTreeSet::new(),
665 BTreeSet::new(),
666 ),
667 };
668 (
669 res.key.clone(),
670 BaselineEntry {
671 basis,
672 before: r0,
673 differs_at,
674 writes_at,
675 shown_at,
676 },
677 )
678 })
679 .collect();
680 ReadBackBaseline {
681 node: node.to_string(),
682 resources,
683 }
684 }
685
686 pub fn node(&self) -> &str {
688 &self.node
689 }
690
691 pub fn entry(&self, key: &str) -> Option<&BaselineEntry> {
693 self.resources.get(key)
694 }
695
696 pub fn record(&self) -> BaselineRecord {
704 let resources = self
705 .resources
706 .iter()
707 .map(|(key, entry)| {
708 let state = match &entry.before {
709 Remote::Config(_) => "config",
710 Remote::Missing => "missing",
711 Remote::System => "system",
712 };
713 (
714 key.clone(),
715 BaselineRecordEntry {
716 state,
717 basis: entry.basis,
718 differs_at: entry.differs_at.iter().cloned().collect(),
719 writes_at: entry.writes_at.iter().cloned().collect(),
720 },
721 )
722 })
723 .collect();
724 BaselineRecord {
725 node: self.node.clone(),
726 resources,
727 }
728 }
729}
730
731#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
734pub struct BaselineRecord {
735 pub node: String,
737 pub resources: BTreeMap<String, BaselineRecordEntry>,
739}
740
741#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
743pub struct BaselineRecordEntry {
744 pub state: &'static str,
746 pub basis: BaselineBasis,
748 pub differs_at: Vec<String>,
750 pub writes_at: Vec<String>,
754}
755
756pub fn path_covered(path: &str, by: &BTreeSet<String>) -> bool {
760 by.iter().any(|ancestor| path_under(path, ancestor))
761}
762
763fn path_under(path: &str, ancestor: &str) -> bool {
766 ancestor == ROOT_PATH
767 || path == ancestor
768 || (path.len() > ancestor.len()
769 && path.starts_with(ancestor)
770 && matches!(path.as_bytes()[ancestor.len()], b'.' | b'['))
771}
772
773fn paths_overlap(a: &str, b: &str) -> bool {
776 path_under(a, b) || path_under(b, a)
777}
778
779fn shows(rest: &Value, at: &str, target: &str) -> bool {
786 if rest.is_null() {
787 return false;
788 }
789 if target == ROOT_PATH || at == target {
790 return true;
791 }
792 let below =
793 |child: &str, value: &Value| path_under(target, child) && shows(value, child, target);
794 match rest {
795 Value::Object(map) => map
796 .iter()
797 .any(|(key, value)| below(&child_path(at, key), value)),
798 Value::Array(items) => items
799 .iter()
800 .enumerate()
801 .any(|(i, value)| below(&child_path(at, &i.to_string()), value)),
802 _ => true,
803 }
804}
805
806pub fn evaluate(expected: &Expected, observed: &ObservedState) -> VerifyReport {
808 evaluate_with(expected, observed, &ReadBackJudge::Strict)
809}
810
811pub fn evaluate_with(
813 expected: &Expected,
814 observed: &ObservedState,
815 judge: &ReadBackJudge<'_>,
816) -> VerifyReport {
817 let mut warnings = Vec::new();
818 let (read_back, resources_checked) =
819 read_back_check_with(expected, &observed.read_back, judge, &mut warnings);
820 let checks = vec![
821 check_scan(observed),
822 check_mode(expected, observed),
823 read_back,
824 check_files(expected, observed, &mut warnings),
825 check_loaded(expected, observed),
826 check_probes(expected, observed),
827 ];
828 for check in &checks {
829 if check.status == CheckStatus::Warn {
830 warnings.push(format!("{}: {}", check_word(check.id), check.detail));
831 }
832 }
833 let ok = checks.iter().all(|c| c.status != CheckStatus::Fail);
834 VerifyReport {
835 ok,
836 slug: (!ok).then_some(VERIFY_FAILED_SLUG),
837 checks,
838 resources_checked,
839 warnings,
840 }
841}
842
843fn check_word(id: CheckId) -> &'static str {
844 match id {
845 CheckId::Scan => "scan",
846 CheckId::Mode => "mode",
847 CheckId::ReadBack => "read_back",
848 CheckId::Files => "files",
849 CheckId::Loaded => "loaded",
850 CheckId::Probes => "probes",
851 }
852}
853
854fn finding(code: &'static str, subject: impl Into<String>, detail: impl Into<String>) -> Finding {
855 Finding {
856 code,
857 subject: subject.into(),
858 detail: detail.into(),
859 status: None,
860 }
861}
862
863fn sort_findings(findings: &mut [Finding]) {
866 findings.sort_by(|a, b| {
867 a.subject
868 .as_bytes()
869 .cmp(b.subject.as_bytes())
870 .then_with(|| a.code.cmp(b.code))
871 });
872}
873
874fn finished(id: CheckId, detail: String, findings: Vec<Finding>) -> Check {
875 Check {
876 id,
877 status: if findings.is_empty() {
878 CheckStatus::Pass
879 } else {
880 CheckStatus::Fail
881 },
882 detail,
883 findings,
884 }
885}
886
887fn check_scan(observed: &ObservedState) -> Check {
890 if observed.scan.scan_active {
891 return finished(
892 CheckId::Scan,
893 "a config scan is running".to_string(),
894 vec![finding(
895 "scan_active",
896 "config",
897 "the gateway reports an active config scan; verify reads settled state only",
898 )],
899 );
900 }
901 finished(
902 CheckId::Scan,
903 "no config scan is running".to_string(),
904 Vec::new(),
905 )
906}
907
908fn check_mode(expected: &Expected, observed: &ObservedState) -> Check {
911 mode_check(expected, observed.mode.as_deref())
912}
913
914pub(crate) fn mode_check(expected: &Expected, mode: Option<&str>) -> Check {
917 if mode == Some(expected.mode.as_str()) {
918 return finished(
919 CheckId::Mode,
920 format!("the gateway runs mode `{}`", expected.mode),
921 Vec::new(),
922 );
923 }
924 let seen = match mode {
925 Some(mode) => format!("`{}`", printable(mode)),
926 None => "no mode".to_string(),
927 };
928 finished(
929 CheckId::Mode,
930 "the gateway does not run the target's mode".to_string(),
931 vec![finding(
932 "mode_mismatch",
933 "mode",
934 format!("expected `{}`, the gateway reports {seen}", expected.mode),
935 )],
936 )
937}
938
939pub(crate) fn read_back_check_with(
1003 expected: &Expected,
1004 read_back: &BTreeMap<String, Remote>,
1005 judge: &ReadBackJudge<'_>,
1006 warnings: &mut Vec<String>,
1007) -> (Check, usize) {
1008 let mut findings = Vec::new();
1009 let mut checked = 0usize;
1010 let mut warned = 0usize;
1011 for res in &expected.resources {
1012 let after = read_back.get(&res.key).unwrap_or(&Remote::Missing);
1013 let paths = match after {
1014 Remote::System => {
1015 match system_red(&res.key, judge) {
1016 Some(f) => {
1017 checked += 1;
1018 findings.push(f);
1019 }
1020 None => warnings.push(format!(
1021 "read_back: {} not compared: the effective copy is in the `{SYSTEM_COLLECTION}` collection (gateway-provided, not file-backed)",
1022 res.key
1023 )),
1024 }
1025 continue;
1026 }
1027 Remote::Missing => None,
1028 Remote::Config(rest) => Some(read_back_paths(&res.compare, rest)),
1029 };
1030 checked += 1;
1031 for verdict in judge_resource(&res.key, after, paths.as_ref(), judge) {
1032 match verdict {
1033 Verdict::Pass => {}
1034 Verdict::Red(f) => findings.push(f),
1035 Verdict::Warn(mut f, line) => {
1036 f.status = Some(CheckStatus::Warn);
1037 findings.push(f);
1038 warnings.push(line);
1039 warned += 1;
1040 }
1041 }
1042 }
1043 }
1044 for (key, reason) in &expected.not_checked {
1045 warnings.push(format!("read_back: {key} not compared: {reason}"));
1046 }
1047 if checked == 0 && findings.is_empty() {
1048 warnings.push(
1049 "read_back: no declared non-tag resource was compared (nothing to read back)"
1050 .to_string(),
1051 );
1052 }
1053 sort_findings(&mut findings);
1054 let mut detail =
1055 format!("{checked} declared resource(s) compared with the REST effective view");
1056 let status = if findings.iter().any(|f| f.status.is_none()) {
1057 CheckStatus::Fail
1058 } else if findings.is_empty() {
1059 CheckStatus::Pass
1060 } else {
1061 detail.push_str(&format!("; {warned} difference(s) reported as warnings"));
1062 CheckStatus::Warn
1063 };
1064 (
1065 Check {
1066 id: CheckId::ReadBack,
1067 status,
1068 detail,
1069 findings,
1070 },
1071 checked,
1072 )
1073}
1074
1075pub(crate) fn read_back_check(
1078 expected: &Expected,
1079 read_back: &BTreeMap<String, Remote>,
1080 warnings: &mut Vec<String>,
1081) -> (Check, usize) {
1082 read_back_check_with(expected, read_back, &ReadBackJudge::Strict, warnings)
1083}
1084
1085fn system_red(key: &str, judge: &ReadBackJudge<'_>) -> Option<Finding> {
1091 let ReadBackJudge::Baseline(baseline) = judge else {
1092 return None;
1093 };
1094 let entry = baseline.entry(key)?;
1095 let why = match (entry.basis, &entry.before) {
1096 (BaselineBasis::DeclaredFile, Remote::Config(_)) => {
1097 "it was file-backed before this apply and dropped out of the effective view during this apply"
1098 }
1099 (BaselineBasis::DeclaredFile, _) => return None,
1100 (BaselineBasis::LiveFile | BaselineBasis::Strict, _) => {
1101 "this apply wrote it, so the write did not take effect"
1102 }
1103 };
1104 Some(finding(
1105 "resource_missing",
1106 key,
1107 format!(
1108 "{MISSING_DETAIL} as a file-backed copy: the effective copy is in the `{SYSTEM_COLLECTION}` collection ({why})"
1109 ),
1110 ))
1111}
1112
1113enum Verdict {
1115 Pass,
1116 Red(Finding),
1117 Warn(Finding, String),
1119}
1120
1121const MISSING_DETAIL: &str = "not in the gateway's effective view";
1123
1124const UNOBSERVABLE_DETAIL: &str =
1126 "not visible in the gateway's REST view, so the write could not be verified";
1127
1128fn warn_differs(key: &str, p: &BTreeSet<String>, why: &str) -> Verdict {
1131 if p.is_empty() {
1132 return Verdict::Pass;
1133 }
1134 let at = join_set(p);
1135 Verdict::Warn(
1136 finding("resource_differs", key, format!("differs at: {at} ({why})")),
1137 format!("read_back: {key} differs from its file at: {at} ({why})"),
1138 )
1139}
1140
1141fn warn_unobservable(key: &str, unseen: &BTreeSet<String>) -> Verdict {
1144 if unseen.is_empty() {
1145 return Verdict::Pass;
1146 }
1147 let verb = if unseen.len() == 1 { "is" } else { "are" };
1148 let detail = format!("{} {verb} {UNOBSERVABLE_DETAIL}", join_set(unseen));
1149 Verdict::Warn(
1150 finding("resource_differs", key, detail.clone()),
1151 format!("read_back: {key}: {detail}"),
1152 )
1153}
1154
1155fn judge_resource(
1160 key: &str,
1161 after: &Remote,
1162 paths: Option<&BTreeSet<String>>,
1163 judge: &ReadBackJudge<'_>,
1164) -> Vec<Verdict> {
1165 let strict = || match paths {
1166 None => Verdict::Red(finding("resource_missing", key, MISSING_DETAIL)),
1167 Some(p) if p.is_empty() => Verdict::Pass,
1168 Some(p) => Verdict::Red(finding(
1169 "resource_differs",
1170 key,
1171 format!("differs at: {}", join_set(p)),
1172 )),
1173 };
1174 let warn = |why: &str| match paths {
1175 None => Verdict::Warn(
1176 finding("resource_missing", key, format!("{MISSING_DETAIL} ({why})")),
1177 format!("read_back: {key} is {MISSING_DETAIL} ({why})"),
1178 ),
1179 Some(p) => warn_differs(key, p, why),
1180 };
1181 match judge {
1182 ReadBackJudge::Strict => vec![strict()],
1183 ReadBackJudge::Standalone { file_status } => {
1184 vec![if file_status.get(key) == Some(&PlanStatus::Same) {
1185 match paths {
1186 None => warn("the live files equal the repo"),
1187 Some(_) => {
1188 warn("the live files equal the repo; the gateway's REST view differs")
1189 }
1190 }
1191 } else {
1192 strict()
1193 }]
1194 }
1195 ReadBackJudge::Baseline(baseline) => {
1196 let Some(entry) = baseline.entry(key) else {
1197 return vec![strict()];
1198 };
1199 let Some(p) = paths else {
1200 return vec![match (entry.basis, &entry.before) {
1203 (BaselineBasis::DeclaredFile, Remote::Missing) => {
1204 warn("it was not there before this apply either")
1205 }
1206 _ => strict(),
1207 }];
1208 };
1209 judge_baseline(key, entry, after, p)
1210 }
1211 }
1212}
1213
1214fn judge_baseline(
1217 key: &str,
1218 entry: &BaselineEntry,
1219 after: &Remote,
1220 p: &BTreeSet<String>,
1221) -> Vec<Verdict> {
1222 let red = |detail: String| Verdict::Red(finding("resource_differs", key, detail));
1223 let unseen = match (entry.basis, &entry.before, after) {
1227 (BaselineBasis::LiveFile, Remote::Config(r0), Remote::Config(r1)) => {
1228 entry.unobservable(r0, r1)
1229 }
1230 _ => BTreeSet::new(),
1231 };
1232 let mut out = vec![warn_unobservable(key, &unseen)];
1233 if p.is_empty() {
1234 return out;
1235 }
1236 out.push(match (entry.basis, &entry.before, after) {
1237 (BaselineBasis::DeclaredFile, Remote::Config(r0), Remote::Config(r1)) => {
1238 let moved = rest_moved(r0, r1);
1239 if moved.is_empty() {
1240 warn_differs(key, p, "already different before this apply")
1241 } else {
1242 red(format!(
1243 "differs at: {}; its REST view changed during this apply at: {}",
1244 join_set(p),
1245 join_set(&moved)
1246 ))
1247 }
1248 }
1249 (BaselineBasis::DeclaredFile, _, _) => red(format!(
1250 "differs at: {}; its REST view changed during this apply at: {ROOT_PATH}",
1251 join_set(p)
1252 )),
1253 (BaselineBasis::LiveFile, Remote::Config(r0), Remote::Config(r1)) => {
1254 judge_written(key, entry, r0, r1, p, &unseen)
1255 }
1256 _ => red(format!("differs at: {}", join_set(p))),
1257 });
1258 out
1259}
1260
1261fn judge_written(
1266 key: &str,
1267 entry: &BaselineEntry,
1268 r0: &Value,
1269 r1: &Value,
1270 p: &BTreeSet<String>,
1271 unseen: &BTreeSet<String>,
1272) -> Verdict {
1273 let red = |detail: String| Verdict::Red(finding("resource_differs", key, detail));
1274 let new: BTreeSet<String> = p
1278 .iter()
1279 .filter(|path| !path_covered(path, &entry.differs_at))
1280 .filter(|path| !unseen.iter().any(|u| paths_overlap(path, u)))
1281 .cloned()
1282 .collect();
1283 if !new.is_empty() {
1284 return red(format!(
1285 "differs at: {} (not different before this apply)",
1286 join_set(&new)
1287 ));
1288 }
1289 let moved = rest_moved(r0, r1);
1290 let judged: BTreeSet<&String> = entry
1292 .writes_at
1293 .iter()
1294 .filter(|w| !unseen.contains(*w) && !entry.shown_at.contains(*w))
1295 .collect();
1296 if !judged.is_empty() && moved.is_empty() {
1297 return red(format!(
1299 "differs at: {} (its REST view did not change during this apply, so the write did not take effect)",
1300 join_set(p)
1301 ));
1302 }
1303 let stuck: BTreeSet<String> = judged
1306 .into_iter()
1307 .filter(|w| p.iter().any(|q| paths_overlap(q, w)))
1308 .filter(|w| !moved.iter().any(|m| paths_overlap(m, w)))
1309 .cloned()
1310 .collect();
1311 if !stuck.is_empty() {
1312 return red(format!(
1313 "differs at: {} (its REST view did not change there during this apply, so that part of the write did not take effect)",
1314 join_set(&stuck)
1315 ));
1316 }
1317 let left: BTreeSet<String> = p
1319 .iter()
1320 .filter(|q| !unseen.iter().any(|u| paths_overlap(q, u)))
1321 .cloned()
1322 .collect();
1323 warn_differs(key, &left, "already different before this apply")
1324}
1325
1326fn read_back_paths(compare: &Compare, rest: &Value) -> BTreeSet<String> {
1329 let mut paths = Vec::new();
1330 match compare {
1331 Compare::Exact(file) => {
1332 diff_exact(&strip_nulls(file), &strip_nulls(rest), "", &mut paths);
1333 }
1334 Compare::NameKeyedSubset(file) => {
1335 diff_subset(&strip_nulls(file), &strip_nulls(rest), "", &mut paths);
1336 }
1337 Compare::Attributes(attrs) => diff_attributes(attrs, rest, &mut paths),
1339 }
1340 paths.into_iter().collect()
1341}
1342
1343fn rest_moved(before: &Value, after: &Value) -> BTreeSet<String> {
1345 let mut paths = Vec::new();
1346 diff_exact(&strip_nulls(before), &strip_nulls(after), "", &mut paths);
1347 paths.into_iter().collect()
1348}
1349
1350fn write_paths(desired: &Compare, live: &Compare) -> BTreeSet<String> {
1355 let ((kd, vd), (kl, vl)) = (compare_doc(desired), compare_doc(live));
1356 if kd != kl {
1357 return BTreeSet::from([ROOT_PATH.to_string()]);
1358 }
1359 let mut paths = Vec::new();
1360 diff_exact(&vd, &vl, "", &mut paths);
1361 paths.into_iter().collect()
1362}
1363
1364fn compare_doc(c: &Compare) -> (u8, Value) {
1366 match c {
1367 Compare::Exact(v) => (0, strip_nulls(v)),
1368 Compare::NameKeyedSubset(v) => (1, strip_nulls(v)),
1369 Compare::Attributes(m) => (2, strip_nulls(&Value::Object(m.clone()))),
1370 }
1371}
1372
1373fn already_shown(writes_at: &BTreeSet<String>, desired: &Compare, r0: &Value) -> BTreeSet<String> {
1376 let (_, doc) = compare_doc(desired);
1377 let r0 = strip_nulls(r0);
1378 writes_at
1379 .iter()
1380 .filter(|w| match (resolve(&r0, "", w), resolve(&doc, "", w)) {
1381 (Some(seen), Some(wanted)) => {
1382 let mut out = Vec::new();
1383 diff_exact(wanted, seen, "", &mut out);
1384 out.is_empty()
1385 }
1386 _ => false,
1387 })
1388 .cloned()
1389 .collect()
1390}
1391
1392fn resolve<'v>(value: &'v Value, at: &str, target: &str) -> Option<&'v Value> {
1397 if target == ROOT_PATH || at == target {
1398 return Some(value);
1399 }
1400 let below = |child: String, v: &'v Value| {
1401 path_under(target, &child)
1402 .then(|| resolve(v, &child, target))
1403 .flatten()
1404 };
1405 match value {
1406 Value::Object(map) => map
1407 .iter()
1408 .find_map(|(key, v)| below(child_path(at, key), v)),
1409 Value::Array(items) => items
1410 .iter()
1411 .enumerate()
1412 .find_map(|(i, v)| below(child_path(at, &i.to_string()), v)),
1413 _ => None,
1414 }
1415}
1416
1417fn join_set(paths: &BTreeSet<String>) -> String {
1418 join_paths(paths.iter().cloned().collect())
1419}
1420
1421fn strip_nulls(value: &Value) -> Value {
1425 match value {
1426 Value::Object(map) => Value::Object(
1427 map.iter()
1428 .filter(|(_, v)| !v.is_null())
1429 .map(|(k, v)| (k.clone(), strip_nulls(v)))
1430 .collect(),
1431 ),
1432 Value::Array(items) => Value::Array(items.iter().map(strip_nulls).collect()),
1433 other => other.clone(),
1434 }
1435}
1436
1437fn child_path(path: &str, key: &str) -> String {
1438 let key = printable(key);
1441 if path.is_empty() {
1442 key
1443 } else {
1444 format!("{path}.{key}")
1445 }
1446}
1447
1448fn root_or(path: &str) -> String {
1449 if path.is_empty() {
1450 ROOT_PATH.to_string()
1451 } else {
1452 path.to_string()
1453 }
1454}
1455
1456fn same_scalar(a: &Value, b: &Value) -> bool {
1458 match (a, b) {
1459 (Value::Number(x), Value::Number(y)) => {
1460 if x.is_f64() || y.is_f64() {
1463 matches!((x.as_f64(), y.as_f64()), (Some(x), Some(y)) if x == y)
1464 } else {
1465 x == y
1466 }
1467 }
1468 _ => a == b,
1469 }
1470}
1471
1472fn diff_exact(file: &Value, rest: &Value, path: &str, out: &mut Vec<String>) {
1474 match (file, rest) {
1475 (Value::Object(f), Value::Object(r)) => {
1476 for (key, fv) in f {
1477 match r.get(key) {
1478 Some(rv) => diff_exact(fv, rv, &child_path(path, key), out),
1479 None => out.push(child_path(path, key)),
1480 }
1481 }
1482 for key in r.keys().filter(|k| !f.contains_key(*k)) {
1483 out.push(child_path(path, key));
1484 }
1485 }
1486 (Value::Array(f), Value::Array(r)) if f.len() == r.len() => {
1487 for (i, (fv, rv)) in f.iter().zip(r).enumerate() {
1488 diff_exact(fv, rv, &child_path(path, &i.to_string()), out);
1489 }
1490 }
1491 (Value::Object(_) | Value::Array(_), _) | (_, Value::Object(_) | Value::Array(_)) => {
1492 out.push(root_or(path));
1493 }
1494 (f, r) => {
1495 if !same_scalar(f, r) {
1496 out.push(root_or(path));
1497 }
1498 }
1499 }
1500}
1501
1502fn diff_subset(file: &Value, rest: &Value, path: &str, out: &mut Vec<String>) {
1505 match (file, rest) {
1506 (Value::Object(f), Value::Object(r)) => {
1507 for (key, fv) in f {
1508 match r.get(key) {
1509 Some(rv) => diff_subset(fv, rv, &child_path(path, key), out),
1510 None => out.push(child_path(path, key)),
1511 }
1512 }
1513 }
1514 (Value::Array(f), Value::Array(r)) => {
1515 for (i, fv) in f.iter().enumerate() {
1516 let name = fv.get("name").and_then(Value::as_str);
1517 if let (Some(name), true) = (name, fv.is_object()) {
1518 let at = format!("{path}[{}]", printable(name));
1519 match r
1520 .iter()
1521 .find(|rv| rv.get("name").and_then(Value::as_str) == Some(name))
1522 {
1523 Some(rv) => diff_subset(fv, rv, &at, out),
1524 None => out.push(at),
1525 }
1526 } else {
1527 let found = r.iter().any(|rv| {
1528 let mut inner = Vec::new();
1529 diff_exact(fv, rv, "", &mut inner);
1530 inner.is_empty()
1531 });
1532 if !found {
1533 out.push(child_path(path, &i.to_string()));
1534 }
1535 }
1536 }
1537 }
1538 (Value::Object(_) | Value::Array(_), _) | (_, Value::Object(_) | Value::Array(_)) => {
1539 out.push(root_or(path));
1540 }
1541 (f, r) => {
1542 if !same_scalar(f, r) {
1543 out.push(root_or(path));
1544 }
1545 }
1546 }
1547}
1548
1549fn diff_attributes(attrs: &Map<String, Value>, rest: &Value, out: &mut Vec<String>) {
1555 let Some(rest) = rest.as_object() else {
1556 out.push("(root)".to_string());
1557 return;
1558 };
1559 for (key, expected) in attrs {
1560 if let Some(seen) = rest.get(key) {
1561 let mut inner = Vec::new();
1562 diff_exact(&strip_nulls(expected), &strip_nulls(seen), "", &mut inner);
1563 if !inner.is_empty() {
1564 out.push(key.clone());
1565 }
1566 }
1567 }
1568}
1569
1570fn join_paths(mut paths: Vec<String>) -> String {
1571 paths.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
1572 paths.dedup();
1573 let more = paths.len().saturating_sub(MAX_PATHS);
1574 paths.truncate(MAX_PATHS);
1575 let mut text = paths.join(", ");
1576 if more > 0 {
1577 text.push_str(&format!(" (+{more} more)"));
1578 }
1579 text
1580}
1581
1582const UNREADABLE_DETAIL: &str =
1586 "the unit has a live path the host's sftp server cannot read; its live copy is unknown";
1587
1588fn mode_copy_detail(mode: &str, paths: &[String]) -> String {
1592 let mut paths = paths.to_vec();
1593 paths.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
1594 paths.dedup();
1595 let shown = paths.len().min(APPLY_LIST_SHOWN);
1596 let mut named = paths[..shown].join(", ");
1597 if paths.len() > shown {
1598 named.push_str(&format!(" and {} more", paths.len() - shown));
1599 }
1600 format!(
1601 "the master's {mode} collection holds a copy of the unit ({} file(s): {named}), which \
1602 overrides `core` and blocks the backup's configuration sync",
1603 paths.len()
1604 )
1605}
1606
1607fn check_files(expected: &Expected, observed: &ObservedState, warnings: &mut Vec<String>) -> Check {
1615 let mut findings = Vec::new();
1616 for unit in &expected.tag_units {
1617 if observed.unreadable.iter().any(|(key, _)| key == unit) {
1619 findings.push(finding("unreadable", unit.clone(), UNREADABLE_DETAIL));
1620 continue;
1621 }
1622 if let Some(paths) = observed.mode_copies.get(unit).filter(|p| !p.is_empty()) {
1623 findings.push(finding(
1624 "files_differ",
1625 unit.clone(),
1626 mode_copy_detail(&expected.mode, paths),
1627 ));
1628 continue;
1629 }
1630 let status = observed
1631 .files
1632 .iter()
1633 .find(|(key, _)| key == unit)
1634 .map(|(_, status)| *status);
1635 match status {
1636 Some(PlanStatus::Same) => {}
1637 Some(other) => findings.push(finding(
1638 "files_differ",
1639 unit.clone(),
1640 format!(
1641 "the effective live copy is `{}` against the render",
1642 other.word()
1643 ),
1644 )),
1645 None => findings.push(finding(
1646 "files_differ",
1647 unit.clone(),
1648 "the unit was not found among the live files",
1649 )),
1650 }
1651 }
1652 for (key, declared) in &observed.unreadable {
1653 if expected.tag_units.contains(key) {
1654 continue;
1655 }
1656 if *declared {
1657 findings.push(finding("unreadable", key.clone(), UNREADABLE_DETAIL));
1658 } else {
1659 warnings.push(format!(
1660 "files: {key} is unreadable on the host and is not declared; it was not compared"
1661 ));
1662 }
1663 }
1664 sort_findings(&mut findings);
1665 finished(
1666 CheckId::Files,
1667 format!(
1668 "{} declared tag unit(s) compared byte for byte with the live files",
1669 expected.tag_units.len()
1670 ),
1671 findings,
1672 )
1673}
1674
1675fn check_loaded(expected: &Expected, observed: &ObservedState) -> Check {
1678 if observed.scan.scan_active {
1679 return Check {
1680 id: CheckId::Loaded,
1681 status: CheckStatus::Warn,
1682 detail: "not observed: a scan is running, so its log lines are not readable yet"
1683 .to_string(),
1684 findings: Vec::new(),
1685 };
1686 }
1687 let lines = match &observed.logs {
1688 Ok(lines) => lines,
1689 Err(slug) => {
1690 return finished(
1691 CheckId::Loaded,
1692 "the gateway log could not be read".to_string(),
1693 vec![finding("logs_unavailable", "logs", printable(slug))],
1694 );
1695 }
1696 };
1697
1698 let mut hits: BTreeMap<&str, (i64, &str)> = BTreeMap::new();
1701 for line in lines {
1702 if !line.message.contains(WILL_NOT_BE_LOADED) {
1703 continue;
1704 }
1705 let owner = expected
1706 .declared_ids
1707 .iter()
1708 .filter(|id| line.message.contains(&format!("Resource {id} ")))
1709 .max_by_key(|id| id.len());
1710 if let Some(id) = owner {
1711 let entry = hits
1712 .entry(id.as_str())
1713 .or_insert((line.timestamp, line.logger.as_str()));
1714 if line.timestamp < entry.0 {
1715 *entry = (line.timestamp, line.logger.as_str());
1716 }
1717 }
1718 }
1719 let mut findings: Vec<Finding> = hits
1720 .into_iter()
1721 .map(|(id, (timestamp, logger))| {
1722 finding(
1723 "unloaded_resource",
1724 id,
1725 format!("logged at {timestamp} by `{}`", printable(logger)),
1726 )
1727 })
1728 .collect();
1729 sort_findings(&mut findings);
1730 let detail = if findings.is_empty() {
1731 format!(
1732 "no `{WILL_NOT_BE_LOADED}` line for a declared resource since the last scan (the gateway logs it once per scan that finds the resource, and not for every type, so this covers the last scan only)"
1733 )
1734 } else {
1735 format!("{} declared resource(s) were not loaded", findings.len())
1736 };
1737 finished(CheckId::Loaded, detail, findings)
1738}
1739
1740fn check_probes(expected: &Expected, observed: &ObservedState) -> Check {
1743 if expected.probes.is_empty() {
1744 return Check {
1745 id: CheckId::Probes,
1746 status: CheckStatus::Warn,
1747 detail: "the manifest declares no `verify.tag_probes`; nothing was probed".to_string(),
1748 findings: vec![finding(
1749 "tag_probes_absent",
1750 "verify.tag_probes",
1751 "add tag paths to the manifest to probe them after a deploy",
1752 )],
1753 };
1754 }
1755 let readings = match &observed.probes {
1756 Some(Ok(readings)) => readings,
1757 Some(Err(slug)) => {
1758 return finished(
1759 CheckId::Probes,
1760 "the probes could not be read".to_string(),
1761 vec![finding("tag_probes_unavailable", "tags", printable(slug))],
1762 );
1763 }
1764 None => {
1765 return finished(
1766 CheckId::Probes,
1767 "the probes could not be read".to_string(),
1768 vec![finding("tag_probes_unavailable", "tags", "not read")],
1769 );
1770 }
1771 };
1772 let mut findings = Vec::new();
1773 for path in &expected.probes {
1774 match readings.iter().find(|r| &r.path == path) {
1775 Some(r) if r.quality.starts_with("Good") => {}
1776 Some(r) => findings.push(finding(
1777 "tag_probe_not_good",
1778 path.clone(),
1779 format!("quality {}", quality_code(&r.quality)),
1780 )),
1781 None => findings.push(finding("tag_probe_not_good", path.clone(), "no reading")),
1782 }
1783 }
1784 finished(
1786 CheckId::Probes,
1787 format!("{} tag probe(s) read", expected.probes.len()),
1788 findings,
1789 )
1790}
1791
1792fn quality_code(quality: &str) -> String {
1796 if quality.is_empty() {
1797 return "empty".to_string();
1798 }
1799 let (class, rest) = match quality.split_once('_') {
1800 Some((class, rest)) => (class, Some(rest)),
1801 None => (quality, None),
1802 };
1803 if !matches!(class, "Good" | "Bad" | "Uncertain" | "Error") {
1804 return "unrecognised".to_string();
1805 }
1806 match rest {
1807 Some(rest)
1808 if !rest.is_empty()
1809 && rest.len() <= 48
1810 && rest.chars().all(|c| c.is_ascii_alphabetic() || c == '_') =>
1811 {
1812 format!("{class}_{rest}")
1813 }
1814 _ => class.to_string(),
1815 }
1816}
1817
1818fn printable(text: &str) -> String {
1822 let mut out: String = text
1823 .chars()
1824 .filter(|c| !c.is_control())
1825 .take(MAX_CODE_CHARS)
1826 .collect();
1827 if text.chars().count() > MAX_CODE_CHARS {
1828 out.push_str("...");
1829 }
1830 out
1831}
1832
1833#[cfg(test)]
1834mod tests {
1835 use super::*;
1836 use serde_json::json;
1837
1838 #[test]
1841 fn words_equal_the_serialized_forms() {
1842 for id in [
1843 CheckId::Scan,
1844 CheckId::Mode,
1845 CheckId::ReadBack,
1846 CheckId::Files,
1847 CheckId::Loaded,
1848 CheckId::Probes,
1849 ] {
1850 assert_eq!(json!(id), json!(id.word()));
1851 }
1852 for status in [CheckStatus::Pass, CheckStatus::Fail, CheckStatus::Warn] {
1853 assert_eq!(json!(status), json!(status.word()));
1854 }
1855 }
1856
1857 #[test]
1858 fn quality_codes_carry_no_free_text() {
1859 assert_eq!(quality_code("Good"), "Good");
1860 assert_eq!(quality_code("Good_Provisional"), "Good_Provisional");
1861 assert_eq!(quality_code("Bad_NotFound"), "Bad_NotFound");
1862 assert_eq!(quality_code("Bad_secret-123"), "Bad");
1863 assert_eq!(quality_code("Weird"), "unrecognised");
1864 assert_eq!(quality_code(""), "empty");
1865 }
1866
1867 #[test]
1868 fn printable_drops_controls_and_caps_length() {
1869 assert_eq!(printable("a\nb\u{7}c"), "abc");
1870 let long = "x".repeat(MAX_CODE_CHARS + 10);
1871 assert_eq!(printable(&long).len(), MAX_CODE_CHARS + 3);
1872 }
1873
1874 #[test]
1875 fn gateway_supplied_keys_are_sanitised_in_diff_paths() {
1876 let hostile = format!("evil\u{1b}[31m{}", "k".repeat(MAX_CODE_CHARS * 2));
1879 let mut rest = serde_json::Map::new();
1880 rest.insert(hostile, json!(1));
1881 let mut out = Vec::new();
1882 diff_exact(&json!({}), &Value::Object(rest), "", &mut out);
1883 assert_eq!(out.len(), 1);
1884 assert!(!out[0].contains('\u{1b}'), "{:?}", out[0]);
1885 assert!(
1886 out[0].chars().count() <= MAX_CODE_CHARS + 3,
1887 "{}",
1888 out[0].len()
1889 );
1890 }
1891
1892 #[test]
1893 fn large_integers_compare_exactly() {
1894 let mut out = Vec::new();
1896 diff_exact(
1897 &json!({"id": 9_007_199_254_740_993_i64}),
1898 &json!({"id": 9_007_199_254_740_992_i64}),
1899 "",
1900 &mut out,
1901 );
1902 assert_eq!(out, vec!["id".to_string()]);
1903 let mut out = Vec::new();
1904 diff_exact(
1905 &json!({"id": u64::MAX}),
1906 &json!({"id": u64::MAX - 1}),
1907 "",
1908 &mut out,
1909 );
1910 assert_eq!(out, vec!["id".to_string()]);
1911 let mut out = Vec::new();
1912 diff_exact(
1913 &json!({"id": 9_007_199_254_740_993_i64}),
1914 &json!({"id": 9_007_199_254_740_993_i64}),
1915 "",
1916 &mut out,
1917 );
1918 assert!(out.is_empty());
1919 }
1920
1921 #[test]
1922 fn numbers_compare_by_value() {
1923 let mut out = Vec::new();
1924 diff_exact(
1925 &json!({"a": 1, "b": 2.5}),
1926 &json!({"a": 1.0, "b": 2.5}),
1927 "",
1928 &mut out,
1929 );
1930 assert!(out.is_empty(), "{out:?}");
1931 diff_exact(&json!({"a": 1}), &json!({"a": 2}), "", &mut out);
1932 assert_eq!(out, vec!["a".to_string()]);
1933 }
1934
1935 #[test]
1936 fn shows_resolves_paths_ancestors_and_opaque_values() {
1937 let rest = json!({
1938 "settings": {"enabled": true, "blob": "opaque", "empty": {}, "gone": null},
1939 "list": [{"host": "a"}]
1940 });
1941 assert!(shows(&rest, "", "settings.enabled"));
1943 assert!(shows(&rest, "", "settings"));
1944 assert!(shows(&rest, "", "list.0.host"));
1945 assert!(shows(&rest, "", ROOT_PATH));
1946 assert!(shows(&rest, "", "settings.blob.hostname"));
1948 assert!(!shows(&rest, "", "settings.deviceSettings"));
1951 assert!(!shows(&rest, "", "settings.gone"));
1952 assert!(!shows(&rest, "", "list.1"));
1953 assert!(!shows(&rest, "", "settings.empty.x"));
1954 assert!(!shows(&rest, "", "settingsX"));
1955 assert!(!shows(&Value::Null, "", ROOT_PATH));
1957 assert!(!shows(&Value::Null, "", "namespace"));
1958 }
1959
1960 #[test]
1961 fn paths_overlap_is_symmetric_on_segment_boundaries() {
1962 for (a, b) in [
1964 ("a", "a"),
1965 ("a", "a.b"),
1966 ("a", "a.b.c"),
1967 ("a", "a[n]"),
1968 ("s", "s.0.a"),
1969 ] {
1970 assert!(paths_overlap(a, b), "{a} {b}");
1971 assert!(paths_overlap(b, a), "{b} {a}");
1972 }
1973 for p in ["a", "a.b", "x[y]", ROOT_PATH] {
1975 assert!(paths_overlap(ROOT_PATH, p), "{p}");
1976 assert!(paths_overlap(p, ROOT_PATH), "{p}");
1977 }
1978 for (a, b) in [
1980 ("a", "ab"),
1981 ("a.b", "a.bc"),
1982 ("a.b", "a.c"),
1983 ("s.0", "s.1"),
1984 ("b.a", "a"),
1985 ] {
1986 assert!(!paths_overlap(a, b), "{a} {b}");
1987 assert!(!paths_overlap(b, a), "{b} {a}");
1988 }
1989 }
1990
1991 #[test]
1992 fn resolve_finds_present_paths_only() {
1993 let doc = json!({"s": {"a": 1, "blob": "opaque"}, "l": [{"h": "x"}]});
1994 assert_eq!(resolve(&doc, "", "s.a"), Some(&json!(1)));
1995 assert_eq!(resolve(&doc, "", "l.0.h"), Some(&json!("x")));
1996 assert_eq!(resolve(&doc, "", ROOT_PATH), Some(&doc));
1997 assert_eq!(resolve(&doc, "", "s"), doc.get("s"));
1998 assert_eq!(resolve(&doc, "", "s.b"), None);
2000 assert_eq!(resolve(&doc, "", "l.1"), None);
2001 assert_eq!(resolve(&doc, "", "s.blob.hostname"), None);
2002 assert_eq!(resolve(&doc, "", "sa"), None);
2003 }
2004
2005 #[test]
2010 fn files_check_fails_a_declared_unit_with_a_mode_copy() {
2011 const UNIT: &str = "ignition/tag-definition/default/Plant";
2012 let expected = Expected {
2013 mode: "production".to_string(),
2014 resources: Vec::new(),
2015 not_checked: Vec::new(),
2016 declared_ids: Vec::new(),
2017 tag_units: vec![UNIT.to_string()],
2018 probes: Vec::new(),
2019 };
2020 let mut observed = ObservedState {
2021 scan: ScanStatus {
2022 scan_active: false,
2023 last_scan_timestamp: 1_792_000_000_000,
2024 last_scan_duration: Some(400),
2025 extra: BTreeMap::new(),
2026 },
2027 mode: Some("production".to_string()),
2028 read_back: BTreeMap::new(),
2029 files: vec![(UNIT.to_string(), PlanStatus::Same)],
2030 logs: Ok(Vec::new()),
2031 probes: None,
2032 unreadable: Vec::new(),
2033 mode_copies: BTreeMap::new(),
2034 };
2035 let mut warnings = Vec::new();
2036 let clean = check_files(&expected, &observed, &mut warnings);
2037 assert_eq!(clean.status, CheckStatus::Pass, "{clean:?}");
2038 assert!(clean.findings.is_empty(), "{clean:?}");
2039
2040 let paths = vec![
2041 format!("{UNIT}/tags.json"),
2042 format!("{UNIT}/unary-resource.json"),
2043 ];
2044 observed.mode_copies.insert(UNIT.to_string(), paths.clone());
2045 let check = check_files(&expected, &observed, &mut warnings);
2046 assert_eq!(check.status, CheckStatus::Fail, "{check:?}");
2047 assert_eq!(check.findings.len(), 1, "{check:?}");
2048 let found = &check.findings[0];
2049 assert_eq!(found.code, "files_differ");
2050 assert_eq!(found.subject, UNIT);
2051 assert!(
2052 found.detail.contains("production collection"),
2053 "{}",
2054 found.detail
2055 );
2056 for path in &paths {
2057 assert!(found.detail.contains(path.as_str()), "{}", found.detail);
2058 }
2059 assert!(warnings.is_empty(), "{warnings:?}");
2060 }
2061
2062 #[test]
2063 fn path_list_is_sorted_deduplicated_and_capped() {
2064 let paths: Vec<String> = (0..MAX_PATHS + 3)
2065 .rev()
2066 .map(|i| format!("p{i:02}"))
2067 .collect();
2068 let text = join_paths(paths);
2069 assert!(text.starts_with("p00, p01"), "{text}");
2070 assert!(text.ends_with("(+3 more)"), "{text}");
2071 }
2072}