1use std::collections::{BTreeMap, BTreeSet};
19
20use super::layout::{RelPath, ResourceKey};
21use super::node::Node;
22use super::rules::{openapi_describes, secret_paths};
23use super::target::{Target, TargetOs};
24use super::tree::RenderedTree;
25use crate::error::DeployFinding;
26
27#[derive(Debug, Clone, Default, PartialEq, Eq)]
29pub struct SecretScan {
30 pub refused: Vec<String>,
33 pub warnings: Vec<String>,
35}
36
37const ROOT_PATH: &str = "$";
39
40fn is_embedded(node: &Node) -> bool {
43 matches!(node.get("type"), Some(Node::Str(t)) if t == "Embedded")
44 && matches!(node.get("data"), Some(Node::Obj(_)))
45}
46
47fn is_referenced(node: &Node) -> bool {
55 let Node::Obj(members) = node else {
56 return false;
57 };
58 if members.len() != 2 || !matches!(node.get("type"), Some(Node::Str(t)) if t == "Referenced") {
59 return false;
60 }
61 match node.get("data") {
62 Some(data @ Node::Obj(inner)) => {
63 inner.len() == 2
64 && matches!(data.get("providerName"), Some(Node::Str(_)))
65 && matches!(data.get("secretName"), Some(Node::Str(_)))
66 }
67 _ => false,
68 }
69}
70
71pub fn referenced_secret_names(node: &Node, out: &mut BTreeSet<(String, String)>) {
80 if is_referenced(node) {
81 if let Some(data) = node.get("data")
82 && let (Some(Node::Str(provider)), Some(Node::Str(name))) =
83 (data.get("providerName"), data.get("secretName"))
84 {
85 out.insert((provider.clone(), name.clone()));
86 }
87 return;
88 }
89 match node {
90 Node::Obj(members) => {
91 for (_, member) in members {
92 referenced_secret_names(member, out);
93 }
94 }
95 Node::Arr(items) => {
96 for item in items {
97 referenced_secret_names(item, out);
98 }
99 }
100 Node::Str(_) | Node::Lit(_) => {}
101 }
102}
103
104fn key_looks_secret(key: &str) -> bool {
107 let lower = key.to_ascii_lowercase();
108 lower.contains("password") || lower.contains("secret")
109}
110
111fn member_path(parent: &str, key: &str) -> String {
113 if parent.is_empty() {
114 key.to_string()
115 } else {
116 format!("{parent}.{key}")
117 }
118}
119
120struct Rules<'a> {
122 schema: &'a [&'static str],
124 key_names: bool,
126 described: bool,
128}
129
130struct Walker<'a> {
131 origin: &'a str,
132 rules: Option<Rules<'a>>,
133 scan: SecretScan,
134}
135
136impl Walker<'_> {
137 fn refuse(&mut self, path: &str, kind: &str) {
138 let shown = if path.is_empty() { ROOT_PATH } else { path };
139 self.scan
140 .refused
141 .push(format!("{}: {shown} ({kind})", self.origin));
142 }
143
144 fn walk(&mut self, node: &Node, path: &str, key_hit: bool) {
149 match node {
150 Node::Obj(members) => {
151 if is_embedded(node) {
152 self.refuse(path, "embedded");
153 return;
154 }
155 if is_referenced(node) {
156 return;
158 }
159 for (key, value) in members {
160 let child = member_path(path, key);
161 let at_schema = self
162 .rules
163 .as_ref()
164 .is_some_and(|r| r.schema.contains(&child.as_str()));
165 if at_schema {
166 self.schema_value(value, &child);
167 continue;
168 }
169 let hit =
170 self.rules.as_ref().is_some_and(|r| r.key_names) && key_looks_secret(key);
171 self.walk(value, &child, hit);
172 }
173 }
174 Node::Arr(items) => {
175 for (i, item) in items.iter().enumerate() {
176 self.walk(item, &format!("{path}[{i}]"), key_hit);
177 }
178 }
179 Node::Str(s) => {
180 if key_hit && !s.is_empty() {
181 self.key_name_hit(path);
182 }
183 }
184 Node::Lit(_) => {}
185 }
186 }
187
188 fn schema_value(&mut self, value: &Node, path: &str) {
192 match value {
193 Node::Lit(l) if l == "null" => {}
194 Node::Obj(_) if is_embedded(value) => self.refuse(path, "embedded"),
195 Node::Obj(_) if is_referenced(value) => {}
196 _ => self.refuse(path, "plaintext"),
197 }
198 }
199
200 fn key_name_hit(&mut self, path: &str) {
202 let described = self.rules.as_ref().is_some_and(|r| r.described);
203 if described {
204 self.scan.warnings.push(format!(
205 "possible plaintext secret at {}: {path}; this field is not secret-typed in the 8.3.6 OpenAPI, check it is not a secret",
206 self.origin
207 ));
208 } else {
209 self.refuse(path, "plaintext, by key name");
210 }
211 }
212}
213
214pub fn scan_embedded(origin: &str, node: &Node) -> SecretScan {
218 let mut walker = Walker {
219 origin,
220 rules: None,
221 scan: SecretScan::default(),
222 };
223 walker.walk(node, "", false);
224 walker.scan
225}
226
227pub fn scan_secrets(origin: &str, key: &ResourceKey, file_name: &str, node: &Node) -> SecretScan {
243 if file_name != "config.json" {
244 return scan_embedded(origin, node);
245 }
246 let mut walker = Walker {
247 origin,
248 rules: Some(Rules {
249 schema: secret_paths(&key.module, &key.ty),
250 key_names: true,
251 described: openapi_describes(&key.module, &key.ty),
252 }),
253 scan: SecretScan::default(),
254 };
255 walker.walk(node, "", false);
256 walker.scan
257}
258
259pub const WINDOWS_MAX_PATH: usize = 248;
266
267pub const WINDOWS_MAX_COMPONENT: usize = 255;
269
270pub const DEFAULT_WINDOWS_DATA_DIR: &str = r"C:\Program Files\Inductive Automation\Ignition\data";
273
274pub const COLLECTION_SEGMENT: &str = r"\config\resources\";
277
278pub const RESERVED_STEMS: [&str; 22] = [
281 "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
282 "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
283];
284
285pub const TEXT_EXTENSIONS: [&str; 13] = [
288 "json",
289 "css",
290 "yaml",
291 "yml",
292 "txt",
293 "md",
294 "xml",
295 "py",
296 "csv",
297 "properties",
298 "html",
299 "js",
300 "svg",
301];
302
303fn utf16_len(s: &str) -> usize {
305 s.encode_utf16().count()
306}
307
308pub fn windows_prefix_len(target: &Target) -> usize {
314 let nodes = std::iter::once(&target.nodes.master).chain(target.nodes.backup.as_ref());
315 let data_dir = nodes
316 .filter_map(|n| n.ssh.as_ref())
317 .filter(|ssh| ssh.os == TargetOs::Windows)
318 .map(|ssh| utf16_len(ssh.data_dir.trim_end_matches(['\\', '/'])))
319 .fold(utf16_len(DEFAULT_WINDOWS_DATA_DIR), usize::max);
320 data_dir + utf16_len(COLLECTION_SEGMENT) + utf16_len(&target.mode)
321}
322
323pub fn is_text_member(path: &str, bytes: &[u8]) -> bool {
327 let ext = path.rsplit_once('.').map(|(_, e)| e);
328 if let Some(ext) = ext
329 && !ext.contains('/')
330 && TEXT_EXTENSIONS.iter().any(|t| t.eq_ignore_ascii_case(ext))
331 {
332 return true;
333 }
334 !bytes.contains(&0) && std::str::from_utf8(bytes).is_ok()
335}
336
337fn windows_problems(name: &str) -> Vec<String> {
342 let mut problems = Vec::new();
343 if let Some(c) = name
344 .chars()
345 .find(|c| matches!(c, '<' | '>' | ':' | '"' | '|' | '?' | '*') || c.is_control())
346 {
347 let shown = if c.is_control() {
348 format!("the control character U+{:04X}", c as u32)
349 } else {
350 format!("`{c}`")
351 };
352 problems.push(format!(
353 "contains {shown}, which Windows does not allow in a name"
354 ));
355 }
356 if name.ends_with('.') || name.ends_with(' ') {
357 problems.push("ends in a `.` or a space, which Windows strips".to_string());
358 }
359 let units = utf16_len(name);
360 if units > WINDOWS_MAX_COMPONENT {
361 problems.push(format!(
362 "is {units} UTF-16 code units long; a Windows name component may be at most {WINDOWS_MAX_COMPONENT}"
363 ));
364 }
365 let stem = name.split('.').next().unwrap_or(name).to_ascii_uppercase();
366 if RESERVED_STEMS.contains(&stem.as_str()) {
367 problems.push(format!(
368 "is the reserved Windows device name `{stem}` (reserved with any extension)"
369 ));
370 }
371 problems
372}
373
374fn first_cr(bytes: &[u8]) -> Option<(usize, usize)> {
377 let at = bytes.iter().position(|&b| b == b'\r')?;
378 let before = &bytes[..at];
379 let line = before.iter().filter(|&&b| b == b'\n').count() + 1;
380 let line_start = before
381 .iter()
382 .rposition(|&b| b == b'\n')
383 .map_or(0, |i| i + 1);
384 Some((line, at - line_start + 1))
385}
386
387pub fn lint_tree(
389 tree: &RenderedTree,
390 target: &Target,
391 env: &str,
392 label: &dyn Fn(&RelPath) -> String,
393) -> Vec<DeployFinding> {
394 lint_files(&tree.files, target, env, label)
395}
396
397pub fn lint_files(
417 files: &BTreeMap<RelPath, Vec<u8>>,
418 target: &Target,
419 env: &str,
420 label: &dyn Fn(&RelPath) -> String,
421) -> Vec<DeployFinding> {
422 let mut findings = Vec::new();
423 let finding = |file: String, line: Option<(usize, usize)>, message: String| DeployFinding {
424 file,
425 line: line.map(|l| l.0),
426 column: line.map(|l| l.1),
427 message,
428 };
429
430 for problem in windows_problems(&target.mode) {
431 findings.push(finding(
432 format!("targets/{env}.yaml"),
433 None,
434 format!("mode `{}` {problem}", target.mode),
435 ));
436 }
437
438 let prefix = windows_prefix_len(target);
439 let mut seen: BTreeSet<&str> = BTreeSet::new();
441 let mut siblings: BTreeMap<&str, BTreeMap<String, BTreeMap<&str, String>>> = BTreeMap::new();
443
444 for (path, bytes) in files {
445 let full = path.as_str();
446 let source = label(path);
447
448 let mut end = 0;
449 for name in path.components() {
450 let start = end;
451 end += name.len();
452 let upto = &full[..end];
453 if end < full.len() {
454 end += 1; }
456 if !seen.insert(upto) {
457 continue;
458 }
459 let parent = if start == 0 { "" } else { &full[..start - 1] };
460 siblings
461 .entry(parent)
462 .or_default()
463 .entry(name.to_lowercase())
464 .or_default()
465 .entry(name)
466 .or_insert_with(|| source.clone());
467 for problem in windows_problems(name) {
468 findings.push(finding(
469 source.clone(),
470 None,
471 format!("path component `{name}` {problem}"),
472 ));
473 }
474 }
475
476 let total = prefix + 1 + utf16_len(full);
477 if total >= WINDOWS_MAX_PATH {
478 findings.push(finding(
479 source.clone(),
480 None,
481 format!(
482 "the path would be {total} UTF-16 code units long on Windows (data directory, `{}`, the mode and `{full}`); it must be shorter than {WINDOWS_MAX_PATH}",
483 COLLECTION_SEGMENT.trim_matches('\\')
484 ),
485 ));
486 }
487
488 if is_text_member(full, bytes)
489 && let Some((line, column)) = first_cr(bytes)
490 {
491 findings.push(finding(
492 source,
493 Some((line, column)),
494 format!(
495 "carriage return (0x0D) at line {line} of a text file; render refuses CR so no tool can convert line endings (use LF)"
496 ),
497 ));
498 }
499 }
500
501 for (parent, groups) in &siblings {
502 for names in groups.values().filter(|names| names.len() > 1) {
503 let shown: Vec<String> = names
504 .keys()
505 .map(|n| {
506 if parent.is_empty() {
507 format!("`{n}`")
508 } else {
509 format!("`{parent}/{n}`")
510 }
511 })
512 .collect();
513 let first = names.values().next().cloned().unwrap_or_default();
514 findings.push(finding(
515 first,
516 None,
517 format!(
518 "sibling names collide when case is folded ({}); a case-insensitive filesystem keeps only one",
519 shown.join(", ")
520 ),
521 ));
522 }
523 }
524 findings
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530 use crate::deploy::node;
531
532 fn collect(json: &str) -> Vec<(String, String)> {
533 let mut out = BTreeSet::new();
534 referenced_secret_names(&node::parse(json).unwrap(), &mut out);
535 out.into_iter().collect()
536 }
537
538 fn pair(provider: &str, name: &str) -> (String, String) {
539 (provider.to_string(), name.to_string())
540 }
541
542 const REF_A: &str = r#"{"type":"Referenced","data":{"providerName":"p","secretName":"a"}}"#;
543
544 #[test]
545 fn collects_every_referenced_pair() {
546 let doc = format!(
547 r#"{{"top":{REF_A},"nested":{{"deep":{{"type":"Referenced","data":{{"providerName":"p","secretName":"b"}}}}}},"list":[1,{{"type":"Referenced","data":{{"providerName":"q","secretName":"c"}}}}]}}"#
548 );
549 assert_eq!(
550 collect(&doc),
551 [pair("p", "a"), pair("p", "b"), pair("q", "c")]
552 );
553 assert_eq!(collect(REF_A), [pair("p", "a")]);
555 }
556
557 #[test]
558 fn duplicate_reference_collects_once() {
559 let doc = format!(r#"{{"x":{REF_A},"y":[{REF_A}]}}"#);
560 assert_eq!(collect(&doc), [pair("p", "a")]);
561 }
562
563 #[test]
564 fn same_name_two_providers_collects_twice() {
565 let doc = r#"[{"type":"Referenced","data":{"providerName":"p2","secretName":"a"}},{"type":"Referenced","data":{"providerName":"p1","secretName":"a"}}]"#;
566 assert_eq!(collect(doc), [pair("p1", "a"), pair("p2", "a")]);
568 }
569
570 #[test]
571 fn no_reference_collects_nothing() {
572 assert!(collect(r#"{"a":1,"b":["x",{"c":null}]}"#).is_empty());
573 assert!(collect(r#"{}"#).is_empty());
574 assert!(collect(r#""just a string""#).is_empty());
575 }
576
577 #[test]
578 fn lookalikes_are_ignored() {
579 for doc in [
580 r#"{"type":"Referenced","data":{"providerName":"p","secretName":"a"},"x":1}"#,
582 r#"{"type":"referenced","data":{"providerName":"p","secretName":"a"}}"#,
584 r#"{"type":"Referenced","data":{"providerName":"p","secretName":7}}"#,
586 r#"{"type":"Referenced","data":{"providerName":"p","secretName":"a","extra":"v"}}"#,
588 r#"{"type":"Referenced","data":{"secretName":"a"}}"#,
590 r#"{"type":"Embedded","data":{"ciphertext":"X"}}"#,
592 ] {
593 assert!(collect(doc).is_empty(), "{doc}");
594 }
595 }
596
597 #[test]
598 fn a_lookalike_does_not_hide_a_reference_inside_it() {
599 let doc = format!(r#"{{"type":"Referenced","extra":{REF_A}}}"#);
600 assert_eq!(collect(&doc), [pair("p", "a")]);
601 }
602
603 fn key(module: &str, ty: &str) -> ResourceKey {
604 ResourceKey {
605 module: module.to_string(),
606 ty: ty.to_string(),
607 name: Some("x".to_string()),
608 }
609 }
610
611 fn scan(module: &str, ty: &str, json: &str) -> SecretScan {
612 scan_secrets(
613 "f/config.json",
614 &key(module, ty),
615 "config.json",
616 &node::parse(json).unwrap(),
617 )
618 }
619
620 #[test]
621 fn embedded_nested_in_arrays_is_found_with_index_path() {
622 let s = scan(
623 "m",
624 "t",
625 r#"{"a":[{"b":{"type":"Embedded","data":{"ciphertext":"X"}}}]}"#,
626 );
627 assert_eq!(s.refused, ["f/config.json: a[0].b (embedded)"]);
628 }
629
630 #[test]
631 fn embedded_without_object_data_is_not_embedded() {
632 let s = scan("m", "t", r#"{"a":{"type":"Embedded","data":"x"}}"#);
633 assert!(s.refused.is_empty());
634 }
635
636 #[test]
637 fn embedded_at_a_root_is_reported_as_root() {
638 let s = scan_embedded(
639 "f.json",
640 &node::parse(r#"{"type":"Embedded","data":{}}"#).unwrap(),
641 );
642 assert_eq!(s.refused, ["f.json: $ (embedded)"]);
643 }
644
645 #[test]
646 fn schema_path_values() {
647 let ok = scan("ignition", "database-connection", r#"{"password":null}"#);
648 assert_eq!(ok, SecretScan::default());
649 let ok = scan(
650 "ignition",
651 "database-connection",
652 r#"{"password":{"type":"Referenced","data":{"providerName":"p","secretName":"s"}}}"#,
653 );
654 assert_eq!(ok, SecretScan::default());
655 for bad in [r#""x""#, r#""""#, "1", "true", "[]", "{}"] {
656 let s = scan(
657 "ignition",
658 "database-connection",
659 &format!(r#"{{"password":{bad}}}"#),
660 );
661 assert_eq!(
662 s.refused,
663 ["f/config.json: password (plaintext)"],
664 "value {bad}"
665 );
666 }
667 }
668
669 #[test]
670 fn non_config_files_get_only_the_embedded_rule() {
671 let node = node::parse(r#"{"password":"x"}"#).unwrap();
672 let s = scan_secrets(
673 "f/other.json",
674 &key("com.cirruslink.mqtt.engine.gateway", "server"),
675 "other.json",
676 &node,
677 );
678 assert_eq!(s, SecretScan::default());
679 }
680
681 #[test]
682 fn key_name_in_a_described_type_warns_by_path() {
683 let s = scan(
684 "ignition",
685 "database-connection",
686 r#"{"settings":{"mqttPassword":"CANARY"}}"#,
687 );
688 assert!(s.refused.is_empty());
689 assert_eq!(s.warnings.len(), 1);
690 assert!(s.warnings[0].contains("settings.mqttPassword"));
691 assert!(!s.warnings[0].contains("CANARY"));
692 }
693
694 #[test]
695 fn referenced_data_secret_name_is_a_name() {
696 let s = scan(
697 "com.cirruslink.mqtt.engine.gateway",
698 "server",
699 r#"{"password":{"type":"Referenced","data":{"providerName":"p","secretName":"s"}}}"#,
700 );
701 assert_eq!(s, SecretScan::default());
702 }
703
704 #[test]
705 fn referenced_with_extra_data_member_at_schema_path_is_plaintext() {
706 let s = scan(
707 "ignition",
708 "database-connection",
709 r#"{"password":{"type":"Referenced","data":{"providerName":"p","secretName":"s","password":"hunter2"}}}"#,
710 );
711 assert_eq!(s.refused, ["f/config.json: password (plaintext)"]);
712 assert!(s.warnings.is_empty(), "{:?}", s.warnings);
713 }
714
715 #[test]
716 fn key_name_rule_applies_inside_malformed_referenced() {
717 let s = scan(
718 "com.cirruslink.mqtt.engine.gateway",
719 "server",
720 r#"{"settings":{"cred":{"type":"Referenced","data":{"providerName":"p","secretName":"s","password":"hunter2"}}}}"#,
721 );
722 assert_eq!(
723 s.refused,
724 [
725 "f/config.json: settings.cred.data.secretName (plaintext, by key name)",
726 "f/config.json: settings.cred.data.password (plaintext, by key name)",
727 ]
728 );
729 }
730
731 const MALFORMED_REFERENCED: [&str; 14] = [
734 r#"{"type":"Referenced","data":{"providerName":"p","secretName":"s","password":"x"}}"#,
735 r#"{"type":"Referenced","data":{"providerName":"p"}}"#,
736 r#"{"type":"Referenced","data":{"secretName":"s"}}"#,
737 r#"{"type":"Referenced","data":{}}"#,
738 r#"{"type":"Referenced","data":{"providerName":1,"secretName":"s"}}"#,
739 r#"{"type":"Referenced","data":{"providerName":"p","secretName":null}}"#,
740 r#"{"type":"Referenced","data":{"providerName":"p","secretName":{"v":"x"}}}"#,
741 r#"{"type":"Referenced","data":"x"}"#,
742 r#"{"type":"Referenced","data":[]}"#,
743 r#"{"type":"Referenced","data":{"providerName":"p","secretName":"s"},"value":"x"}"#,
744 r#"{"type":"referenced","data":{"providerName":"p","secretName":"s"}}"#,
745 r#"{"type":1,"data":{"providerName":"p","secretName":"s"}}"#,
746 r#"{"data":{"providerName":"p","secretName":"s"}}"#,
747 r#"{"type":"Referenced","data":{"providername":"p","secretName":"s"}}"#,
748 ];
749
750 #[test]
751 fn malformed_referenced_variants_are_plaintext_at_schema_path() {
752 for (i, variant) in MALFORMED_REFERENCED.iter().enumerate() {
753 let s = scan(
754 "ignition",
755 "database-connection",
756 &format!(r#"{{"password":{variant}}}"#),
757 );
758 assert_eq!(
759 s.refused,
760 ["f/config.json: password (plaintext)"],
761 "variant {i}"
762 );
763 assert!(s.warnings.is_empty(), "variant {i}: {:?}", s.warnings);
764 }
765 }
766
767 #[test]
768 fn well_formed_referenced_passes_in_either_key_order() {
769 for referenced in [
770 r#"{"data":{"providerName":"p","secretName":"s"},"type":"Referenced"}"#,
771 r#"{"type":"Referenced","data":{"secretName":"s","providerName":"p"}}"#,
772 ] {
773 let s = scan(
774 "ignition",
775 "database-connection",
776 &format!(r#"{{"password":{referenced}}}"#),
777 );
778 assert_eq!(s, SecretScan::default(), "{referenced}");
779 let s = scan(
780 "com.cirruslink.mqtt.engine.gateway",
781 "server",
782 &format!(
783 r#"{{"password":{referenced},"settings":{{"clientSecret":{referenced}}}}}"#
784 ),
785 );
786 assert_eq!(s, SecretScan::default(), "{referenced}");
787 }
788 }
789
790 #[test]
791 fn malformed_referenced_outside_schema_path_warns_in_described_type() {
792 let s = scan(
793 "ignition",
794 "database-connection",
795 r#"{"settings":{"cred":{"type":"Referenced","data":{"providerName":"p","secretName":"s","password":"hunter2"}}}}"#,
796 );
797 assert!(s.refused.is_empty(), "{:?}", s.refused);
798 assert_eq!(s.warnings.len(), 2, "{:?}", s.warnings);
799 assert!(
800 s.warnings
801 .iter()
802 .any(|w| w.contains("settings.cred.data.secretName;"))
803 );
804 assert!(
805 s.warnings
806 .iter()
807 .any(|w| w.contains("settings.cred.data.password;"))
808 );
809 assert!(s.warnings.iter().all(|w| !w.contains("hunter2")));
810 }
811
812 use proptest::prelude::*;
815
816 fn target(mode: &str, extra_nodes: &str) -> Target {
817 let yaml = format!(
818 "schema_version: 1\ngateway: g\nmode: {mode}\nnodes:\n master:\n profile: p\n{extra_nodes}"
819 );
820 Target::parse("t.yaml", yaml.as_bytes()).unwrap()
821 }
822
823 fn files(entries: &[(&str, &[u8])]) -> BTreeMap<RelPath, Vec<u8>> {
824 entries
825 .iter()
826 .map(|(p, b)| (RelPath::new(p).unwrap(), b.to_vec()))
827 .collect()
828 }
829
830 fn lint(entries: &[(&str, &[u8])], t: &Target) -> Vec<DeployFinding> {
831 lint_files(&files(entries), t, "dev", &|p| format!("src/{p}"))
832 }
833
834 fn texts(findings: &[DeployFinding]) -> String {
835 findings
836 .iter()
837 .map(ToString::to_string)
838 .collect::<Vec<_>>()
839 .join(" | ")
840 }
841
842 #[test]
843 fn case_fold_collision_names_both() {
844 let t = target("dev", "");
845 let found = lint(
846 &[
847 ("ignition/schedule/Pump/config.json", b"{}"),
848 ("ignition/schedule/pump/config.json", b"{}"),
849 ("ignition/schedule/Other/config.json", b"{}"),
850 ],
851 &t,
852 );
853 assert_eq!(found.len(), 1, "{}", texts(&found));
854 let text = texts(&found);
855 assert!(text.contains("`ignition/schedule/Pump`"), "{text}");
856 assert!(text.contains("`ignition/schedule/pump`"), "{text}");
857 assert!(!text.contains("Other"), "{text}");
858
859 let found = lint(&[("a/b/X", b""), ("a/b/x", b""), ("a/b/X.", b"")], &t);
861 let text = texts(&found);
862 assert!(
863 text.contains("`a/b/X`") && text.contains("`a/b/x`"),
864 "{text}"
865 );
866 assert!(lint(&[("a/b/x", b""), ("a/b/xy", b"")], &t).is_empty());
868 assert_eq!(lint(&[("a/b/É", b""), ("a/b/é", b"")], &t).len(), 1);
870 }
871
872 #[test]
873 fn illegal_characters_are_refused() {
874 let t = target("dev", "");
875 for bad in [
876 "a<b", "a>b", "a:b", "a\"b", "a|b", "a?b", "a*b", "a\u{1}b", "a\u{1f}b", "a\u{7f}b",
877 ] {
878 let found = lint(&[(&format!("m/t/{bad}/config.json"), b"{}")], &t);
879 assert_eq!(found.len(), 1, "{bad:?}: {}", texts(&found));
880 assert!(
881 found[0].message.contains("does not allow in a name"),
882 "{bad:?}: {}",
883 texts(&found)
884 );
885 }
886 let found = lint(&[("m/t/a:b/x.json", b"{}"), ("m/t/a:b/y.json", b"{}")], &t);
888 assert_eq!(found.len(), 1, "{}", texts(&found));
889 assert!(lint(&[("m/t/a b-c_d.e%3ab/x.json", b"{}")], &t).is_empty());
891 }
892
893 #[test]
894 fn trailing_dot_and_space_are_refused() {
895 let t = target("dev", "");
896 for bad in ["trail.", "trail ", "a b ", "dots.."] {
897 let found = lint(&[(&format!("m/t/{bad}/x.json"), b"{}")], &t);
898 assert_eq!(found.len(), 1, "{bad:?}: {}", texts(&found));
899 assert!(found[0].message.contains("ends in"), "{}", texts(&found));
900 }
901 assert!(lint(&[("m/t/inner .dot/x.json", b"{}")], &t).is_empty());
902 }
903
904 #[test]
905 fn reserved_names_are_refused() {
906 let t = target("dev", "");
907 for stem in RESERVED_STEMS {
908 for name in [stem.to_string(), stem.to_lowercase(), format!("{stem}.txt")] {
909 let found = lint(&[(&format!("m/t/{name}/x.json"), b"{}")], &t);
910 assert_eq!(found.len(), 1, "{name}: {}", texts(&found));
911 assert!(found[0].message.contains("reserved"), "{}", texts(&found));
912 }
913 }
914 for ok in [
916 "CON1", "COM0", "COM10", "xcon", "CONSOLE", "LPT", "a.CON", "COMM",
917 ] {
918 assert!(
919 lint(&[(&format!("m/t/{ok}/x.json"), b"{}")], &t).is_empty(),
920 "{ok}"
921 );
922 }
923 assert_eq!(lint(&[("m/t/n/nul.json", b"{}")], &t).len(), 1);
925 }
926
927 #[test]
928 fn component_length_boundary() {
929 let t = target("d", "");
930 let component = |path: &str| texts(&lint(&[(path, b"")], &t)).contains("name component");
932 let long = |n: usize| format!("m/t/{}/x", "a".repeat(n));
933 assert!(!component(&long(255)));
934 assert!(component(&long(256)));
935 assert!(texts(&lint(&[(&long(256), b"")], &t)).contains("256 UTF-16 code units"));
936 assert!(!component(&format!("m/t/{}a/x", "\u{1F600}".repeat(127))));
938 assert!(component(&format!("m/t/{}/x", "\u{1F600}".repeat(128))));
939 assert!(!component(&format!("m/t/{}/x", "\u{e9}".repeat(255))));
941 }
942
943 fn path_of(total: usize) -> String {
945 let mut path = String::from("m/t/n/");
946 path.push_str(&"a".repeat(total - path.len()));
947 path
948 }
949
950 #[test]
951 fn path_budget_boundary() {
952 let t = target("production", "");
955 assert_eq!(windows_prefix_len(&t), 79);
956 let ok = path_of(167);
957 assert!(lint(&[(&ok, b"")], &t).is_empty(), "247 passes");
958 let bad = path_of(168);
959 let found = lint(&[(&bad, b"")], &t);
960 assert_eq!(found.len(), 1, "{}", texts(&found));
961 assert!(
962 found[0].message.contains("248 UTF-16 code units"),
963 "{}",
964 texts(&found)
965 );
966
967 let dir = format!("D:\\{}", "d".repeat(60));
970 let windows = format!(
971 " ssh:\n host: h\n user: u\n os: windows\n data_dir: '{dir}'\n known_hosts: kh\n"
972 );
973 let t2 = target("production", &windows);
974 assert_eq!(windows_prefix_len(&t2), 79 + 12);
975 assert!(lint(&[(&path_of(167 - 12), b"")], &t2).is_empty());
976 assert_eq!(lint(&[(&path_of(168 - 12), b"")], &t2).len(), 1);
977
978 let backup = " backup:\n profile: b\n ssh:\n host: h\n user: u\n os: windows\n data_dir: 'C:\\Program Files\\Inductive Automation\\Ignition\\data\\ab\\'\n known_hosts: kh\n";
980 assert_eq!(windows_prefix_len(&target("production", backup)), 79 + 3);
981
982 let linux = format!(
984 " ssh:\n host: h\n user: u\n os: linux\n data_dir: /{}\n known_hosts: kh\n",
985 "l".repeat(100)
986 );
987 assert_eq!(windows_prefix_len(&target("production", &linux)), 79);
988 }
989
990 #[test]
991 fn mode_is_held_to_the_component_rules() {
992 let t = target("prod.", "");
993 let found = lint(&[], &t);
994 assert_eq!(found.len(), 1, "{}", texts(&found));
995 assert_eq!(found[0].file, "targets/dev.yaml");
996 assert!(found[0].message.contains("mode `prod.`"));
997 assert_eq!(lint(&[], &target("CON", "")).len(), 1);
998 }
999
1000 #[test]
1001 fn cr_in_text_member_is_refused_with_line() {
1002 let t = target("dev", "");
1003 let found = lint(
1004 &[("m/t/n/config.json", b"{\n \"a\": 1,\r\n \"b\": 2\n}")],
1005 &t,
1006 );
1007 assert_eq!(found.len(), 1, "{}", texts(&found));
1008 assert_eq!((found[0].line, found[0].column), (Some(2), Some(10)));
1009 assert_eq!(found[0].file, "src/m/t/n/config.json");
1010 assert!(
1011 found[0]
1012 .to_string()
1013 .starts_with("src/m/t/n/config.json:2:10: "),
1014 "{}",
1015 found[0]
1016 );
1017 let found = lint(&[("m/t/n/a.txt", b"x\ry\r\n")], &t);
1019 assert_eq!(found[0].line, Some(1));
1020 }
1021
1022 #[test]
1023 fn cr_in_jar_is_not_refused() {
1024 let t = target("dev", "");
1025 assert!(lint(&[("m/t/n/driver.jar", b"PK\r\n\x00\x01")], &t).is_empty());
1027 assert!(lint(&[("m/t/n/driver.bin", b"\xff\xfe\r\n")], &t).is_empty());
1029 }
1030
1031 #[test]
1032 fn cr_in_unknown_text_extension_is_refused() {
1033 let t = target("dev", "");
1034 for ext in [
1035 "json",
1036 "css",
1037 "yaml",
1038 "yml",
1039 "txt",
1040 "md",
1041 "xml",
1042 "py",
1043 "csv",
1044 "properties",
1045 "html",
1046 "js",
1047 "svg",
1048 "JSON",
1049 "Xml",
1050 "weird",
1051 "noext",
1052 ] {
1053 let name = if ext == "noext" {
1054 "m/t/n/LICENSE".to_string()
1055 } else {
1056 format!("m/t/n/f.{ext}")
1057 };
1058 let found = lint(&[(&name, b"a\r\nb")], &t);
1059 assert_eq!(found.len(), 1, "{name}: {}", texts(&found));
1060 }
1061 assert!(is_text_member("a.json", b"\x00\xff"));
1063 assert!(!is_text_member("a.jar", b"\x00PK"));
1064 assert!(is_text_member("a.jar", b"plain"));
1065 assert!(!is_text_member("dir.json/blob", b"\x00"));
1067 }
1068
1069 #[test]
1070 fn empty_inputs_pass() {
1071 let t = target("dev", "");
1072 assert!(lint(&[], &t).is_empty());
1073 assert!(lint(&[("m/t/n/empty.txt", b""), ("m/t/n/empty.bin", b"")], &t).is_empty());
1074 }
1075
1076 fn reserved(name: &str) -> bool {
1077 RESERVED_STEMS.contains(
1078 &name
1079 .split('.')
1080 .next()
1081 .unwrap()
1082 .to_ascii_uppercase()
1083 .as_str(),
1084 )
1085 }
1086
1087 proptest! {
1088 #[test]
1089 fn prop_extra_data_member_is_refused_without_value(
1090 key in "[A-Za-z][A-Za-z0-9]{0,12}",
1091 value in "CANARY-[A-Za-z0-9]{6,12}",
1092 ) {
1093 prop_assume!(key != "providerName" && key != "secretName");
1094 let json = format!(
1095 r#"{{"password":{{"type":"Referenced","data":{{"providerName":"p","secretName":"s","{key}":"{value}"}}}}}}"#
1096 );
1097 let s = scan("ignition", "database-connection", &json);
1098 prop_assert_eq!(&s.refused, &["f/config.json: password (plaintext)".to_string()]);
1099 for text in s.refused.iter().chain(s.warnings.iter()) {
1100 prop_assert!(!text.contains(&value), "value leaked: {text}");
1101 }
1102 }
1103
1104 #[test]
1105 fn prop_illegal_char_names_refused(
1106 base in "[A-Za-z0-9_-]{1,20}",
1107 at in 0usize..=20,
1108 bad in prop::sample::select(vec!['<', '>', ':', '"', '|', '?', '*', '\u{1}', '\u{8}', '\u{1f}']),
1109 ) {
1110 let mut name = base.clone();
1111 name.insert(at.min(base.len()), bad);
1112 let found = lint(
1113 &[(&format!("m/t/{name}/x.json"), b"{}")],
1114 &target("dev", ""),
1115 );
1116 prop_assert!(!found.is_empty(), "{name:?} passed");
1117 }
1118
1119 #[test]
1120 fn prop_legal_names_pass(name in "[A-Za-z0-9_-]([A-Za-z0-9_ .-]{0,30}[A-Za-z0-9_-])?") {
1121 prop_assume!(!reserved(&name));
1122 let found = lint(
1123 &[(&format!("m/t/{name}/x.json"), b"{}")],
1124 &target("dev", ""),
1125 );
1126 prop_assert!(found.is_empty(), "{name:?}: {}", texts(&found));
1127 }
1128 }
1129}