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;
90
91pub const VERIFY_FAILED_SLUG: &str = "deploy_verify_failed";
93
94pub const PROBE_DEADLINE: Duration = Duration::from_secs(30);
96
97pub const PROBE_INTERVAL: Duration = Duration::from_secs(1);
99
100pub const LOG_WINDOW_SLACK_MS: i64 = 1_000;
104
105pub const LOG_WINDOW_FALLBACK_MS: i64 = 30_000;
107
108pub const LOG_SETTLE_MS: i64 = 5_000;
111
112pub const WILL_NOT_BE_LOADED: &str = "will not be loaded";
114
115pub const NO_READ_ROUTE: &[(&str, &str)] = &[
118 ("com.inductiveautomation.opcua", "one-time"),
119 ("ignition", "images"),
120];
121
122pub const SYSTEM_COLLECTION: &str = "system";
124
125const MAX_PATHS: usize = 20;
127
128const ROOT_PATH: &str = "(root)";
130
131const MAX_CODE_CHARS: usize = 96;
133
134#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
136#[serde(rename_all = "snake_case")]
137pub enum CheckId {
138 Scan,
140 Mode,
142 ReadBack,
144 Files,
146 Loaded,
148 Probes,
150}
151
152impl CheckId {
153 pub fn word(self) -> &'static str {
156 match self {
157 Self::Scan => "scan",
158 Self::Mode => "mode",
159 Self::ReadBack => "read_back",
160 Self::Files => "files",
161 Self::Loaded => "loaded",
162 Self::Probes => "probes",
163 }
164 }
165}
166
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
169#[serde(rename_all = "lowercase")]
170pub enum CheckStatus {
171 Pass,
173 Fail,
175 Warn,
177}
178
179impl CheckStatus {
180 pub fn word(self) -> &'static str {
182 match self {
183 Self::Pass => "pass",
184 Self::Fail => "fail",
185 Self::Warn => "warn",
186 }
187 }
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
192pub struct Finding {
193 pub code: &'static str,
198 pub subject: String,
200 pub detail: String,
202 #[serde(skip_serializing_if = "Option::is_none")]
206 pub status: Option<CheckStatus>,
207}
208
209#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
211pub struct Check {
212 pub id: CheckId,
214 pub status: CheckStatus,
216 pub detail: String,
218 pub findings: Vec<Finding>,
220}
221
222#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
224pub struct VerifyReport {
225 pub ok: bool,
227 pub slug: Option<&'static str>,
229 pub checks: Vec<Check>,
231 pub resources_checked: usize,
233 pub warnings: Vec<String>,
235}
236
237#[derive(Clone, PartialEq)]
239pub enum Compare {
240 Exact(Value),
242 NameKeyedSubset(Value),
244 Attributes(Map<String, Value>),
246}
247
248impl fmt::Debug for Compare {
249 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
250 let kind = match self {
251 Compare::Exact(_) => "Exact",
252 Compare::NameKeyedSubset(_) => "NameKeyedSubset",
253 Compare::Attributes(_) => "Attributes",
254 };
255 write!(f, "{kind}(<withheld>)")
256 }
257}
258
259#[derive(Debug, Clone, PartialEq)]
261pub struct ExpectedResource {
262 pub key: String,
264 pub module: String,
266 pub ty: String,
268 pub name: Option<String>,
270 pub compare: Compare,
272}
273
274impl ExpectedResource {
275 pub fn exact(module: &str, ty: &str, name: Option<&str>, config: Value) -> Self {
277 Self::new(module, ty, name, Compare::Exact(config))
278 }
279
280 pub fn new(module: &str, ty: &str, name: Option<&str>, compare: Compare) -> Self {
282 Self {
283 key: resource_id(module, ty, name),
284 module: module.to_string(),
285 ty: ty.to_string(),
286 name: name.map(str::to_string),
287 compare,
288 }
289 }
290}
291
292pub fn resource_id(module: &str, ty: &str, name: Option<&str>) -> String {
294 match name {
295 Some(name) => format!("{module}/{ty}/{name}"),
296 None => format!("{module}/{ty}"),
297 }
298}
299
300#[derive(Debug, Clone, PartialEq)]
302pub struct Expected {
303 pub mode: String,
305 pub resources: Vec<ExpectedResource>,
307 pub not_checked: Vec<(String, String)>,
310 pub declared_ids: Vec<String>,
313 pub tag_units: Vec<String>,
315 pub probes: Vec<String>,
317}
318
319impl Expected {
320 pub fn from_render(target_mode: &str, outcome: &RenderOutcome, manifest: &Manifest) -> Self {
322 let files = &outcome.tree.files;
323 let is_tag = |module: &str, ty: &str| module == "ignition" && TAG_TYPES.contains(&ty);
324
325 let mut tag_units = BTreeSet::new();
326 for path in files.keys() {
327 let mut parts = path.components();
328 if let (Some(module), Some(ty)) = (parts.next(), parts.next())
329 && is_tag(module, ty)
330 {
331 tag_units.insert(unit_key(path, files));
332 }
333 }
334
335 let mut resources = BTreeMap::new();
336 let mut not_checked = BTreeMap::new();
337 let mut declared_ids = BTreeSet::new();
338 for res in &outcome.resources {
339 if is_tag(&res.module, &res.ty) {
340 continue;
341 }
342 let key = resource_id(&res.module, &res.ty, res.name.as_deref());
343 declared_ids.insert(key.clone());
344 if NO_READ_ROUTE
345 .iter()
346 .any(|(m, t)| *m == res.module && *t == res.ty)
347 {
348 not_checked.insert(key, "no REST read route".to_string());
349 continue;
350 }
351 let file = |name: &str| {
352 super::layout::RelPath::new(&format!("{key}/{name}"))
353 .ok()
354 .and_then(|p| files.get(&p))
355 .map(Vec::as_slice)
356 };
357 let compare = match compare_for(&res.module, &res.ty, file) {
358 Ok(compare) => compare,
359 Err(reason) => {
360 not_checked.insert(key, reason.to_string());
361 continue;
362 }
363 };
364 resources.insert(
365 key.clone(),
366 ExpectedResource {
367 key,
368 module: res.module.clone(),
369 ty: res.ty.clone(),
370 name: res.name.clone(),
371 compare,
372 },
373 );
374 }
375
376 Expected {
377 mode: target_mode.to_string(),
378 resources: resources.into_values().collect(),
379 not_checked: not_checked.into_iter().collect(),
380 declared_ids: declared_ids.into_iter().collect(),
381 tag_units: tag_units.into_iter().collect(),
382 probes: manifest
383 .verify
384 .as_ref()
385 .map(|v| v.tag_probes.clone())
386 .unwrap_or_default(),
387 }
388 }
389}
390
391pub fn compare_for<'f>(
399 module: &str,
400 ty: &str,
401 file: impl Fn(&str) -> Option<&'f [u8]>,
402) -> Result<Compare, &'static str> {
403 if let Some(bytes) = file("config.json") {
404 match serde_json::from_slice::<Value>(bytes) {
405 Ok(value) if module == "ignition" && ty == "security-levels" => {
406 Ok(Compare::NameKeyedSubset(value))
407 }
408 Ok(value) => Ok(Compare::Exact(value)),
409 Err(_) => Err("config.json is not JSON"),
410 }
411 } else if let Some(attributes) = file("resource.json")
412 .and_then(|bytes| serde_json::from_slice::<Value>(bytes).ok())
413 .and_then(|v| v.get("attributes").and_then(Value::as_object).cloned())
414 {
415 Ok(Compare::Attributes(attributes))
416 } else {
417 Err("no config.json to compare")
418 }
419}
420
421#[derive(Clone, PartialEq)]
423pub enum Remote {
424 Missing,
426 Config(Value),
428 System,
430}
431
432impl fmt::Debug for Remote {
433 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
434 match self {
435 Remote::Missing => f.write_str("Missing"),
436 Remote::Config(_) => f.write_str("Config(<withheld>)"),
437 Remote::System => f.write_str("System"),
438 }
439 }
440}
441
442#[derive(Clone, PartialEq, Eq)]
444pub struct LogLine {
445 pub timestamp: i64,
447 pub logger: String,
449 pub message: String,
451}
452
453impl fmt::Debug for LogLine {
454 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
455 f.debug_struct("LogLine")
456 .field("timestamp", &self.timestamp)
457 .field("logger", &self.logger)
458 .field("message", &"<withheld>")
459 .finish()
460 }
461}
462
463#[derive(Clone, PartialEq, Eq)]
465pub struct ProbeReading {
466 pub path: String,
468 pub quality: String,
470}
471
472impl fmt::Debug for ProbeReading {
473 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
474 f.debug_struct("ProbeReading")
475 .field("path", &self.path)
476 .field("quality", &quality_code(&self.quality))
477 .finish()
478 }
479}
480
481#[derive(Debug, Clone)]
483pub struct ObservedState {
484 pub scan: ScanStatus,
486 pub mode: Option<String>,
488 pub read_back: BTreeMap<String, Remote>,
490 pub files: Vec<(String, PlanStatus)>,
492 pub logs: Result<Vec<LogLine>, String>,
495 pub probes: Option<Result<Vec<ProbeReading>, String>>,
498 pub unreadable: Vec<(String, bool)>,
503}
504
505pub fn log_window_start(scan: &ScanStatus) -> i64 {
508 let duration = scan.last_scan_duration.unwrap_or(LOG_WINDOW_FALLBACK_MS);
509 (scan.last_scan_timestamp - duration - LOG_WINDOW_SLACK_MS).max(0)
510}
511
512#[derive(Debug, Clone, Copy)]
515pub enum ReadBackJudge<'a> {
516 Strict,
518 Standalone {
521 file_status: &'a BTreeMap<String, PlanStatus>,
523 },
524 Baseline(&'a ReadBackBaseline),
526}
527
528#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
530#[serde(rename_all = "snake_case")]
531pub enum BaselineBasis {
532 DeclaredFile,
534 LiveFile,
536 Strict,
538}
539
540#[derive(Debug, Clone, PartialEq)]
542pub enum Written {
543 Added,
545 Changed(Option<Compare>),
548}
549
550#[derive(Debug, Clone, PartialEq)]
552pub struct BaselineEntry {
553 basis: BaselineBasis,
554 before: Remote,
555 differs_at: BTreeSet<String>,
556 writes_at: BTreeSet<String>,
557 shown_at: BTreeSet<String>,
561}
562
563impl BaselineEntry {
564 pub fn basis(&self) -> BaselineBasis {
566 self.basis
567 }
568
569 pub fn differs_at(&self) -> &BTreeSet<String> {
572 &self.differs_at
573 }
574
575 pub fn writes_at(&self) -> &BTreeSet<String> {
579 &self.writes_at
580 }
581
582 fn unobservable(&self, r0: &Value, r1: &Value) -> BTreeSet<String> {
587 self.writes_at
588 .iter()
589 .filter(|w| !shows(r0, "", w) && !shows(r1, "", w))
590 .cloned()
591 .collect()
592 }
593}
594
595#[derive(Debug, Clone, PartialEq)]
598pub struct ReadBackBaseline {
599 node: String,
600 resources: BTreeMap<String, BaselineEntry>,
601}
602
603impl ReadBackBaseline {
604 pub fn build(
624 node: &str,
625 expected: &Expected,
626 before: &BTreeMap<String, Remote>,
627 written: &BTreeMap<String, Written>,
628 ) -> ReadBackBaseline {
629 let against = |remote: &Remote, compare: &Compare| match remote {
630 Remote::Config(rest) => read_back_paths(compare, rest),
631 Remote::Missing | Remote::System => BTreeSet::new(),
632 };
633 let resources = expected
634 .resources
635 .iter()
636 .map(|res| {
637 let r0 = before.get(&res.key).cloned().unwrap_or(Remote::Missing);
638 let (basis, differs_at, writes_at, shown_at) = match (written.get(&res.key), &r0) {
639 (None, _) => (
640 BaselineBasis::DeclaredFile,
641 against(&r0, &res.compare),
642 BTreeSet::new(),
643 BTreeSet::new(),
644 ),
645 (Some(Written::Changed(Some(live))), Remote::Config(rest)) => {
646 let writes_at = write_paths(&res.compare, live);
647 let shown_at = already_shown(&writes_at, &res.compare, rest);
648 (
649 BaselineBasis::LiveFile,
650 read_back_paths(live, rest),
651 writes_at,
652 shown_at,
653 )
654 }
655 (Some(_), _) => (
656 BaselineBasis::Strict,
657 against(&r0, &res.compare),
658 BTreeSet::new(),
659 BTreeSet::new(),
660 ),
661 };
662 (
663 res.key.clone(),
664 BaselineEntry {
665 basis,
666 before: r0,
667 differs_at,
668 writes_at,
669 shown_at,
670 },
671 )
672 })
673 .collect();
674 ReadBackBaseline {
675 node: node.to_string(),
676 resources,
677 }
678 }
679
680 pub fn node(&self) -> &str {
682 &self.node
683 }
684
685 pub fn entry(&self, key: &str) -> Option<&BaselineEntry> {
687 self.resources.get(key)
688 }
689
690 pub fn record(&self) -> BaselineRecord {
698 let resources = self
699 .resources
700 .iter()
701 .map(|(key, entry)| {
702 let state = match &entry.before {
703 Remote::Config(_) => "config",
704 Remote::Missing => "missing",
705 Remote::System => "system",
706 };
707 (
708 key.clone(),
709 BaselineRecordEntry {
710 state,
711 basis: entry.basis,
712 differs_at: entry.differs_at.iter().cloned().collect(),
713 writes_at: entry.writes_at.iter().cloned().collect(),
714 },
715 )
716 })
717 .collect();
718 BaselineRecord {
719 node: self.node.clone(),
720 resources,
721 }
722 }
723}
724
725#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
728pub struct BaselineRecord {
729 pub node: String,
731 pub resources: BTreeMap<String, BaselineRecordEntry>,
733}
734
735#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
737pub struct BaselineRecordEntry {
738 pub state: &'static str,
740 pub basis: BaselineBasis,
742 pub differs_at: Vec<String>,
744 pub writes_at: Vec<String>,
748}
749
750pub fn path_covered(path: &str, by: &BTreeSet<String>) -> bool {
754 by.iter().any(|ancestor| path_under(path, ancestor))
755}
756
757fn path_under(path: &str, ancestor: &str) -> bool {
760 ancestor == ROOT_PATH
761 || path == ancestor
762 || (path.len() > ancestor.len()
763 && path.starts_with(ancestor)
764 && matches!(path.as_bytes()[ancestor.len()], b'.' | b'['))
765}
766
767fn paths_overlap(a: &str, b: &str) -> bool {
770 path_under(a, b) || path_under(b, a)
771}
772
773fn shows(rest: &Value, at: &str, target: &str) -> bool {
780 if rest.is_null() {
781 return false;
782 }
783 if target == ROOT_PATH || at == target {
784 return true;
785 }
786 let below =
787 |child: &str, value: &Value| path_under(target, child) && shows(value, child, target);
788 match rest {
789 Value::Object(map) => map
790 .iter()
791 .any(|(key, value)| below(&child_path(at, key), value)),
792 Value::Array(items) => items
793 .iter()
794 .enumerate()
795 .any(|(i, value)| below(&child_path(at, &i.to_string()), value)),
796 _ => true,
797 }
798}
799
800pub fn evaluate(expected: &Expected, observed: &ObservedState) -> VerifyReport {
802 evaluate_with(expected, observed, &ReadBackJudge::Strict)
803}
804
805pub fn evaluate_with(
807 expected: &Expected,
808 observed: &ObservedState,
809 judge: &ReadBackJudge<'_>,
810) -> VerifyReport {
811 let mut warnings = Vec::new();
812 let (read_back, resources_checked) =
813 read_back_check_with(expected, &observed.read_back, judge, &mut warnings);
814 let checks = vec![
815 check_scan(observed),
816 check_mode(expected, observed),
817 read_back,
818 check_files(expected, observed, &mut warnings),
819 check_loaded(expected, observed),
820 check_probes(expected, observed),
821 ];
822 for check in &checks {
823 if check.status == CheckStatus::Warn {
824 warnings.push(format!("{}: {}", check_word(check.id), check.detail));
825 }
826 }
827 let ok = checks.iter().all(|c| c.status != CheckStatus::Fail);
828 VerifyReport {
829 ok,
830 slug: (!ok).then_some(VERIFY_FAILED_SLUG),
831 checks,
832 resources_checked,
833 warnings,
834 }
835}
836
837fn check_word(id: CheckId) -> &'static str {
838 match id {
839 CheckId::Scan => "scan",
840 CheckId::Mode => "mode",
841 CheckId::ReadBack => "read_back",
842 CheckId::Files => "files",
843 CheckId::Loaded => "loaded",
844 CheckId::Probes => "probes",
845 }
846}
847
848fn finding(code: &'static str, subject: impl Into<String>, detail: impl Into<String>) -> Finding {
849 Finding {
850 code,
851 subject: subject.into(),
852 detail: detail.into(),
853 status: None,
854 }
855}
856
857fn sort_findings(findings: &mut [Finding]) {
860 findings.sort_by(|a, b| {
861 a.subject
862 .as_bytes()
863 .cmp(b.subject.as_bytes())
864 .then_with(|| a.code.cmp(b.code))
865 });
866}
867
868fn finished(id: CheckId, detail: String, findings: Vec<Finding>) -> Check {
869 Check {
870 id,
871 status: if findings.is_empty() {
872 CheckStatus::Pass
873 } else {
874 CheckStatus::Fail
875 },
876 detail,
877 findings,
878 }
879}
880
881fn check_scan(observed: &ObservedState) -> Check {
884 if observed.scan.scan_active {
885 return finished(
886 CheckId::Scan,
887 "a config scan is running".to_string(),
888 vec![finding(
889 "scan_active",
890 "config",
891 "the gateway reports an active config scan; verify reads settled state only",
892 )],
893 );
894 }
895 finished(
896 CheckId::Scan,
897 "no config scan is running".to_string(),
898 Vec::new(),
899 )
900}
901
902fn check_mode(expected: &Expected, observed: &ObservedState) -> Check {
905 mode_check(expected, observed.mode.as_deref())
906}
907
908pub(crate) fn mode_check(expected: &Expected, mode: Option<&str>) -> Check {
911 if mode == Some(expected.mode.as_str()) {
912 return finished(
913 CheckId::Mode,
914 format!("the gateway runs mode `{}`", expected.mode),
915 Vec::new(),
916 );
917 }
918 let seen = match mode {
919 Some(mode) => format!("`{}`", printable(mode)),
920 None => "no mode".to_string(),
921 };
922 finished(
923 CheckId::Mode,
924 "the gateway does not run the target's mode".to_string(),
925 vec![finding(
926 "mode_mismatch",
927 "mode",
928 format!("expected `{}`, the gateway reports {seen}", expected.mode),
929 )],
930 )
931}
932
933pub(crate) fn read_back_check_with(
997 expected: &Expected,
998 read_back: &BTreeMap<String, Remote>,
999 judge: &ReadBackJudge<'_>,
1000 warnings: &mut Vec<String>,
1001) -> (Check, usize) {
1002 let mut findings = Vec::new();
1003 let mut checked = 0usize;
1004 let mut warned = 0usize;
1005 for res in &expected.resources {
1006 let after = read_back.get(&res.key).unwrap_or(&Remote::Missing);
1007 let paths = match after {
1008 Remote::System => {
1009 match system_red(&res.key, judge) {
1010 Some(f) => {
1011 checked += 1;
1012 findings.push(f);
1013 }
1014 None => warnings.push(format!(
1015 "read_back: {} not compared: the effective copy is in the `{SYSTEM_COLLECTION}` collection (gateway-provided, not file-backed)",
1016 res.key
1017 )),
1018 }
1019 continue;
1020 }
1021 Remote::Missing => None,
1022 Remote::Config(rest) => Some(read_back_paths(&res.compare, rest)),
1023 };
1024 checked += 1;
1025 for verdict in judge_resource(&res.key, after, paths.as_ref(), judge) {
1026 match verdict {
1027 Verdict::Pass => {}
1028 Verdict::Red(f) => findings.push(f),
1029 Verdict::Warn(mut f, line) => {
1030 f.status = Some(CheckStatus::Warn);
1031 findings.push(f);
1032 warnings.push(line);
1033 warned += 1;
1034 }
1035 }
1036 }
1037 }
1038 for (key, reason) in &expected.not_checked {
1039 warnings.push(format!("read_back: {key} not compared: {reason}"));
1040 }
1041 if checked == 0 && findings.is_empty() {
1042 warnings.push(
1043 "read_back: no declared non-tag resource was compared (nothing to read back)"
1044 .to_string(),
1045 );
1046 }
1047 sort_findings(&mut findings);
1048 let mut detail =
1049 format!("{checked} declared resource(s) compared with the REST effective view");
1050 let status = if findings.iter().any(|f| f.status.is_none()) {
1051 CheckStatus::Fail
1052 } else if findings.is_empty() {
1053 CheckStatus::Pass
1054 } else {
1055 detail.push_str(&format!("; {warned} difference(s) reported as warnings"));
1056 CheckStatus::Warn
1057 };
1058 (
1059 Check {
1060 id: CheckId::ReadBack,
1061 status,
1062 detail,
1063 findings,
1064 },
1065 checked,
1066 )
1067}
1068
1069pub(crate) fn read_back_check(
1072 expected: &Expected,
1073 read_back: &BTreeMap<String, Remote>,
1074 warnings: &mut Vec<String>,
1075) -> (Check, usize) {
1076 read_back_check_with(expected, read_back, &ReadBackJudge::Strict, warnings)
1077}
1078
1079fn system_red(key: &str, judge: &ReadBackJudge<'_>) -> Option<Finding> {
1085 let ReadBackJudge::Baseline(baseline) = judge else {
1086 return None;
1087 };
1088 let entry = baseline.entry(key)?;
1089 let why = match (entry.basis, &entry.before) {
1090 (BaselineBasis::DeclaredFile, Remote::Config(_)) => {
1091 "it was file-backed before this apply and dropped out of the effective view during this apply"
1092 }
1093 (BaselineBasis::DeclaredFile, _) => return None,
1094 (BaselineBasis::LiveFile | BaselineBasis::Strict, _) => {
1095 "this apply wrote it, so the write did not take effect"
1096 }
1097 };
1098 Some(finding(
1099 "resource_missing",
1100 key,
1101 format!(
1102 "{MISSING_DETAIL} as a file-backed copy: the effective copy is in the `{SYSTEM_COLLECTION}` collection ({why})"
1103 ),
1104 ))
1105}
1106
1107enum Verdict {
1109 Pass,
1110 Red(Finding),
1111 Warn(Finding, String),
1113}
1114
1115const MISSING_DETAIL: &str = "not in the gateway's effective view";
1117
1118const UNOBSERVABLE_DETAIL: &str =
1120 "not visible in the gateway's REST view, so the write could not be verified";
1121
1122fn warn_differs(key: &str, p: &BTreeSet<String>, why: &str) -> Verdict {
1125 if p.is_empty() {
1126 return Verdict::Pass;
1127 }
1128 let at = join_set(p);
1129 Verdict::Warn(
1130 finding("resource_differs", key, format!("differs at: {at} ({why})")),
1131 format!("read_back: {key} differs from its file at: {at} ({why})"),
1132 )
1133}
1134
1135fn warn_unobservable(key: &str, unseen: &BTreeSet<String>) -> Verdict {
1138 if unseen.is_empty() {
1139 return Verdict::Pass;
1140 }
1141 let verb = if unseen.len() == 1 { "is" } else { "are" };
1142 let detail = format!("{} {verb} {UNOBSERVABLE_DETAIL}", join_set(unseen));
1143 Verdict::Warn(
1144 finding("resource_differs", key, detail.clone()),
1145 format!("read_back: {key}: {detail}"),
1146 )
1147}
1148
1149fn judge_resource(
1154 key: &str,
1155 after: &Remote,
1156 paths: Option<&BTreeSet<String>>,
1157 judge: &ReadBackJudge<'_>,
1158) -> Vec<Verdict> {
1159 let strict = || match paths {
1160 None => Verdict::Red(finding("resource_missing", key, MISSING_DETAIL)),
1161 Some(p) if p.is_empty() => Verdict::Pass,
1162 Some(p) => Verdict::Red(finding(
1163 "resource_differs",
1164 key,
1165 format!("differs at: {}", join_set(p)),
1166 )),
1167 };
1168 let warn = |why: &str| match paths {
1169 None => Verdict::Warn(
1170 finding("resource_missing", key, format!("{MISSING_DETAIL} ({why})")),
1171 format!("read_back: {key} is {MISSING_DETAIL} ({why})"),
1172 ),
1173 Some(p) => warn_differs(key, p, why),
1174 };
1175 match judge {
1176 ReadBackJudge::Strict => vec![strict()],
1177 ReadBackJudge::Standalone { file_status } => {
1178 vec![if file_status.get(key) == Some(&PlanStatus::Same) {
1179 match paths {
1180 None => warn("the live files equal the repo"),
1181 Some(_) => {
1182 warn("the live files equal the repo; the gateway's REST view differs")
1183 }
1184 }
1185 } else {
1186 strict()
1187 }]
1188 }
1189 ReadBackJudge::Baseline(baseline) => {
1190 let Some(entry) = baseline.entry(key) else {
1191 return vec![strict()];
1192 };
1193 let Some(p) = paths else {
1194 return vec![match (entry.basis, &entry.before) {
1197 (BaselineBasis::DeclaredFile, Remote::Missing) => {
1198 warn("it was not there before this apply either")
1199 }
1200 _ => strict(),
1201 }];
1202 };
1203 judge_baseline(key, entry, after, p)
1204 }
1205 }
1206}
1207
1208fn judge_baseline(
1211 key: &str,
1212 entry: &BaselineEntry,
1213 after: &Remote,
1214 p: &BTreeSet<String>,
1215) -> Vec<Verdict> {
1216 let red = |detail: String| Verdict::Red(finding("resource_differs", key, detail));
1217 let unseen = match (entry.basis, &entry.before, after) {
1221 (BaselineBasis::LiveFile, Remote::Config(r0), Remote::Config(r1)) => {
1222 entry.unobservable(r0, r1)
1223 }
1224 _ => BTreeSet::new(),
1225 };
1226 let mut out = vec![warn_unobservable(key, &unseen)];
1227 if p.is_empty() {
1228 return out;
1229 }
1230 out.push(match (entry.basis, &entry.before, after) {
1231 (BaselineBasis::DeclaredFile, Remote::Config(r0), Remote::Config(r1)) => {
1232 let moved = rest_moved(r0, r1);
1233 if moved.is_empty() {
1234 warn_differs(key, p, "already different before this apply")
1235 } else {
1236 red(format!(
1237 "differs at: {}; its REST view changed during this apply at: {}",
1238 join_set(p),
1239 join_set(&moved)
1240 ))
1241 }
1242 }
1243 (BaselineBasis::DeclaredFile, _, _) => red(format!(
1244 "differs at: {}; its REST view changed during this apply at: {ROOT_PATH}",
1245 join_set(p)
1246 )),
1247 (BaselineBasis::LiveFile, Remote::Config(r0), Remote::Config(r1)) => {
1248 judge_written(key, entry, r0, r1, p, &unseen)
1249 }
1250 _ => red(format!("differs at: {}", join_set(p))),
1251 });
1252 out
1253}
1254
1255fn judge_written(
1260 key: &str,
1261 entry: &BaselineEntry,
1262 r0: &Value,
1263 r1: &Value,
1264 p: &BTreeSet<String>,
1265 unseen: &BTreeSet<String>,
1266) -> Verdict {
1267 let red = |detail: String| Verdict::Red(finding("resource_differs", key, detail));
1268 let new: BTreeSet<String> = p
1272 .iter()
1273 .filter(|path| !path_covered(path, &entry.differs_at))
1274 .filter(|path| !unseen.iter().any(|u| paths_overlap(path, u)))
1275 .cloned()
1276 .collect();
1277 if !new.is_empty() {
1278 return red(format!(
1279 "differs at: {} (not different before this apply)",
1280 join_set(&new)
1281 ));
1282 }
1283 let moved = rest_moved(r0, r1);
1284 let judged: BTreeSet<&String> = entry
1286 .writes_at
1287 .iter()
1288 .filter(|w| !unseen.contains(*w) && !entry.shown_at.contains(*w))
1289 .collect();
1290 if !judged.is_empty() && moved.is_empty() {
1291 return red(format!(
1293 "differs at: {} (its REST view did not change during this apply, so the write did not take effect)",
1294 join_set(p)
1295 ));
1296 }
1297 let stuck: BTreeSet<String> = judged
1300 .into_iter()
1301 .filter(|w| p.iter().any(|q| paths_overlap(q, w)))
1302 .filter(|w| !moved.iter().any(|m| paths_overlap(m, w)))
1303 .cloned()
1304 .collect();
1305 if !stuck.is_empty() {
1306 return red(format!(
1307 "differs at: {} (its REST view did not change there during this apply, so that part of the write did not take effect)",
1308 join_set(&stuck)
1309 ));
1310 }
1311 let left: BTreeSet<String> = p
1313 .iter()
1314 .filter(|q| !unseen.iter().any(|u| paths_overlap(q, u)))
1315 .cloned()
1316 .collect();
1317 warn_differs(key, &left, "already different before this apply")
1318}
1319
1320fn read_back_paths(compare: &Compare, rest: &Value) -> BTreeSet<String> {
1323 let mut paths = Vec::new();
1324 match compare {
1325 Compare::Exact(file) => {
1326 diff_exact(&strip_nulls(file), &strip_nulls(rest), "", &mut paths);
1327 }
1328 Compare::NameKeyedSubset(file) => {
1329 diff_subset(&strip_nulls(file), &strip_nulls(rest), "", &mut paths);
1330 }
1331 Compare::Attributes(attrs) => diff_attributes(attrs, rest, &mut paths),
1333 }
1334 paths.into_iter().collect()
1335}
1336
1337fn rest_moved(before: &Value, after: &Value) -> BTreeSet<String> {
1339 let mut paths = Vec::new();
1340 diff_exact(&strip_nulls(before), &strip_nulls(after), "", &mut paths);
1341 paths.into_iter().collect()
1342}
1343
1344fn write_paths(desired: &Compare, live: &Compare) -> BTreeSet<String> {
1349 let ((kd, vd), (kl, vl)) = (compare_doc(desired), compare_doc(live));
1350 if kd != kl {
1351 return BTreeSet::from([ROOT_PATH.to_string()]);
1352 }
1353 let mut paths = Vec::new();
1354 diff_exact(&vd, &vl, "", &mut paths);
1355 paths.into_iter().collect()
1356}
1357
1358fn compare_doc(c: &Compare) -> (u8, Value) {
1360 match c {
1361 Compare::Exact(v) => (0, strip_nulls(v)),
1362 Compare::NameKeyedSubset(v) => (1, strip_nulls(v)),
1363 Compare::Attributes(m) => (2, strip_nulls(&Value::Object(m.clone()))),
1364 }
1365}
1366
1367fn already_shown(writes_at: &BTreeSet<String>, desired: &Compare, r0: &Value) -> BTreeSet<String> {
1370 let (_, doc) = compare_doc(desired);
1371 let r0 = strip_nulls(r0);
1372 writes_at
1373 .iter()
1374 .filter(|w| match (resolve(&r0, "", w), resolve(&doc, "", w)) {
1375 (Some(seen), Some(wanted)) => {
1376 let mut out = Vec::new();
1377 diff_exact(wanted, seen, "", &mut out);
1378 out.is_empty()
1379 }
1380 _ => false,
1381 })
1382 .cloned()
1383 .collect()
1384}
1385
1386fn resolve<'v>(value: &'v Value, at: &str, target: &str) -> Option<&'v Value> {
1391 if target == ROOT_PATH || at == target {
1392 return Some(value);
1393 }
1394 let below = |child: String, v: &'v Value| {
1395 path_under(target, &child)
1396 .then(|| resolve(v, &child, target))
1397 .flatten()
1398 };
1399 match value {
1400 Value::Object(map) => map
1401 .iter()
1402 .find_map(|(key, v)| below(child_path(at, key), v)),
1403 Value::Array(items) => items
1404 .iter()
1405 .enumerate()
1406 .find_map(|(i, v)| below(child_path(at, &i.to_string()), v)),
1407 _ => None,
1408 }
1409}
1410
1411fn join_set(paths: &BTreeSet<String>) -> String {
1412 join_paths(paths.iter().cloned().collect())
1413}
1414
1415fn strip_nulls(value: &Value) -> Value {
1419 match value {
1420 Value::Object(map) => Value::Object(
1421 map.iter()
1422 .filter(|(_, v)| !v.is_null())
1423 .map(|(k, v)| (k.clone(), strip_nulls(v)))
1424 .collect(),
1425 ),
1426 Value::Array(items) => Value::Array(items.iter().map(strip_nulls).collect()),
1427 other => other.clone(),
1428 }
1429}
1430
1431fn child_path(path: &str, key: &str) -> String {
1432 let key = printable(key);
1435 if path.is_empty() {
1436 key
1437 } else {
1438 format!("{path}.{key}")
1439 }
1440}
1441
1442fn root_or(path: &str) -> String {
1443 if path.is_empty() {
1444 ROOT_PATH.to_string()
1445 } else {
1446 path.to_string()
1447 }
1448}
1449
1450fn same_scalar(a: &Value, b: &Value) -> bool {
1452 match (a, b) {
1453 (Value::Number(x), Value::Number(y)) => {
1454 if x.is_f64() || y.is_f64() {
1457 matches!((x.as_f64(), y.as_f64()), (Some(x), Some(y)) if x == y)
1458 } else {
1459 x == y
1460 }
1461 }
1462 _ => a == b,
1463 }
1464}
1465
1466fn diff_exact(file: &Value, rest: &Value, path: &str, out: &mut Vec<String>) {
1468 match (file, rest) {
1469 (Value::Object(f), Value::Object(r)) => {
1470 for (key, fv) in f {
1471 match r.get(key) {
1472 Some(rv) => diff_exact(fv, rv, &child_path(path, key), out),
1473 None => out.push(child_path(path, key)),
1474 }
1475 }
1476 for key in r.keys().filter(|k| !f.contains_key(*k)) {
1477 out.push(child_path(path, key));
1478 }
1479 }
1480 (Value::Array(f), Value::Array(r)) if f.len() == r.len() => {
1481 for (i, (fv, rv)) in f.iter().zip(r).enumerate() {
1482 diff_exact(fv, rv, &child_path(path, &i.to_string()), out);
1483 }
1484 }
1485 (Value::Object(_) | Value::Array(_), _) | (_, Value::Object(_) | Value::Array(_)) => {
1486 out.push(root_or(path));
1487 }
1488 (f, r) => {
1489 if !same_scalar(f, r) {
1490 out.push(root_or(path));
1491 }
1492 }
1493 }
1494}
1495
1496fn diff_subset(file: &Value, rest: &Value, path: &str, out: &mut Vec<String>) {
1499 match (file, rest) {
1500 (Value::Object(f), Value::Object(r)) => {
1501 for (key, fv) in f {
1502 match r.get(key) {
1503 Some(rv) => diff_subset(fv, rv, &child_path(path, key), out),
1504 None => out.push(child_path(path, key)),
1505 }
1506 }
1507 }
1508 (Value::Array(f), Value::Array(r)) => {
1509 for (i, fv) in f.iter().enumerate() {
1510 let name = fv.get("name").and_then(Value::as_str);
1511 if let (Some(name), true) = (name, fv.is_object()) {
1512 let at = format!("{path}[{}]", printable(name));
1513 match r
1514 .iter()
1515 .find(|rv| rv.get("name").and_then(Value::as_str) == Some(name))
1516 {
1517 Some(rv) => diff_subset(fv, rv, &at, out),
1518 None => out.push(at),
1519 }
1520 } else {
1521 let found = r.iter().any(|rv| {
1522 let mut inner = Vec::new();
1523 diff_exact(fv, rv, "", &mut inner);
1524 inner.is_empty()
1525 });
1526 if !found {
1527 out.push(child_path(path, &i.to_string()));
1528 }
1529 }
1530 }
1531 }
1532 (Value::Object(_) | Value::Array(_), _) | (_, Value::Object(_) | Value::Array(_)) => {
1533 out.push(root_or(path));
1534 }
1535 (f, r) => {
1536 if !same_scalar(f, r) {
1537 out.push(root_or(path));
1538 }
1539 }
1540 }
1541}
1542
1543fn diff_attributes(attrs: &Map<String, Value>, rest: &Value, out: &mut Vec<String>) {
1549 let Some(rest) = rest.as_object() else {
1550 out.push("(root)".to_string());
1551 return;
1552 };
1553 for (key, expected) in attrs {
1554 if let Some(seen) = rest.get(key) {
1555 let mut inner = Vec::new();
1556 diff_exact(&strip_nulls(expected), &strip_nulls(seen), "", &mut inner);
1557 if !inner.is_empty() {
1558 out.push(key.clone());
1559 }
1560 }
1561 }
1562}
1563
1564fn join_paths(mut paths: Vec<String>) -> String {
1565 paths.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
1566 paths.dedup();
1567 let more = paths.len().saturating_sub(MAX_PATHS);
1568 paths.truncate(MAX_PATHS);
1569 let mut text = paths.join(", ");
1570 if more > 0 {
1571 text.push_str(&format!(" (+{more} more)"));
1572 }
1573 text
1574}
1575
1576const UNREADABLE_DETAIL: &str =
1580 "the unit has a live path the host's sftp server cannot read; its live copy is unknown";
1581
1582fn check_files(expected: &Expected, observed: &ObservedState, warnings: &mut Vec<String>) -> Check {
1583 let mut findings = Vec::new();
1584 for unit in &expected.tag_units {
1585 if observed.unreadable.iter().any(|(key, _)| key == unit) {
1587 findings.push(finding("unreadable", unit.clone(), UNREADABLE_DETAIL));
1588 continue;
1589 }
1590 let status = observed
1591 .files
1592 .iter()
1593 .find(|(key, _)| key == unit)
1594 .map(|(_, status)| *status);
1595 match status {
1596 Some(PlanStatus::Same) => {}
1597 Some(other) => findings.push(finding(
1598 "files_differ",
1599 unit.clone(),
1600 format!(
1601 "the effective live copy is `{}` against the render",
1602 other.word()
1603 ),
1604 )),
1605 None => findings.push(finding(
1606 "files_differ",
1607 unit.clone(),
1608 "the unit was not found among the live files",
1609 )),
1610 }
1611 }
1612 for (key, declared) in &observed.unreadable {
1613 if expected.tag_units.contains(key) {
1614 continue;
1615 }
1616 if *declared {
1617 findings.push(finding("unreadable", key.clone(), UNREADABLE_DETAIL));
1618 } else {
1619 warnings.push(format!(
1620 "files: {key} is unreadable on the host and is not declared; it was not compared"
1621 ));
1622 }
1623 }
1624 sort_findings(&mut findings);
1625 finished(
1626 CheckId::Files,
1627 format!(
1628 "{} declared tag unit(s) compared byte for byte with the live files",
1629 expected.tag_units.len()
1630 ),
1631 findings,
1632 )
1633}
1634
1635fn check_loaded(expected: &Expected, observed: &ObservedState) -> Check {
1638 if observed.scan.scan_active {
1639 return Check {
1640 id: CheckId::Loaded,
1641 status: CheckStatus::Warn,
1642 detail: "not observed: a scan is running, so its log lines are not readable yet"
1643 .to_string(),
1644 findings: Vec::new(),
1645 };
1646 }
1647 let lines = match &observed.logs {
1648 Ok(lines) => lines,
1649 Err(slug) => {
1650 return finished(
1651 CheckId::Loaded,
1652 "the gateway log could not be read".to_string(),
1653 vec![finding("logs_unavailable", "logs", printable(slug))],
1654 );
1655 }
1656 };
1657
1658 let mut hits: BTreeMap<&str, (i64, &str)> = BTreeMap::new();
1661 for line in lines {
1662 if !line.message.contains(WILL_NOT_BE_LOADED) {
1663 continue;
1664 }
1665 let owner = expected
1666 .declared_ids
1667 .iter()
1668 .filter(|id| line.message.contains(&format!("Resource {id} ")))
1669 .max_by_key(|id| id.len());
1670 if let Some(id) = owner {
1671 let entry = hits
1672 .entry(id.as_str())
1673 .or_insert((line.timestamp, line.logger.as_str()));
1674 if line.timestamp < entry.0 {
1675 *entry = (line.timestamp, line.logger.as_str());
1676 }
1677 }
1678 }
1679 let mut findings: Vec<Finding> = hits
1680 .into_iter()
1681 .map(|(id, (timestamp, logger))| {
1682 finding(
1683 "unloaded_resource",
1684 id,
1685 format!("logged at {timestamp} by `{}`", printable(logger)),
1686 )
1687 })
1688 .collect();
1689 sort_findings(&mut findings);
1690 let detail = if findings.is_empty() {
1691 format!(
1692 "no `{WILL_NOT_BE_LOADED}` line for a declared resource since the last scan (the gateway logs it once per scan that finds the resource, and not for every type, so this covers the last scan only)"
1693 )
1694 } else {
1695 format!("{} declared resource(s) were not loaded", findings.len())
1696 };
1697 finished(CheckId::Loaded, detail, findings)
1698}
1699
1700fn check_probes(expected: &Expected, observed: &ObservedState) -> Check {
1703 if expected.probes.is_empty() {
1704 return Check {
1705 id: CheckId::Probes,
1706 status: CheckStatus::Warn,
1707 detail: "the manifest declares no `verify.tag_probes`; nothing was probed".to_string(),
1708 findings: vec![finding(
1709 "tag_probes_absent",
1710 "verify.tag_probes",
1711 "add tag paths to the manifest to probe them after a deploy",
1712 )],
1713 };
1714 }
1715 let readings = match &observed.probes {
1716 Some(Ok(readings)) => readings,
1717 Some(Err(slug)) => {
1718 return finished(
1719 CheckId::Probes,
1720 "the probes could not be read".to_string(),
1721 vec![finding("tag_probes_unavailable", "tags", printable(slug))],
1722 );
1723 }
1724 None => {
1725 return finished(
1726 CheckId::Probes,
1727 "the probes could not be read".to_string(),
1728 vec![finding("tag_probes_unavailable", "tags", "not read")],
1729 );
1730 }
1731 };
1732 let mut findings = Vec::new();
1733 for path in &expected.probes {
1734 match readings.iter().find(|r| &r.path == path) {
1735 Some(r) if r.quality.starts_with("Good") => {}
1736 Some(r) => findings.push(finding(
1737 "tag_probe_not_good",
1738 path.clone(),
1739 format!("quality {}", quality_code(&r.quality)),
1740 )),
1741 None => findings.push(finding("tag_probe_not_good", path.clone(), "no reading")),
1742 }
1743 }
1744 finished(
1746 CheckId::Probes,
1747 format!("{} tag probe(s) read", expected.probes.len()),
1748 findings,
1749 )
1750}
1751
1752fn quality_code(quality: &str) -> String {
1756 if quality.is_empty() {
1757 return "empty".to_string();
1758 }
1759 let (class, rest) = match quality.split_once('_') {
1760 Some((class, rest)) => (class, Some(rest)),
1761 None => (quality, None),
1762 };
1763 if !matches!(class, "Good" | "Bad" | "Uncertain" | "Error") {
1764 return "unrecognised".to_string();
1765 }
1766 match rest {
1767 Some(rest)
1768 if !rest.is_empty()
1769 && rest.len() <= 48
1770 && rest.chars().all(|c| c.is_ascii_alphabetic() || c == '_') =>
1771 {
1772 format!("{class}_{rest}")
1773 }
1774 _ => class.to_string(),
1775 }
1776}
1777
1778fn printable(text: &str) -> String {
1782 let mut out: String = text
1783 .chars()
1784 .filter(|c| !c.is_control())
1785 .take(MAX_CODE_CHARS)
1786 .collect();
1787 if text.chars().count() > MAX_CODE_CHARS {
1788 out.push_str("...");
1789 }
1790 out
1791}
1792
1793#[cfg(test)]
1794mod tests {
1795 use super::*;
1796 use serde_json::json;
1797
1798 #[test]
1801 fn words_equal_the_serialized_forms() {
1802 for id in [
1803 CheckId::Scan,
1804 CheckId::Mode,
1805 CheckId::ReadBack,
1806 CheckId::Files,
1807 CheckId::Loaded,
1808 CheckId::Probes,
1809 ] {
1810 assert_eq!(json!(id), json!(id.word()));
1811 }
1812 for status in [CheckStatus::Pass, CheckStatus::Fail, CheckStatus::Warn] {
1813 assert_eq!(json!(status), json!(status.word()));
1814 }
1815 }
1816
1817 #[test]
1818 fn quality_codes_carry_no_free_text() {
1819 assert_eq!(quality_code("Good"), "Good");
1820 assert_eq!(quality_code("Good_Provisional"), "Good_Provisional");
1821 assert_eq!(quality_code("Bad_NotFound"), "Bad_NotFound");
1822 assert_eq!(quality_code("Bad_secret-123"), "Bad");
1823 assert_eq!(quality_code("Weird"), "unrecognised");
1824 assert_eq!(quality_code(""), "empty");
1825 }
1826
1827 #[test]
1828 fn printable_drops_controls_and_caps_length() {
1829 assert_eq!(printable("a\nb\u{7}c"), "abc");
1830 let long = "x".repeat(MAX_CODE_CHARS + 10);
1831 assert_eq!(printable(&long).len(), MAX_CODE_CHARS + 3);
1832 }
1833
1834 #[test]
1835 fn gateway_supplied_keys_are_sanitised_in_diff_paths() {
1836 let hostile = format!("evil\u{1b}[31m{}", "k".repeat(MAX_CODE_CHARS * 2));
1839 let mut rest = serde_json::Map::new();
1840 rest.insert(hostile, json!(1));
1841 let mut out = Vec::new();
1842 diff_exact(&json!({}), &Value::Object(rest), "", &mut out);
1843 assert_eq!(out.len(), 1);
1844 assert!(!out[0].contains('\u{1b}'), "{:?}", out[0]);
1845 assert!(
1846 out[0].chars().count() <= MAX_CODE_CHARS + 3,
1847 "{}",
1848 out[0].len()
1849 );
1850 }
1851
1852 #[test]
1853 fn large_integers_compare_exactly() {
1854 let mut out = Vec::new();
1856 diff_exact(
1857 &json!({"id": 9_007_199_254_740_993_i64}),
1858 &json!({"id": 9_007_199_254_740_992_i64}),
1859 "",
1860 &mut out,
1861 );
1862 assert_eq!(out, vec!["id".to_string()]);
1863 let mut out = Vec::new();
1864 diff_exact(
1865 &json!({"id": u64::MAX}),
1866 &json!({"id": u64::MAX - 1}),
1867 "",
1868 &mut out,
1869 );
1870 assert_eq!(out, vec!["id".to_string()]);
1871 let mut out = Vec::new();
1872 diff_exact(
1873 &json!({"id": 9_007_199_254_740_993_i64}),
1874 &json!({"id": 9_007_199_254_740_993_i64}),
1875 "",
1876 &mut out,
1877 );
1878 assert!(out.is_empty());
1879 }
1880
1881 #[test]
1882 fn numbers_compare_by_value() {
1883 let mut out = Vec::new();
1884 diff_exact(
1885 &json!({"a": 1, "b": 2.5}),
1886 &json!({"a": 1.0, "b": 2.5}),
1887 "",
1888 &mut out,
1889 );
1890 assert!(out.is_empty(), "{out:?}");
1891 diff_exact(&json!({"a": 1}), &json!({"a": 2}), "", &mut out);
1892 assert_eq!(out, vec!["a".to_string()]);
1893 }
1894
1895 #[test]
1896 fn shows_resolves_paths_ancestors_and_opaque_values() {
1897 let rest = json!({
1898 "settings": {"enabled": true, "blob": "opaque", "empty": {}, "gone": null},
1899 "list": [{"host": "a"}]
1900 });
1901 assert!(shows(&rest, "", "settings.enabled"));
1903 assert!(shows(&rest, "", "settings"));
1904 assert!(shows(&rest, "", "list.0.host"));
1905 assert!(shows(&rest, "", ROOT_PATH));
1906 assert!(shows(&rest, "", "settings.blob.hostname"));
1908 assert!(!shows(&rest, "", "settings.deviceSettings"));
1911 assert!(!shows(&rest, "", "settings.gone"));
1912 assert!(!shows(&rest, "", "list.1"));
1913 assert!(!shows(&rest, "", "settings.empty.x"));
1914 assert!(!shows(&rest, "", "settingsX"));
1915 assert!(!shows(&Value::Null, "", ROOT_PATH));
1917 assert!(!shows(&Value::Null, "", "namespace"));
1918 }
1919
1920 #[test]
1921 fn paths_overlap_is_symmetric_on_segment_boundaries() {
1922 for (a, b) in [
1924 ("a", "a"),
1925 ("a", "a.b"),
1926 ("a", "a.b.c"),
1927 ("a", "a[n]"),
1928 ("s", "s.0.a"),
1929 ] {
1930 assert!(paths_overlap(a, b), "{a} {b}");
1931 assert!(paths_overlap(b, a), "{b} {a}");
1932 }
1933 for p in ["a", "a.b", "x[y]", ROOT_PATH] {
1935 assert!(paths_overlap(ROOT_PATH, p), "{p}");
1936 assert!(paths_overlap(p, ROOT_PATH), "{p}");
1937 }
1938 for (a, b) in [
1940 ("a", "ab"),
1941 ("a.b", "a.bc"),
1942 ("a.b", "a.c"),
1943 ("s.0", "s.1"),
1944 ("b.a", "a"),
1945 ] {
1946 assert!(!paths_overlap(a, b), "{a} {b}");
1947 assert!(!paths_overlap(b, a), "{b} {a}");
1948 }
1949 }
1950
1951 #[test]
1952 fn resolve_finds_present_paths_only() {
1953 let doc = json!({"s": {"a": 1, "blob": "opaque"}, "l": [{"h": "x"}]});
1954 assert_eq!(resolve(&doc, "", "s.a"), Some(&json!(1)));
1955 assert_eq!(resolve(&doc, "", "l.0.h"), Some(&json!("x")));
1956 assert_eq!(resolve(&doc, "", ROOT_PATH), Some(&doc));
1957 assert_eq!(resolve(&doc, "", "s"), doc.get("s"));
1958 assert_eq!(resolve(&doc, "", "s.b"), None);
1960 assert_eq!(resolve(&doc, "", "l.1"), None);
1961 assert_eq!(resolve(&doc, "", "s.blob.hostname"), None);
1962 assert_eq!(resolve(&doc, "", "sa"), None);
1963 }
1964
1965 #[test]
1966 fn path_list_is_sorted_deduplicated_and_capped() {
1967 let paths: Vec<String> = (0..MAX_PATHS + 3)
1968 .rev()
1969 .map(|i| format!("p{i:02}"))
1970 .collect();
1971 let text = join_paths(paths);
1972 assert!(text.starts_with("p00, p01"), "{text}");
1973 assert!(text.ends_with("(+3 more)"), "{text}");
1974 }
1975}