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