Skip to main content

ignition_core/deploy/
lint.rs

1//! Render stage 7: the fail-closed lints (Phase 21, plan 21-08, DRND-07 and
2//! DRND-08).
3//!
4//! - **Secrets** ([`scan_embedded`], [`scan_secrets`]): an `Embedded` secret
5//!   object anywhere in a `.json` member; a non-null, non-Referenced value at
6//!   an OpenAPI secret-typed path (Referenced means the exact shape of
7//!   [`is_referenced`], nothing looser) ([`secret_paths`]); and, in a module type
8//!   the 8.3.6 OpenAPI does not describe, a non-empty string under a key whose
9//!   name contains `password` or `secret`.
10//! - **Windows safety** (`lint_tree`): illegal names, case-fold collisions
11//!   and the path budget (D-04).
12//! - **Line endings**: a CR byte in a text member (D-01).
13//!
14//! Every output names a location, never a value. [`SecretScan`] has no field
15//! that can hold a JSON string, so a secret cannot reach an error, a warning,
16//! the `--json` envelope or a log through this module (T-21-33).
17
18use 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/// What a secret scan of one parsed file found. Locations only.
28#[derive(Debug, Clone, Default, PartialEq, Eq)]
29pub struct SecretScan {
30    /// `<origin>: <json path> (<kind>)` for every refused location, in
31    /// document order.
32    pub refused: Vec<String>,
33    /// Warning texts naming a path, in document order.
34    pub warnings: Vec<String>,
35}
36
37/// The root of a document, as a JSON path.
38const ROOT_PATH: &str = "$";
39
40/// Whether `node` is an `Embedded` secret object: `"type": "Embedded"` with an
41/// object `data`.
42fn 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
47/// Whether `node` is a well-formed `Referenced` secret object (CD7): exactly
48/// the members `type` (the string `Referenced`) and `data`, where `data` is
49/// exactly the string members `providerName` and `secretName`. Member order
50/// does not matter; the parser refuses duplicate keys, so the length checks
51/// plus named lookups are an exact shape test. Any other shape (an extra,
52/// missing or wrong-case member, a non-string name, a sibling beside
53/// `type`/`data`) is not Referenced and is scanned as ordinary content.
54fn 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
71/// Collect the `(providerName, secretName)` of every well-formed `Referenced`
72/// object anywhere in `node` into `out` (D-13).
73///
74/// Names only: no value is read and none is returned (ENT-3115). The shape
75/// is [`is_referenced`], the one the secret lint accepts, so a reference the
76/// lint lets through is exactly one this collects, and a lookalike (an extra
77/// member, `referenced` in lower case, a non-string name) is not collected.
78/// The set is byte-ordered by provider then name and holds each pair once.
79pub 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
104/// Whether a member named `key` looks like it holds a secret: its name
105/// contains `password` or `secret` (ASCII case-insensitive).
106fn key_looks_secret(key: &str) -> bool {
107    let lower = key.to_ascii_lowercase();
108    lower.contains("password") || lower.contains("secret")
109}
110
111/// Join a member name onto a JSON path.
112fn 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
120/// What a scan applies besides the generic Embedded rule.
121struct Rules<'a> {
122    /// OpenAPI secret-typed paths of the resource type (config.json only).
123    schema: &'a [&'static str],
124    /// Apply the key-name rule (config.json of a resource type).
125    key_names: bool,
126    /// The 8.3.6 OpenAPI describes the type: key-name hits warn, not refuse.
127    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    /// Visit `node` at `path`. `key_hit` is true when `node` is a string (or
145    /// an array of them) held by a secret-looking key. A well-formed
146    /// Referenced object is skipped whole; no flag turns the key-name rule off
147    /// for any subtree.
148    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                    // names only: no value, no nested object that could be Embedded
157                    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    /// A value at an OpenAPI secret-typed path: null and a well-formed
189    /// Referenced pass, an Embedded object is refused as embedded, anything
190    /// else is plaintext.
191    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    /// A non-empty string under a secret-looking key outside the schema paths.
201    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
214/// The generic Embedded scan: every object in `node` that is an Embedded
215/// secret, as `<origin>: <json path> (embedded)`. Used for every `.json`
216/// member, including tag files, which have no resource type.
217pub 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
227/// Scan one parsed resource file.
228///
229/// Always the generic Embedded rule. When `file_name` (the file's path
230/// relative to the resource root) is `config.json`, also:
231///
232/// - each [`secret_paths`] entry must be absent, `null` or Referenced; an
233///   Embedded object there is reported once, as embedded; a string or any
234///   other value is plaintext;
235/// - a non-empty string under a key containing `password` or `secret`
236///   (outside the schema paths and outside a well-formed Referenced object) is
237///   refused as plaintext by key name when the 8.3.6 OpenAPI does not
238///   describe the type, and only warned about when it does (the schema is
239///   authoritative there).
240///
241/// `origin` is the source file label findings should carry.
242pub 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
259// ---- Windows safety, path budget and line endings (21-08 task 2) ----------
260
261/// A full Windows path must be shorter than this many UTF-16 code units
262/// (DRND-08): 260 (`MAX_PATH`) less 12, the room Windows keeps for an 8.3
263/// file name when it creates a directory. A total of 247 passes, 248 is
264/// refused.
265pub const WINDOWS_MAX_PATH: usize = 248;
266
267/// The longest a single Windows path component may be, in UTF-16 code units.
268pub const WINDOWS_MAX_COMPONENT: usize = 255;
269
270/// The default Ignition data directory on Windows (51 characters). A Windows
271/// node's `ssh.data_dir` replaces it in the budget when longer.
272pub const DEFAULT_WINDOWS_DATA_DIR: &str = r"C:\Program Files\Inductive Automation\Ignition\data";
273
274/// What sits between the data directory and the mode directory on a gateway
275/// host: `\config\resources\` (18 characters).
276pub const COLLECTION_SEGMENT: &str = r"\config\resources\";
277
278/// Device names Windows reserves, whatever the extension (compared against the
279/// ASCII-uppercased stem before the first `.`).
280pub 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
285/// Extensions (ASCII case-insensitive) that are always text members for the CR
286/// lint, whatever their bytes (D-01).
287pub 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
303/// UTF-16 length: the unit Windows limits are counted in.
304fn utf16_len(s: &str) -> usize {
305    s.encode_utf16().count()
306}
307
308/// The length of the Windows path before the relative path: the longest of
309/// [`DEFAULT_WINDOWS_DATA_DIR`] and every Windows node's `ssh.data_dir`
310/// (trailing separators trimmed), plus [`COLLECTION_SEGMENT`], plus
311/// `target.mode`, in UTF-16 code units. The default always counts, even for a
312/// Linux-only target, because `resources/` is shared between environments.
313pub 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
323/// Whether the CR lint treats a member as text: its extension is one of
324/// [`TEXT_EXTENSIONS`], or its bytes are valid UTF-8 without a NUL byte. A
325/// jar, whose bytes are not text, is therefore exempt (D-01).
326pub 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
337/// Why `name` is not a name Windows accepts for one path component. The rules
338/// are an approximation of NTFS made fail-closed on purpose (D-04): real NTFS
339/// behaviour is captured in Phase 22 (assumption A4), and the Linux encoding
340/// of hostile names is decision CD6.
341fn 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
374/// The 1-based (line, column) of the first CR byte in `bytes`, if any. The
375/// column counts bytes from the start of its line.
376fn 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
387/// Lint a rendered tree (see [`lint_files`]).
388pub 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
397/// The fail-closed tree lints, all collected in one pass (DRND-08, D-01,
398/// D-04). They apply to every environment, because `resources/` is shared.
399///
400/// - per path component (once per distinct component path): characters
401///   Windows rejects, a trailing `.` or space, more than
402///   [`WINDOWS_MAX_COMPONENT`] UTF-16 units, a reserved device stem;
403/// - per parent directory: sibling names (files and directories) that differ
404///   only by case under `str::to_lowercase`, naming every colliding sibling;
405/// - per file: [`windows_prefix_len`] + 1 + the UTF-16 length of the
406///   collection path must be below [`WINDOWS_MAX_PATH`];
407/// - per text member ([`is_text_member`]): the first CR byte, with its line
408///   and column (D-01; binary members such as jars are exempt);
409/// - `target.mode`, held to the component rules (finding on
410///   `targets/<env>.yaml`).
411///
412/// `label` names the source file a finding should carry for a collection path.
413/// The Windows rules approximate NTFS and are fail-closed; the real NTFS
414/// capture is Phase 22 (A4, D-04). CD6 records how an 8.3.6 gateway on Linux
415/// encodes hostile names.
416pub 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    // distinct component paths already checked, and the sibling groups
440    let mut seen: BTreeSet<&str> = BTreeSet::new();
441    // parent -> lowercased name -> name -> label of the first file under it
442    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; // the `/` after this component
455            }
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        // The root itself may be a reference.
554        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        // Byte order of provider, then name.
567        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            // an extra member beside type/data
581            r#"{"type":"Referenced","data":{"providerName":"p","secretName":"a"},"x":1}"#,
582            // lower case type
583            r#"{"type":"referenced","data":{"providerName":"p","secretName":"a"}}"#,
584            // a non-string secret name
585            r#"{"type":"Referenced","data":{"providerName":"p","secretName":7}}"#,
586            // a third member in data
587            r#"{"type":"Referenced","data":{"providerName":"p","secretName":"a","extra":"v"}}"#,
588            // a missing provider
589            r#"{"type":"Referenced","data":{"secretName":"a"}}"#,
590            // an Embedded object is not a reference
591            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    /// A Referenced-shaped object at the database-connection `password` path,
732    /// each deviating from the CD7 shape in one way.
733    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    // ---- Windows safety, budget and CR ----------------------------------
813
814    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        // files collide as well as directories, and three-way groups name all
860        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        // whole names fold, not a prefix
867        assert!(lint(&[("a/b/x", b""), ("a/b/xy", b"")], &t).is_empty());
868        // non-ASCII folds with to_lowercase
869        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        // each distinct component is reported once, however many files sit below
887        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        // legal punctuation passes
890        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        // the stem is what precedes the first dot, compared whole
915        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        // a file name is a component too
924        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        // only the component rule is under test here, not the path budget
931        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        // a non-BMP character is two UTF-16 units
937        assert!(!component(&format!("m/t/{}a/x", "\u{1F600}".repeat(127))));
938        assert!(component(&format!("m/t/{}/x", "\u{1F600}".repeat(128))));
939        // 255 UTF-16 units but 510 bytes: bytes do not decide
940        assert!(!component(&format!("m/t/{}/x", "\u{e9}".repeat(255))));
941    }
942
943    /// A relative path of exactly `total` UTF-16 units made of short parts.
944    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        // 51 (default data dir) + 18 (collection segment) + 10 (mode) = 79,
953        // plus 1 separator before the relative path
954        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        // a longer Windows data_dir (63 characters, 12 more) lowers the
968        // allowed relative length by 12
969        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        // a trailing separator is trimmed and the backup node counts
979        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        // a Linux node never lowers the budget
983        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        // the first CR wins; a lone CR counts as well
1018        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        // NUL bytes: not text
1026        assert!(lint(&[("m/t/n/driver.jar", b"PK\r\n\x00\x01")], &t).is_empty());
1027        // invalid UTF-8: not text
1028        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        // a known text extension is text whatever its bytes
1062        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        // a dot in a directory name is not an extension
1066        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}