1use 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
81pub const VERIFY_FAILED_SLUG: &str = "deploy_verify_failed";
83
84pub const PROBE_DEADLINE: Duration = Duration::from_secs(30);
86
87pub const PROBE_INTERVAL: Duration = Duration::from_secs(1);
89
90pub const LOG_WINDOW_SLACK_MS: i64 = 1_000;
94
95pub const LOG_WINDOW_FALLBACK_MS: i64 = 30_000;
97
98pub const LOG_SETTLE_MS: i64 = 5_000;
101
102pub const WILL_NOT_BE_LOADED: &str = "will not be loaded";
104
105pub const NO_READ_ROUTE: &[(&str, &str)] = &[
108 ("com.inductiveautomation.opcua", "one-time"),
109 ("ignition", "images"),
110];
111
112pub const SYSTEM_COLLECTION: &str = "system";
114
115const MAX_PATHS: usize = 20;
117
118const MAX_CODE_CHARS: usize = 96;
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
123#[serde(rename_all = "snake_case")]
124pub enum CheckId {
125 Scan,
127 Mode,
129 ReadBack,
131 Files,
133 Loaded,
135 Probes,
137}
138
139impl CheckId {
140 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
156#[serde(rename_all = "lowercase")]
157pub enum CheckStatus {
158 Pass,
160 Fail,
162 Warn,
164}
165
166impl CheckStatus {
167 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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
179pub struct Finding {
180 pub code: &'static str,
185 pub subject: String,
187 pub detail: String,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
193pub struct Check {
194 pub id: CheckId,
196 pub status: CheckStatus,
198 pub detail: String,
200 pub findings: Vec<Finding>,
202}
203
204#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
206pub struct VerifyReport {
207 pub ok: bool,
209 pub slug: Option<&'static str>,
211 pub checks: Vec<Check>,
213 pub resources_checked: usize,
215 pub warnings: Vec<String>,
217}
218
219#[derive(Clone, PartialEq)]
221pub enum Compare {
222 Exact(Value),
224 NameKeyedSubset(Value),
226 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#[derive(Debug, Clone, PartialEq)]
243pub struct ExpectedResource {
244 pub key: String,
246 pub module: String,
248 pub ty: String,
250 pub name: Option<String>,
252 pub compare: Compare,
254}
255
256impl ExpectedResource {
257 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 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
274pub 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#[derive(Debug, Clone, PartialEq)]
284pub struct Expected {
285 pub mode: String,
287 pub resources: Vec<ExpectedResource>,
289 pub not_checked: Vec<(String, String)>,
292 pub declared_ids: Vec<String>,
295 pub tag_units: Vec<String>,
297 pub probes: Vec<String>,
299}
300
301impl Expected {
302 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#[derive(Clone, PartialEq)]
387pub enum Remote {
388 Missing,
390 Config(Value),
392 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#[derive(Clone, PartialEq, Eq)]
408pub struct LogLine {
409 pub timestamp: i64,
411 pub logger: String,
413 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#[derive(Clone, PartialEq, Eq)]
429pub struct ProbeReading {
430 pub path: String,
432 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#[derive(Debug, Clone)]
447pub struct ObservedState {
448 pub scan: ScanStatus,
450 pub mode: Option<String>,
452 pub read_back: BTreeMap<String, Remote>,
454 pub files: Vec<(String, PlanStatus)>,
456 pub logs: Result<Vec<LogLine>, String>,
459 pub probes: Option<Result<Vec<ProbeReading>, String>>,
462 pub unreadable: Vec<(String, bool)>,
467}
468
469pub 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
476pub 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
522fn 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
546fn 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
567fn check_mode(expected: &Expected, observed: &ObservedState) -> Check {
570 mode_check(expected, observed.mode.as_deref())
571}
572
573pub(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
598fn 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
608pub(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 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
670fn 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 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
705fn same_scalar(a: &Value, b: &Value) -> bool {
707 match (a, b) {
708 (Value::Number(x), Value::Number(y)) => {
709 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
721fn 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
751fn 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
798fn 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
831const 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 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
890fn 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 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
955fn 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 finished(
1001 CheckId::Probes,
1002 format!("{} tag probe(s) read", expected.probes.len()),
1003 findings,
1004 )
1005}
1006
1007fn 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
1033fn 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 #[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 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 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}