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//! - **Project-owned tag nodes** ([`scan_tag_node`], 26-07, D-14): Embedded
11//!   objects and credential-like values a run would send refuse; what it
12//!   does not send is a warning.
13//! - **Windows safety** (`lint_tree`): illegal names, case-fold collisions
14//!   and the path budget (D-04).
15//! - **Line endings**: a CR byte in a text member (D-01).
16//!
17//! Every output names a location, never a value. [`SecretScan`] has no field
18//! that can hold a JSON string, so a secret cannot reach an error, a warning,
19//! the `--json` envelope or a log through this module (T-21-33).
20
21use std::collections::{BTreeMap, BTreeSet};
22
23use super::layout::{RelPath, ResourceKey};
24use super::node::Node;
25use super::rules::{openapi_describes, secret_paths};
26use super::target::{Target, TargetOs};
27use super::tree::RenderedTree;
28use crate::error::DeployFinding;
29
30/// What a secret scan of one parsed file found. Locations only.
31#[derive(Debug, Clone, Default, PartialEq, Eq)]
32pub struct SecretScan {
33    /// `<origin>: <json path> (<kind>)` for every refused location, in
34    /// document order.
35    pub refused: Vec<String>,
36    /// Warning texts naming a path, in document order.
37    pub warnings: Vec<String>,
38}
39
40/// The root of a document, as a JSON path.
41const ROOT_PATH: &str = "$";
42
43/// Whether `node` is an `Embedded` secret object: `"type": "Embedded"` with an
44/// object `data`.
45fn is_embedded(node: &Node) -> bool {
46    matches!(node.get("type"), Some(Node::Str(t)) if t == "Embedded")
47        && matches!(node.get("data"), Some(Node::Obj(_)))
48}
49
50/// Whether `node` is a well-formed `Referenced` secret object (CD7): exactly
51/// the members `type` (the string `Referenced`) and `data`, where `data` is
52/// exactly the string members `providerName` and `secretName`. Member order
53/// does not matter; the parser refuses duplicate keys, so the length checks
54/// plus named lookups are an exact shape test. Any other shape (an extra,
55/// missing or wrong-case member, a non-string name, a sibling beside
56/// `type`/`data`) is not Referenced and is scanned as ordinary content.
57fn is_referenced(node: &Node) -> bool {
58    let Node::Obj(members) = node else {
59        return false;
60    };
61    if members.len() != 2 || !matches!(node.get("type"), Some(Node::Str(t)) if t == "Referenced") {
62        return false;
63    }
64    match node.get("data") {
65        Some(data @ Node::Obj(inner)) => {
66            inner.len() == 2
67                && matches!(data.get("providerName"), Some(Node::Str(_)))
68                && matches!(data.get("secretName"), Some(Node::Str(_)))
69        }
70        _ => false,
71    }
72}
73
74/// Collect the `(providerName, secretName)` of every well-formed `Referenced`
75/// object anywhere in `node` into `out` (D-13).
76///
77/// Names only: no value is read and none is returned (ENT-3115). The shape
78/// is [`is_referenced`], the one the secret lint accepts, so a reference the
79/// lint lets through is exactly one this collects, and a lookalike (an extra
80/// member, `referenced` in lower case, a non-string name) is not collected.
81/// The set is byte-ordered by provider then name and holds each pair once.
82pub fn referenced_secret_names(node: &Node, out: &mut BTreeSet<(String, String)>) {
83    if is_referenced(node) {
84        if let Some(data) = node.get("data")
85            && let (Some(Node::Str(provider)), Some(Node::Str(name))) =
86                (data.get("providerName"), data.get("secretName"))
87        {
88            out.insert((provider.clone(), name.clone()));
89        }
90        return;
91    }
92    match node {
93        Node::Obj(members) => {
94            for (_, member) in members {
95                referenced_secret_names(member, out);
96            }
97        }
98        Node::Arr(items) => {
99            for item in items {
100                referenced_secret_names(item, out);
101            }
102        }
103        Node::Str(_) | Node::Lit(_) => {}
104    }
105}
106
107/// Whether a member named `key` looks like it holds a secret: its name
108/// contains `password` or `secret` (ASCII case-insensitive).
109fn key_looks_secret(key: &str) -> bool {
110    let lower = key.to_ascii_lowercase();
111    lower.contains("password") || lower.contains("secret")
112}
113
114/// Join a member name onto a JSON path.
115fn member_path(parent: &str, key: &str) -> String {
116    if parent.is_empty() {
117        key.to_string()
118    } else {
119        format!("{parent}.{key}")
120    }
121}
122
123/// What a scan applies besides the generic Embedded rule.
124struct Rules<'a> {
125    /// OpenAPI secret-typed paths of the resource type (config.json only).
126    schema: &'a [&'static str],
127    /// Apply the key-name rule (config.json of a resource type).
128    key_names: bool,
129    /// The 8.3.6 OpenAPI describes the type: key-name hits warn, not refuse.
130    described: bool,
131}
132
133struct Walker<'a> {
134    origin: &'a str,
135    rules: Option<Rules<'a>>,
136    scan: SecretScan,
137}
138
139impl Walker<'_> {
140    fn refuse(&mut self, path: &str, kind: &str) {
141        let shown = if path.is_empty() { ROOT_PATH } else { path };
142        self.scan
143            .refused
144            .push(format!("{}: {shown} ({kind})", self.origin));
145    }
146
147    /// Visit `node` at `path`. `key_hit` is true when `node` is a string (or
148    /// an array of them) held by a secret-looking key. A well-formed
149    /// Referenced object is skipped whole; no flag turns the key-name rule off
150    /// for any subtree.
151    fn walk(&mut self, node: &Node, path: &str, key_hit: bool) {
152        match node {
153            Node::Obj(members) => {
154                if is_embedded(node) {
155                    self.refuse(path, "embedded");
156                    return;
157                }
158                if is_referenced(node) {
159                    // names only: no value, no nested object that could be Embedded
160                    return;
161                }
162                for (key, value) in members {
163                    let child = member_path(path, key);
164                    let at_schema = self
165                        .rules
166                        .as_ref()
167                        .is_some_and(|r| r.schema.contains(&child.as_str()));
168                    if at_schema {
169                        self.schema_value(value, &child);
170                        continue;
171                    }
172                    let hit =
173                        self.rules.as_ref().is_some_and(|r| r.key_names) && key_looks_secret(key);
174                    self.walk(value, &child, hit);
175                }
176            }
177            Node::Arr(items) => {
178                for (i, item) in items.iter().enumerate() {
179                    self.walk(item, &format!("{path}[{i}]"), key_hit);
180                }
181            }
182            Node::Str(s) => {
183                if key_hit && !s.is_empty() {
184                    self.key_name_hit(path);
185                }
186            }
187            Node::Lit(_) => {}
188        }
189    }
190
191    /// A value at an OpenAPI secret-typed path: null and a well-formed
192    /// Referenced pass, an Embedded object is refused as embedded, anything
193    /// else is plaintext.
194    fn schema_value(&mut self, value: &Node, path: &str) {
195        match value {
196            Node::Lit(l) if l == "null" => {}
197            Node::Obj(_) if is_embedded(value) => self.refuse(path, "embedded"),
198            Node::Obj(_) if is_referenced(value) => {}
199            _ => self.refuse(path, "plaintext"),
200        }
201    }
202
203    /// A non-empty string under a secret-looking key outside the schema paths.
204    fn key_name_hit(&mut self, path: &str) {
205        let described = self.rules.as_ref().is_some_and(|r| r.described);
206        if described {
207            self.scan.warnings.push(format!(
208                "possible plaintext secret at {}: {path}; this field is not secret-typed in the 8.3.6 OpenAPI, check it is not a secret",
209                self.origin
210            ));
211        } else {
212            self.refuse(path, "plaintext, by key name");
213        }
214    }
215}
216
217/// The generic Embedded scan: every object in `node` that is an Embedded
218/// secret, as `<origin>: <json path> (embedded)`. Used for every `.json`
219/// member, including tag files, which have no resource type.
220pub fn scan_embedded(origin: &str, node: &Node) -> SecretScan {
221    let mut walker = Walker {
222        origin,
223        rules: None,
224        scan: SecretScan::default(),
225    };
226    walker.walk(node, "", false);
227    walker.scan
228}
229
230/// Scan one parsed resource file.
231///
232/// Always the generic Embedded rule. When `file_name` (the file's path
233/// relative to the resource root) is `config.json`, also:
234///
235/// - each [`secret_paths`] entry must be absent, `null` or Referenced; an
236///   Embedded object there is reported once, as embedded; a string or any
237///   other value is plaintext;
238/// - a non-empty string under a key containing `password` or `secret`
239///   (outside the schema paths and outside a well-formed Referenced object) is
240///   refused as plaintext by key name when the 8.3.6 OpenAPI does not
241///   describe the type, and only warned about when it does (the schema is
242///   authoritative there).
243///
244/// `origin` is the source file label findings should carry.
245pub fn scan_secrets(origin: &str, key: &ResourceKey, file_name: &str, node: &Node) -> SecretScan {
246    if file_name != "config.json" {
247        return scan_embedded(origin, node);
248    }
249    let mut walker = Walker {
250        origin,
251        rules: Some(Rules {
252            schema: secret_paths(&key.module, &key.ty),
253            key_names: true,
254            described: openapi_describes(&key.module, &key.ty),
255        }),
256        scan: SecretScan::default(),
257    };
258    walker.walk(node, "", false);
259    walker.scan
260}
261
262// ---- Project-owned tag nodes (26-07, D-14, R3-SECRET) ----------------------
263
264/// Fragments of a tag name that mark the tag as holding a credential, matched
265/// ASCII case-insensitively against the tag's own name (a top-level node's
266/// last path segment, an instance member's `name`).
267pub const TAG_CREDENTIAL_MARKERS: [&str; 7] = [
268    "password",
269    "passwd",
270    "secret",
271    "token",
272    "apikey",
273    "api_key",
274    "credential",
275];
276
277/// What a run sends of one owned tag node, which decides whether a
278/// credential on it blocks the deploy (D-14).
279#[derive(Debug, Clone, Copy, PartialEq, Eq)]
280pub enum Sends {
281    /// An added node: the export's JSON as it is, `value` and `defaultValue`
282    /// included (D-10).
283    All,
284    /// A changed node (R3-POL): `defaultValue` and every configuration key;
285    /// the AtomicTag runtime keys are stripped, so `value` is not sent. Below
286    /// `_types_/` nothing is stripped: a member's `value` is the type's
287    /// default and is sent.
288    Config,
289    /// A kept-values or unchanged node: no batch names it.
290    Nothing,
291}
292
293/// Whether a tag named `name` is credential-like ([`TAG_CREDENTIAL_MARKERS`]).
294fn tag_name_is_credential(name: &str) -> bool {
295    let lower = name.to_ascii_lowercase();
296    TAG_CREDENTIAL_MARKERS
297        .iter()
298        .any(|marker| lower.contains(marker))
299}
300
301/// Whether `node` is or holds a non-empty string at any depth: a string, or
302/// a Document, object or array with one inside.
303fn holds_text(node: &Node) -> bool {
304    match node {
305        Node::Str(s) => !s.is_empty(),
306        Node::Obj(members) => members.iter().any(|(_, v)| holds_text(v)),
307        Node::Arr(items) => items.iter().any(holds_text),
308        Node::Lit(_) => false,
309    }
310}
311
312/// The D-14 secret rule for one owned tag node at provider-relative `path`,
313/// read from `origin` (its repo file), under what the run `sends` of it
314/// (R3-SECRET).
315///
316/// - An `Embedded` secret object anywhere in the node refuses, unless the run
317///   sends nothing of the node.
318/// - A credential value (a non-empty string, or a Document, object or array
319///   holding one) in `value` or `defaultValue` of a tag whose name holds a
320///   [`TAG_CREDENTIAL_MARKERS`] entry, top level or instance member, refuses
321///   when the run sends that key: both keys under [`Sends::All`],
322///   `defaultValue` under [`Sends::Config`] (and `value` too below
323///   `_types_/`).
324/// - Everything ign does not send is a warning naming the location: a value
325///   ign never writes never blocks a deploy.
326///
327/// Every entry is `<origin>: <json path> (<kind>)`, a warning followed by
328/// why it does not block. Never a value.
329pub fn scan_tag_node(origin: &str, path: &str, node: &Node, sends: Sends) -> SecretScan {
330    let mut out = SecretScan::default();
331    for location in scan_embedded(origin, node).refused {
332        if sends == Sends::Nothing {
333            out.warnings
334                .push(format!("{location}: nothing is sent for this tag"));
335        } else {
336            out.refused.push(location);
337        }
338    }
339    let name = path.rsplit('/').next().unwrap_or(path);
340    let types = super::tag_canon::in_types(path);
341    credential_walk(origin, node, name, "", sends, types, &mut out);
342    out
343}
344
345/// [`scan_tag_node`]'s credential rule for `node`, named `name`, at JSON path
346/// `at`, then for every member below it in `tags`, by name.
347fn credential_walk(
348    origin: &str,
349    node: &Node,
350    name: &str,
351    at: &str,
352    sends: Sends,
353    types: bool,
354    out: &mut SecretScan,
355) {
356    if tag_name_is_credential(name)
357        && let Node::Obj(members) = node
358    {
359        for (key, value) in members {
360            if !matches!(key.as_str(), "value" | "defaultValue")
361                || is_embedded(value)
362                || !holds_text(value)
363            {
364                continue;
365            }
366            let location = format!("{origin}: {} (credential value)", member_path(at, key));
367            let sent = match sends {
368                Sends::All => true,
369                Sends::Config => key == "defaultValue" || types,
370                Sends::Nothing => false,
371            };
372            if sent {
373                out.refused.push(location);
374            } else if sends == Sends::Config {
375                out.warnings
376                    .push(format!("{location}: not sent, the live value is kept"));
377            } else {
378                out.warnings
379                    .push(format!("{location}: nothing is sent for this tag"));
380            }
381        }
382    }
383    if let Some(Node::Arr(items)) = node.get("tags") {
384        for item in items {
385            let Some(Node::Str(child)) = item.get("name") else {
386                continue;
387            };
388            let below = member_path(&member_path(at, "tags"), child);
389            credential_walk(origin, item, child, &below, sends, types, out);
390        }
391    }
392}
393
394// ---- Windows safety, path budget and line endings (21-08 task 2) ----------
395
396/// A full Windows path must be shorter than this many UTF-16 code units
397/// (DRND-08): 260 (`MAX_PATH`) less 12, the room Windows keeps for an 8.3
398/// file name when it creates a directory. A total of 247 passes, 248 is
399/// refused.
400pub const WINDOWS_MAX_PATH: usize = 248;
401
402/// The longest a single Windows path component may be, in UTF-16 code units.
403pub const WINDOWS_MAX_COMPONENT: usize = 255;
404
405/// The default Ignition data directory on Windows (51 characters). A Windows
406/// node's `ssh.data_dir` replaces it in the budget when longer.
407pub const DEFAULT_WINDOWS_DATA_DIR: &str = r"C:\Program Files\Inductive Automation\Ignition\data";
408
409/// What sits between the data directory and the mode directory on a gateway
410/// host: `\config\resources\` (18 characters).
411pub const COLLECTION_SEGMENT: &str = r"\config\resources\";
412
413/// Device names Windows reserves, whatever the extension (compared against the
414/// ASCII-uppercased stem before the first `.`).
415pub const RESERVED_STEMS: [&str; 22] = [
416    "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
417    "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
418];
419
420/// Extensions (ASCII case-insensitive) that are always text members for the CR
421/// lint, whatever their bytes (D-01).
422pub const TEXT_EXTENSIONS: [&str; 13] = [
423    "json",
424    "css",
425    "yaml",
426    "yml",
427    "txt",
428    "md",
429    "xml",
430    "py",
431    "csv",
432    "properties",
433    "html",
434    "js",
435    "svg",
436];
437
438/// UTF-16 length: the unit Windows limits are counted in.
439fn utf16_len(s: &str) -> usize {
440    s.encode_utf16().count()
441}
442
443/// The length of the Windows path before the relative path: the longest of
444/// [`DEFAULT_WINDOWS_DATA_DIR`] and every Windows node's `ssh.data_dir`
445/// (trailing separators trimmed), plus [`COLLECTION_SEGMENT`], plus
446/// `target.mode`, in UTF-16 code units. The default always counts, even for a
447/// Linux-only target, because `resources/` is shared between environments.
448pub fn windows_prefix_len(target: &Target) -> usize {
449    let nodes = std::iter::once(&target.nodes.master).chain(target.nodes.backup.as_ref());
450    let data_dir = nodes
451        .filter_map(|n| n.ssh.as_ref())
452        .filter(|ssh| ssh.os == TargetOs::Windows)
453        .map(|ssh| utf16_len(ssh.data_dir.trim_end_matches(['\\', '/'])))
454        .fold(utf16_len(DEFAULT_WINDOWS_DATA_DIR), usize::max);
455    data_dir + utf16_len(COLLECTION_SEGMENT) + utf16_len(&target.mode)
456}
457
458/// Whether the CR lint treats a member as text: its extension is one of
459/// [`TEXT_EXTENSIONS`], or its bytes are valid UTF-8 without a NUL byte. A
460/// jar, whose bytes are not text, is therefore exempt (D-01).
461pub fn is_text_member(path: &str, bytes: &[u8]) -> bool {
462    let ext = path.rsplit_once('.').map(|(_, e)| e);
463    if let Some(ext) = ext
464        && !ext.contains('/')
465        && TEXT_EXTENSIONS.iter().any(|t| t.eq_ignore_ascii_case(ext))
466    {
467        return true;
468    }
469    !bytes.contains(&0) && std::str::from_utf8(bytes).is_ok()
470}
471
472/// Why `name` is not a name Windows accepts for one path component. The rules
473/// are an approximation of NTFS made fail-closed on purpose (D-04): real NTFS
474/// behaviour is captured in Phase 22 (assumption A4), and the Linux encoding
475/// of hostile names is decision CD6.
476fn windows_problems(name: &str) -> Vec<String> {
477    let mut problems = Vec::new();
478    if let Some(c) = name
479        .chars()
480        .find(|c| matches!(c, '<' | '>' | ':' | '"' | '|' | '?' | '*') || c.is_control())
481    {
482        let shown = if c.is_control() {
483            format!("the control character U+{:04X}", c as u32)
484        } else {
485            format!("`{c}`")
486        };
487        problems.push(format!(
488            "contains {shown}, which Windows does not allow in a name"
489        ));
490    }
491    if name.ends_with('.') || name.ends_with(' ') {
492        problems.push("ends in a `.` or a space, which Windows strips".to_string());
493    }
494    let units = utf16_len(name);
495    if units > WINDOWS_MAX_COMPONENT {
496        problems.push(format!(
497            "is {units} UTF-16 code units long; a Windows name component may be at most {WINDOWS_MAX_COMPONENT}"
498        ));
499    }
500    let stem = name.split('.').next().unwrap_or(name).to_ascii_uppercase();
501    if RESERVED_STEMS.contains(&stem.as_str()) {
502        problems.push(format!(
503            "is the reserved Windows device name `{stem}` (reserved with any extension)"
504        ));
505    }
506    problems
507}
508
509/// The 1-based (line, column) of the first CR byte in `bytes`, if any. The
510/// column counts bytes from the start of its line.
511fn first_cr(bytes: &[u8]) -> Option<(usize, usize)> {
512    let at = bytes.iter().position(|&b| b == b'\r')?;
513    let before = &bytes[..at];
514    let line = before.iter().filter(|&&b| b == b'\n').count() + 1;
515    let line_start = before
516        .iter()
517        .rposition(|&b| b == b'\n')
518        .map_or(0, |i| i + 1);
519    Some((line, at - line_start + 1))
520}
521
522/// Lint a rendered tree (see [`lint_files`]).
523pub fn lint_tree(
524    tree: &RenderedTree,
525    target: &Target,
526    env: &str,
527    label: &dyn Fn(&RelPath) -> String,
528) -> Vec<DeployFinding> {
529    lint_files(&tree.files, target, env, label)
530}
531
532/// The fail-closed tree lints, all collected in one pass (DRND-08, D-01,
533/// D-04). They apply to every environment, because `resources/` is shared.
534///
535/// - per path component (once per distinct component path): characters
536///   Windows rejects, a trailing `.` or space, more than
537///   [`WINDOWS_MAX_COMPONENT`] UTF-16 units, a reserved device stem;
538/// - per parent directory: sibling names (files and directories) that differ
539///   only by case under `str::to_lowercase`, naming every colliding sibling;
540/// - per file: [`windows_prefix_len`] + 1 + the UTF-16 length of the
541///   collection path must be below [`WINDOWS_MAX_PATH`];
542/// - per text member ([`is_text_member`]): the first CR byte, with its line
543///   and column (D-01; binary members such as jars are exempt);
544/// - `target.mode`, held to the component rules (finding on
545///   `targets/<env>.yaml`).
546///
547/// `label` names the source file a finding should carry for a collection path.
548/// The Windows rules approximate NTFS and are fail-closed; the real NTFS
549/// capture is Phase 22 (A4, D-04). CD6 records how an 8.3.6 gateway on Linux
550/// encodes hostile names.
551pub fn lint_files(
552    files: &BTreeMap<RelPath, Vec<u8>>,
553    target: &Target,
554    env: &str,
555    label: &dyn Fn(&RelPath) -> String,
556) -> Vec<DeployFinding> {
557    let mut findings = Vec::new();
558    let finding = |file: String, line: Option<(usize, usize)>, message: String| DeployFinding {
559        file,
560        line: line.map(|l| l.0),
561        column: line.map(|l| l.1),
562        message,
563    };
564
565    for problem in windows_problems(&target.mode) {
566        findings.push(finding(
567            format!("targets/{env}.yaml"),
568            None,
569            format!("mode `{}` {problem}", target.mode),
570        ));
571    }
572
573    let prefix = windows_prefix_len(target);
574    // distinct component paths already checked, and the sibling groups
575    let mut seen: BTreeSet<&str> = BTreeSet::new();
576    // parent -> lowercased name -> name -> label of the first file under it
577    let mut siblings: BTreeMap<&str, BTreeMap<String, BTreeMap<&str, String>>> = BTreeMap::new();
578
579    for (path, bytes) in files {
580        let full = path.as_str();
581        let source = label(path);
582
583        let mut end = 0;
584        for name in path.components() {
585            let start = end;
586            end += name.len();
587            let upto = &full[..end];
588            if end < full.len() {
589                end += 1; // the `/` after this component
590            }
591            if !seen.insert(upto) {
592                continue;
593            }
594            let parent = if start == 0 { "" } else { &full[..start - 1] };
595            siblings
596                .entry(parent)
597                .or_default()
598                .entry(name.to_lowercase())
599                .or_default()
600                .entry(name)
601                .or_insert_with(|| source.clone());
602            for problem in windows_problems(name) {
603                findings.push(finding(
604                    source.clone(),
605                    None,
606                    format!("path component `{name}` {problem}"),
607                ));
608            }
609        }
610
611        let total = prefix + 1 + utf16_len(full);
612        if total >= WINDOWS_MAX_PATH {
613            findings.push(finding(
614                source.clone(),
615                None,
616                format!(
617                    "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}",
618                    COLLECTION_SEGMENT.trim_matches('\\')
619                ),
620            ));
621        }
622
623        if is_text_member(full, bytes)
624            && let Some((line, column)) = first_cr(bytes)
625        {
626            findings.push(finding(
627                source,
628                Some((line, column)),
629                format!(
630                    "carriage return (0x0D) at line {line} of a text file; render refuses CR so no tool can convert line endings (use LF)"
631                ),
632            ));
633        }
634    }
635
636    for (parent, groups) in &siblings {
637        for names in groups.values().filter(|names| names.len() > 1) {
638            let shown: Vec<String> = names
639                .keys()
640                .map(|n| {
641                    if parent.is_empty() {
642                        format!("`{n}`")
643                    } else {
644                        format!("`{parent}/{n}`")
645                    }
646                })
647                .collect();
648            let first = names.values().next().cloned().unwrap_or_default();
649            findings.push(finding(
650                first,
651                None,
652                format!(
653                    "sibling names collide when case is folded ({}); a case-insensitive filesystem keeps only one",
654                    shown.join(", ")
655                ),
656            ));
657        }
658    }
659    findings
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665    use crate::deploy::node;
666
667    fn collect(json: &str) -> Vec<(String, String)> {
668        let mut out = BTreeSet::new();
669        referenced_secret_names(&node::parse(json).unwrap(), &mut out);
670        out.into_iter().collect()
671    }
672
673    fn pair(provider: &str, name: &str) -> (String, String) {
674        (provider.to_string(), name.to_string())
675    }
676
677    const REF_A: &str = r#"{"type":"Referenced","data":{"providerName":"p","secretName":"a"}}"#;
678
679    #[test]
680    fn collects_every_referenced_pair() {
681        let doc = format!(
682            r#"{{"top":{REF_A},"nested":{{"deep":{{"type":"Referenced","data":{{"providerName":"p","secretName":"b"}}}}}},"list":[1,{{"type":"Referenced","data":{{"providerName":"q","secretName":"c"}}}}]}}"#
683        );
684        assert_eq!(
685            collect(&doc),
686            [pair("p", "a"), pair("p", "b"), pair("q", "c")]
687        );
688        // The root itself may be a reference.
689        assert_eq!(collect(REF_A), [pair("p", "a")]);
690    }
691
692    #[test]
693    fn duplicate_reference_collects_once() {
694        let doc = format!(r#"{{"x":{REF_A},"y":[{REF_A}]}}"#);
695        assert_eq!(collect(&doc), [pair("p", "a")]);
696    }
697
698    #[test]
699    fn same_name_two_providers_collects_twice() {
700        let doc = r#"[{"type":"Referenced","data":{"providerName":"p2","secretName":"a"}},{"type":"Referenced","data":{"providerName":"p1","secretName":"a"}}]"#;
701        // Byte order of provider, then name.
702        assert_eq!(collect(doc), [pair("p1", "a"), pair("p2", "a")]);
703    }
704
705    #[test]
706    fn no_reference_collects_nothing() {
707        assert!(collect(r#"{"a":1,"b":["x",{"c":null}]}"#).is_empty());
708        assert!(collect(r#"{}"#).is_empty());
709        assert!(collect(r#""just a string""#).is_empty());
710    }
711
712    #[test]
713    fn lookalikes_are_ignored() {
714        for doc in [
715            // an extra member beside type/data
716            r#"{"type":"Referenced","data":{"providerName":"p","secretName":"a"},"x":1}"#,
717            // lower case type
718            r#"{"type":"referenced","data":{"providerName":"p","secretName":"a"}}"#,
719            // a non-string secret name
720            r#"{"type":"Referenced","data":{"providerName":"p","secretName":7}}"#,
721            // a third member in data
722            r#"{"type":"Referenced","data":{"providerName":"p","secretName":"a","extra":"v"}}"#,
723            // a missing provider
724            r#"{"type":"Referenced","data":{"secretName":"a"}}"#,
725            // an Embedded object is not a reference
726            r#"{"type":"Embedded","data":{"ciphertext":"X"}}"#,
727        ] {
728            assert!(collect(doc).is_empty(), "{doc}");
729        }
730    }
731
732    #[test]
733    fn a_lookalike_does_not_hide_a_reference_inside_it() {
734        let doc = format!(r#"{{"type":"Referenced","extra":{REF_A}}}"#);
735        assert_eq!(collect(&doc), [pair("p", "a")]);
736    }
737
738    fn key(module: &str, ty: &str) -> ResourceKey {
739        ResourceKey {
740            module: module.to_string(),
741            ty: ty.to_string(),
742            name: Some("x".to_string()),
743        }
744    }
745
746    fn scan(module: &str, ty: &str, json: &str) -> SecretScan {
747        scan_secrets(
748            "f/config.json",
749            &key(module, ty),
750            "config.json",
751            &node::parse(json).unwrap(),
752        )
753    }
754
755    #[test]
756    fn embedded_nested_in_arrays_is_found_with_index_path() {
757        let s = scan(
758            "m",
759            "t",
760            r#"{"a":[{"b":{"type":"Embedded","data":{"ciphertext":"X"}}}]}"#,
761        );
762        assert_eq!(s.refused, ["f/config.json: a[0].b (embedded)"]);
763    }
764
765    #[test]
766    fn embedded_without_object_data_is_not_embedded() {
767        let s = scan("m", "t", r#"{"a":{"type":"Embedded","data":"x"}}"#);
768        assert!(s.refused.is_empty());
769    }
770
771    #[test]
772    fn embedded_at_a_root_is_reported_as_root() {
773        let s = scan_embedded(
774            "f.json",
775            &node::parse(r#"{"type":"Embedded","data":{}}"#).unwrap(),
776        );
777        assert_eq!(s.refused, ["f.json: $ (embedded)"]);
778    }
779
780    #[test]
781    fn schema_path_values() {
782        let ok = scan("ignition", "database-connection", r#"{"password":null}"#);
783        assert_eq!(ok, SecretScan::default());
784        let ok = scan(
785            "ignition",
786            "database-connection",
787            r#"{"password":{"type":"Referenced","data":{"providerName":"p","secretName":"s"}}}"#,
788        );
789        assert_eq!(ok, SecretScan::default());
790        for bad in [r#""x""#, r#""""#, "1", "true", "[]", "{}"] {
791            let s = scan(
792                "ignition",
793                "database-connection",
794                &format!(r#"{{"password":{bad}}}"#),
795            );
796            assert_eq!(
797                s.refused,
798                ["f/config.json: password (plaintext)"],
799                "value {bad}"
800            );
801        }
802    }
803
804    #[test]
805    fn non_config_files_get_only_the_embedded_rule() {
806        let node = node::parse(r#"{"password":"x"}"#).unwrap();
807        let s = scan_secrets(
808            "f/other.json",
809            &key("com.cirruslink.mqtt.engine.gateway", "server"),
810            "other.json",
811            &node,
812        );
813        assert_eq!(s, SecretScan::default());
814    }
815
816    #[test]
817    fn key_name_in_a_described_type_warns_by_path() {
818        let s = scan(
819            "ignition",
820            "database-connection",
821            r#"{"settings":{"mqttPassword":"CANARY"}}"#,
822        );
823        assert!(s.refused.is_empty());
824        assert_eq!(s.warnings.len(), 1);
825        assert!(s.warnings[0].contains("settings.mqttPassword"));
826        assert!(!s.warnings[0].contains("CANARY"));
827    }
828
829    #[test]
830    fn referenced_data_secret_name_is_a_name() {
831        let s = scan(
832            "com.cirruslink.mqtt.engine.gateway",
833            "server",
834            r#"{"password":{"type":"Referenced","data":{"providerName":"p","secretName":"s"}}}"#,
835        );
836        assert_eq!(s, SecretScan::default());
837    }
838
839    #[test]
840    fn referenced_with_extra_data_member_at_schema_path_is_plaintext() {
841        let s = scan(
842            "ignition",
843            "database-connection",
844            r#"{"password":{"type":"Referenced","data":{"providerName":"p","secretName":"s","password":"hunter2"}}}"#,
845        );
846        assert_eq!(s.refused, ["f/config.json: password (plaintext)"]);
847        assert!(s.warnings.is_empty(), "{:?}", s.warnings);
848    }
849
850    #[test]
851    fn key_name_rule_applies_inside_malformed_referenced() {
852        let s = scan(
853            "com.cirruslink.mqtt.engine.gateway",
854            "server",
855            r#"{"settings":{"cred":{"type":"Referenced","data":{"providerName":"p","secretName":"s","password":"hunter2"}}}}"#,
856        );
857        assert_eq!(
858            s.refused,
859            [
860                "f/config.json: settings.cred.data.secretName (plaintext, by key name)",
861                "f/config.json: settings.cred.data.password (plaintext, by key name)",
862            ]
863        );
864    }
865
866    /// A Referenced-shaped object at the database-connection `password` path,
867    /// each deviating from the CD7 shape in one way.
868    const MALFORMED_REFERENCED: [&str; 14] = [
869        r#"{"type":"Referenced","data":{"providerName":"p","secretName":"s","password":"x"}}"#,
870        r#"{"type":"Referenced","data":{"providerName":"p"}}"#,
871        r#"{"type":"Referenced","data":{"secretName":"s"}}"#,
872        r#"{"type":"Referenced","data":{}}"#,
873        r#"{"type":"Referenced","data":{"providerName":1,"secretName":"s"}}"#,
874        r#"{"type":"Referenced","data":{"providerName":"p","secretName":null}}"#,
875        r#"{"type":"Referenced","data":{"providerName":"p","secretName":{"v":"x"}}}"#,
876        r#"{"type":"Referenced","data":"x"}"#,
877        r#"{"type":"Referenced","data":[]}"#,
878        r#"{"type":"Referenced","data":{"providerName":"p","secretName":"s"},"value":"x"}"#,
879        r#"{"type":"referenced","data":{"providerName":"p","secretName":"s"}}"#,
880        r#"{"type":1,"data":{"providerName":"p","secretName":"s"}}"#,
881        r#"{"data":{"providerName":"p","secretName":"s"}}"#,
882        r#"{"type":"Referenced","data":{"providername":"p","secretName":"s"}}"#,
883    ];
884
885    #[test]
886    fn malformed_referenced_variants_are_plaintext_at_schema_path() {
887        for (i, variant) in MALFORMED_REFERENCED.iter().enumerate() {
888            let s = scan(
889                "ignition",
890                "database-connection",
891                &format!(r#"{{"password":{variant}}}"#),
892            );
893            assert_eq!(
894                s.refused,
895                ["f/config.json: password (plaintext)"],
896                "variant {i}"
897            );
898            assert!(s.warnings.is_empty(), "variant {i}: {:?}", s.warnings);
899        }
900    }
901
902    #[test]
903    fn well_formed_referenced_passes_in_either_key_order() {
904        for referenced in [
905            r#"{"data":{"providerName":"p","secretName":"s"},"type":"Referenced"}"#,
906            r#"{"type":"Referenced","data":{"secretName":"s","providerName":"p"}}"#,
907        ] {
908            let s = scan(
909                "ignition",
910                "database-connection",
911                &format!(r#"{{"password":{referenced}}}"#),
912            );
913            assert_eq!(s, SecretScan::default(), "{referenced}");
914            let s = scan(
915                "com.cirruslink.mqtt.engine.gateway",
916                "server",
917                &format!(
918                    r#"{{"password":{referenced},"settings":{{"clientSecret":{referenced}}}}}"#
919                ),
920            );
921            assert_eq!(s, SecretScan::default(), "{referenced}");
922        }
923    }
924
925    #[test]
926    fn malformed_referenced_outside_schema_path_warns_in_described_type() {
927        let s = scan(
928            "ignition",
929            "database-connection",
930            r#"{"settings":{"cred":{"type":"Referenced","data":{"providerName":"p","secretName":"s","password":"hunter2"}}}}"#,
931        );
932        assert!(s.refused.is_empty(), "{:?}", s.refused);
933        assert_eq!(s.warnings.len(), 2, "{:?}", s.warnings);
934        assert!(
935            s.warnings
936                .iter()
937                .any(|w| w.contains("settings.cred.data.secretName;"))
938        );
939        assert!(
940            s.warnings
941                .iter()
942                .any(|w| w.contains("settings.cred.data.password;"))
943        );
944        assert!(s.warnings.iter().all(|w| !w.contains("hunter2")));
945    }
946
947    // ---- Windows safety, budget and CR ----------------------------------
948
949    use proptest::prelude::*;
950
951    fn target(mode: &str, extra_nodes: &str) -> Target {
952        let yaml = format!(
953            "schema_version: 1\ngateway: g\nmode: {mode}\nnodes:\n  master:\n    profile: p\n{extra_nodes}"
954        );
955        Target::parse("t.yaml", yaml.as_bytes()).unwrap()
956    }
957
958    fn files(entries: &[(&str, &[u8])]) -> BTreeMap<RelPath, Vec<u8>> {
959        entries
960            .iter()
961            .map(|(p, b)| (RelPath::new(p).unwrap(), b.to_vec()))
962            .collect()
963    }
964
965    fn lint(entries: &[(&str, &[u8])], t: &Target) -> Vec<DeployFinding> {
966        lint_files(&files(entries), t, "dev", &|p| format!("src/{p}"))
967    }
968
969    fn texts(findings: &[DeployFinding]) -> String {
970        findings
971            .iter()
972            .map(ToString::to_string)
973            .collect::<Vec<_>>()
974            .join(" | ")
975    }
976
977    #[test]
978    fn case_fold_collision_names_both() {
979        let t = target("dev", "");
980        let found = lint(
981            &[
982                ("ignition/schedule/Pump/config.json", b"{}"),
983                ("ignition/schedule/pump/config.json", b"{}"),
984                ("ignition/schedule/Other/config.json", b"{}"),
985            ],
986            &t,
987        );
988        assert_eq!(found.len(), 1, "{}", texts(&found));
989        let text = texts(&found);
990        assert!(text.contains("`ignition/schedule/Pump`"), "{text}");
991        assert!(text.contains("`ignition/schedule/pump`"), "{text}");
992        assert!(!text.contains("Other"), "{text}");
993
994        // files collide as well as directories, and three-way groups name all
995        let found = lint(&[("a/b/X", b""), ("a/b/x", b""), ("a/b/X.", b"")], &t);
996        let text = texts(&found);
997        assert!(
998            text.contains("`a/b/X`") && text.contains("`a/b/x`"),
999            "{text}"
1000        );
1001        // whole names fold, not a prefix
1002        assert!(lint(&[("a/b/x", b""), ("a/b/xy", b"")], &t).is_empty());
1003        // non-ASCII folds with to_lowercase
1004        assert_eq!(lint(&[("a/b/É", b""), ("a/b/é", b"")], &t).len(), 1);
1005    }
1006
1007    #[test]
1008    fn illegal_characters_are_refused() {
1009        let t = target("dev", "");
1010        for bad in [
1011            "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",
1012        ] {
1013            let found = lint(&[(&format!("m/t/{bad}/config.json"), b"{}")], &t);
1014            assert_eq!(found.len(), 1, "{bad:?}: {}", texts(&found));
1015            assert!(
1016                found[0].message.contains("does not allow in a name"),
1017                "{bad:?}: {}",
1018                texts(&found)
1019            );
1020        }
1021        // each distinct component is reported once, however many files sit below
1022        let found = lint(&[("m/t/a:b/x.json", b"{}"), ("m/t/a:b/y.json", b"{}")], &t);
1023        assert_eq!(found.len(), 1, "{}", texts(&found));
1024        // legal punctuation passes
1025        assert!(lint(&[("m/t/a b-c_d.e%3ab/x.json", b"{}")], &t).is_empty());
1026    }
1027
1028    #[test]
1029    fn trailing_dot_and_space_are_refused() {
1030        let t = target("dev", "");
1031        for bad in ["trail.", "trail ", "a b ", "dots.."] {
1032            let found = lint(&[(&format!("m/t/{bad}/x.json"), b"{}")], &t);
1033            assert_eq!(found.len(), 1, "{bad:?}: {}", texts(&found));
1034            assert!(found[0].message.contains("ends in"), "{}", texts(&found));
1035        }
1036        assert!(lint(&[("m/t/inner .dot/x.json", b"{}")], &t).is_empty());
1037    }
1038
1039    #[test]
1040    fn reserved_names_are_refused() {
1041        let t = target("dev", "");
1042        for stem in RESERVED_STEMS {
1043            for name in [stem.to_string(), stem.to_lowercase(), format!("{stem}.txt")] {
1044                let found = lint(&[(&format!("m/t/{name}/x.json"), b"{}")], &t);
1045                assert_eq!(found.len(), 1, "{name}: {}", texts(&found));
1046                assert!(found[0].message.contains("reserved"), "{}", texts(&found));
1047            }
1048        }
1049        // the stem is what precedes the first dot, compared whole
1050        for ok in [
1051            "CON1", "COM0", "COM10", "xcon", "CONSOLE", "LPT", "a.CON", "COMM",
1052        ] {
1053            assert!(
1054                lint(&[(&format!("m/t/{ok}/x.json"), b"{}")], &t).is_empty(),
1055                "{ok}"
1056            );
1057        }
1058        // a file name is a component too
1059        assert_eq!(lint(&[("m/t/n/nul.json", b"{}")], &t).len(), 1);
1060    }
1061
1062    #[test]
1063    fn component_length_boundary() {
1064        let t = target("d", "");
1065        // only the component rule is under test here, not the path budget
1066        let component = |path: &str| texts(&lint(&[(path, b"")], &t)).contains("name component");
1067        let long = |n: usize| format!("m/t/{}/x", "a".repeat(n));
1068        assert!(!component(&long(255)));
1069        assert!(component(&long(256)));
1070        assert!(texts(&lint(&[(&long(256), b"")], &t)).contains("256 UTF-16 code units"));
1071        // a non-BMP character is two UTF-16 units
1072        assert!(!component(&format!("m/t/{}a/x", "\u{1F600}".repeat(127))));
1073        assert!(component(&format!("m/t/{}/x", "\u{1F600}".repeat(128))));
1074        // 255 UTF-16 units but 510 bytes: bytes do not decide
1075        assert!(!component(&format!("m/t/{}/x", "\u{e9}".repeat(255))));
1076    }
1077
1078    /// A relative path of exactly `total` UTF-16 units made of short parts.
1079    fn path_of(total: usize) -> String {
1080        let mut path = String::from("m/t/n/");
1081        path.push_str(&"a".repeat(total - path.len()));
1082        path
1083    }
1084
1085    #[test]
1086    fn path_budget_boundary() {
1087        // 51 (default data dir) + 18 (collection segment) + 10 (mode) = 79,
1088        // plus 1 separator before the relative path
1089        let t = target("production", "");
1090        assert_eq!(windows_prefix_len(&t), 79);
1091        let ok = path_of(167);
1092        assert!(lint(&[(&ok, b"")], &t).is_empty(), "247 passes");
1093        let bad = path_of(168);
1094        let found = lint(&[(&bad, b"")], &t);
1095        assert_eq!(found.len(), 1, "{}", texts(&found));
1096        assert!(
1097            found[0].message.contains("248 UTF-16 code units"),
1098            "{}",
1099            texts(&found)
1100        );
1101
1102        // a longer Windows data_dir (63 characters, 12 more) lowers the
1103        // allowed relative length by 12
1104        let dir = format!("D:\\{}", "d".repeat(60));
1105        let windows = format!(
1106            "    ssh:\n      host: h\n      user: u\n      os: windows\n      data_dir: '{dir}'\n      known_hosts: kh\n"
1107        );
1108        let t2 = target("production", &windows);
1109        assert_eq!(windows_prefix_len(&t2), 79 + 12);
1110        assert!(lint(&[(&path_of(167 - 12), b"")], &t2).is_empty());
1111        assert_eq!(lint(&[(&path_of(168 - 12), b"")], &t2).len(), 1);
1112
1113        // a trailing separator is trimmed and the backup node counts
1114        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";
1115        assert_eq!(windows_prefix_len(&target("production", backup)), 79 + 3);
1116
1117        // a Linux node never lowers the budget
1118        let linux = format!(
1119            "    ssh:\n      host: h\n      user: u\n      os: linux\n      data_dir: /{}\n      known_hosts: kh\n",
1120            "l".repeat(100)
1121        );
1122        assert_eq!(windows_prefix_len(&target("production", &linux)), 79);
1123    }
1124
1125    #[test]
1126    fn mode_is_held_to_the_component_rules() {
1127        let t = target("prod.", "");
1128        let found = lint(&[], &t);
1129        assert_eq!(found.len(), 1, "{}", texts(&found));
1130        assert_eq!(found[0].file, "targets/dev.yaml");
1131        assert!(found[0].message.contains("mode `prod.`"));
1132        assert_eq!(lint(&[], &target("CON", "")).len(), 1);
1133    }
1134
1135    #[test]
1136    fn cr_in_text_member_is_refused_with_line() {
1137        let t = target("dev", "");
1138        let found = lint(
1139            &[("m/t/n/config.json", b"{\n  \"a\": 1,\r\n  \"b\": 2\n}")],
1140            &t,
1141        );
1142        assert_eq!(found.len(), 1, "{}", texts(&found));
1143        assert_eq!((found[0].line, found[0].column), (Some(2), Some(10)));
1144        assert_eq!(found[0].file, "src/m/t/n/config.json");
1145        assert!(
1146            found[0]
1147                .to_string()
1148                .starts_with("src/m/t/n/config.json:2:10: "),
1149            "{}",
1150            found[0]
1151        );
1152        // the first CR wins; a lone CR counts as well
1153        let found = lint(&[("m/t/n/a.txt", b"x\ry\r\n")], &t);
1154        assert_eq!(found[0].line, Some(1));
1155    }
1156
1157    #[test]
1158    fn cr_in_jar_is_not_refused() {
1159        let t = target("dev", "");
1160        // NUL bytes: not text
1161        assert!(lint(&[("m/t/n/driver.jar", b"PK\r\n\x00\x01")], &t).is_empty());
1162        // invalid UTF-8: not text
1163        assert!(lint(&[("m/t/n/driver.bin", b"\xff\xfe\r\n")], &t).is_empty());
1164    }
1165
1166    #[test]
1167    fn cr_in_unknown_text_extension_is_refused() {
1168        let t = target("dev", "");
1169        for ext in [
1170            "json",
1171            "css",
1172            "yaml",
1173            "yml",
1174            "txt",
1175            "md",
1176            "xml",
1177            "py",
1178            "csv",
1179            "properties",
1180            "html",
1181            "js",
1182            "svg",
1183            "JSON",
1184            "Xml",
1185            "weird",
1186            "noext",
1187        ] {
1188            let name = if ext == "noext" {
1189                "m/t/n/LICENSE".to_string()
1190            } else {
1191                format!("m/t/n/f.{ext}")
1192            };
1193            let found = lint(&[(&name, b"a\r\nb")], &t);
1194            assert_eq!(found.len(), 1, "{name}: {}", texts(&found));
1195        }
1196        // a known text extension is text whatever its bytes
1197        assert!(is_text_member("a.json", b"\x00\xff"));
1198        assert!(!is_text_member("a.jar", b"\x00PK"));
1199        assert!(is_text_member("a.jar", b"plain"));
1200        // a dot in a directory name is not an extension
1201        assert!(!is_text_member("dir.json/blob", b"\x00"));
1202    }
1203
1204    #[test]
1205    fn empty_inputs_pass() {
1206        let t = target("dev", "");
1207        assert!(lint(&[], &t).is_empty());
1208        assert!(lint(&[("m/t/n/empty.txt", b""), ("m/t/n/empty.bin", b"")], &t).is_empty());
1209    }
1210
1211    fn reserved(name: &str) -> bool {
1212        RESERVED_STEMS.contains(
1213            &name
1214                .split('.')
1215                .next()
1216                .unwrap()
1217                .to_ascii_uppercase()
1218                .as_str(),
1219        )
1220    }
1221
1222    proptest! {
1223        #[test]
1224        fn prop_extra_data_member_is_refused_without_value(
1225            key in "[A-Za-z][A-Za-z0-9]{0,12}",
1226            value in "CANARY-[A-Za-z0-9]{6,12}",
1227        ) {
1228            prop_assume!(key != "providerName" && key != "secretName");
1229            let json = format!(
1230                r#"{{"password":{{"type":"Referenced","data":{{"providerName":"p","secretName":"s","{key}":"{value}"}}}}}}"#
1231            );
1232            let s = scan("ignition", "database-connection", &json);
1233            prop_assert_eq!(&s.refused, &["f/config.json: password (plaintext)".to_string()]);
1234            for text in s.refused.iter().chain(s.warnings.iter()) {
1235                prop_assert!(!text.contains(&value), "value leaked: {text}");
1236            }
1237        }
1238
1239        #[test]
1240        fn prop_illegal_char_names_refused(
1241            base in "[A-Za-z0-9_-]{1,20}",
1242            at in 0usize..=20,
1243            bad in prop::sample::select(vec!['<', '>', ':', '"', '|', '?', '*', '\u{1}', '\u{8}', '\u{1f}']),
1244        ) {
1245            let mut name = base.clone();
1246            name.insert(at.min(base.len()), bad);
1247            let found = lint(
1248                &[(&format!("m/t/{name}/x.json"), b"{}")],
1249                &target("dev", ""),
1250            );
1251            prop_assert!(!found.is_empty(), "{name:?} passed");
1252        }
1253
1254        #[test]
1255        fn prop_legal_names_pass(name in "[A-Za-z0-9_-]([A-Za-z0-9_ .-]{0,30}[A-Za-z0-9_-])?") {
1256            prop_assume!(!reserved(&name));
1257            let found = lint(
1258                &[(&format!("m/t/{name}/x.json"), b"{}")],
1259                &target("dev", ""),
1260            );
1261            prop_assert!(found.is_empty(), "{name:?}: {}", texts(&found));
1262        }
1263    }
1264}
1265
1266/// D-14 with R3-SECRET for project-owned tag nodes (26-07). Synthetic
1267/// content and neutral names only.
1268#[cfg(test)]
1269mod tag_node_tests {
1270    use super::*;
1271    use crate::deploy::node;
1272
1273    const ORIGIN: &str = "tags/PlantA/F/ApiToken.json";
1274
1275    fn scan(path: &str, json: &str, sends: Sends) -> SecretScan {
1276        scan_tag_node(ORIGIN, path, &node::parse(json).unwrap(), sends)
1277    }
1278
1279    fn all_text(s: &SecretScan) -> String {
1280        format!("{:?} {:?}", s.refused, s.warnings)
1281    }
1282
1283    /// A credential value: a non-empty string, or a Document, object or
1284    /// array holding one at any depth.
1285    #[test]
1286    fn credential_values_are_strings_at_any_depth() {
1287        for value in [
1288            r#""fake-token-aaaa""#,
1289            r#"{"jwt":"fake.jwt.value"}"#,
1290            r#"{"outer":{"inner":["", "fake"]}}"#,
1291            r#"["fake"]"#,
1292        ] {
1293            let s = scan(
1294                "F/ApiToken",
1295                &format!(r#"{{"tagType":"AtomicTag","value":{value}}}"#),
1296                Sends::All,
1297            );
1298            assert_eq!(s.refused.len(), 1, "{value}: {}", all_text(&s));
1299        }
1300        for value in [r#""""#, "42", "true", "null", r#"{"n":1,"a":[""]}"#, "[]"] {
1301            let s = scan(
1302                "F/ApiToken",
1303                &format!(r#"{{"tagType":"AtomicTag","value":{value}}}"#),
1304                Sends::All,
1305            );
1306            assert_eq!(s, SecretScan::default(), "{value}");
1307        }
1308    }
1309
1310    /// The tag name holds a marker, ASCII case-insensitively; other names
1311    /// are never credentials, whatever their value.
1312    #[test]
1313    fn markers_match_the_tag_name_without_case() {
1314        assert_eq!(TAG_CREDENTIAL_MARKERS.len(), 7);
1315        for name in [
1316            "DB_PASSWORD",
1317            "Passwd1",
1318            "MySecret",
1319            "authToken",
1320            "APIKEY",
1321            "svc_api_key",
1322            "Credentials",
1323        ] {
1324            let s = scan(
1325                &format!("F/{name}"),
1326                r#"{"tagType":"AtomicTag","value":"fake"}"#,
1327                Sends::All,
1328            );
1329            assert_eq!(s.refused.len(), 1, "{name}");
1330        }
1331        let s = scan(
1332            "F/Speed",
1333            r#"{"tagType":"AtomicTag","value":"fake","defaultValue":"fake"}"#,
1334            Sends::All,
1335        );
1336        assert_eq!(s, SecretScan::default());
1337    }
1338
1339    /// Sends::All (an added node): Embedded anywhere, a credential value and
1340    /// a credential defaultValue refuse.
1341    #[test]
1342    fn all_refuses_value_default_value_and_embedded() {
1343        let s = scan(
1344            "F/ApiToken",
1345            r#"{"tagType":"AtomicTag","value":"fake-a","defaultValue":"fake-b",
1346                "documentation":{"type":"Embedded","data":{"ciphertext":"X"}}}"#,
1347            Sends::All,
1348        );
1349        assert_eq!(
1350            s.refused,
1351            [
1352                format!("{ORIGIN}: documentation (embedded)"),
1353                format!("{ORIGIN}: value (credential value)"),
1354                format!("{ORIGIN}: defaultValue (credential value)"),
1355            ]
1356        );
1357        assert!(s.warnings.is_empty());
1358    }
1359
1360    /// Sends::Config (a changed node, R3-POL): Embedded and a credential
1361    /// defaultValue refuse; a credential value is stripped, so it warns that
1362    /// the live value is kept.
1363    #[test]
1364    fn config_refuses_default_value_and_warns_value() {
1365        let s = scan(
1366            "F/ApiToken",
1367            r#"{"tagType":"AtomicTag","value":"fake-a","defaultValue":"fake-b"}"#,
1368            Sends::Config,
1369        );
1370        assert_eq!(
1371            s.refused,
1372            [format!("{ORIGIN}: defaultValue (credential value)")]
1373        );
1374        assert_eq!(s.warnings.len(), 1);
1375        assert!(s.warnings[0].starts_with(&format!("{ORIGIN}: value (credential value)")));
1376        assert!(
1377            s.warnings[0].contains("live value is kept"),
1378            "{:?}",
1379            s.warnings
1380        );
1381        let s = scan(
1382            "F/Speed",
1383            r#"{"tagType":"AtomicTag","tooltip":{"type":"Embedded","data":{"k":"X"}}}"#,
1384            Sends::Config,
1385        );
1386        assert_eq!(s.refused, [format!("{ORIGIN}: tooltip (embedded)")]);
1387    }
1388
1389    /// Sends::Nothing (a kept-values node): no batch names it, so every case
1390    /// is a warning saying nothing is sent (D-14).
1391    #[test]
1392    fn nothing_only_warns() {
1393        let s = scan(
1394            "F/ApiToken",
1395            r#"{"tagType":"AtomicTag","value":"fake-a","defaultValue":"fake-b",
1396                "tooltip":{"type":"Embedded","data":{"k":"X"}}}"#,
1397            Sends::Nothing,
1398        );
1399        assert!(s.refused.is_empty(), "{}", all_text(&s));
1400        assert_eq!(s.warnings.len(), 3, "{:?}", s.warnings);
1401        assert!(s.warnings.iter().all(|w| w.contains("nothing is sent")));
1402        assert!(s.warnings[0].starts_with(&format!("{ORIGIN}: tooltip (embedded)")));
1403    }
1404
1405    /// R3-SECRET: a Document defaultValue whose only string sits under a key
1406    /// such as `jwt`, beside a different value, refuses whenever the node is
1407    /// sent.
1408    #[test]
1409    fn document_default_value_refuses_when_sent() {
1410        let json = r#"{"tagType":"AtomicTag","dataType":"Document",
1411            "value":{"count":2},"defaultValue":{"jwt":"fake.jwt.part"}}"#;
1412        for sends in [Sends::All, Sends::Config] {
1413            let s = scan("F/ApiToken", json, sends);
1414            assert_eq!(
1415                s.refused,
1416                [format!("{ORIGIN}: defaultValue (credential value)")],
1417                "{sends:?}"
1418            );
1419        }
1420        let s = scan("F/ApiToken", json, Sends::Nothing);
1421        assert!(s.refused.is_empty() && s.warnings.len() == 1);
1422    }
1423
1424    /// Instance members are walked by name; the member's own name decides.
1425    #[test]
1426    fn instance_members_are_scanned() {
1427        let json = r#"{"tagType":"UdtInstance","typeId":"Device","tags":[
1428            {"name":"Speed","tagType":"AtomicTag","value":"fake"},
1429            {"name":"Sub","tagType":"Folder","tags":[
1430                {"name":"LoginPassword","tagType":"AtomicTag","value":"fake-p"}]}]}"#;
1431        let s = scan("F/Device", json, Sends::All);
1432        assert_eq!(
1433            s.refused,
1434            [format!(
1435                "{ORIGIN}: tags.Sub.tags.LoginPassword.value (credential value)"
1436            )]
1437        );
1438        let s = scan("F/Device", json, Sends::Config);
1439        assert!(s.refused.is_empty());
1440        assert_eq!(s.warnings.len(), 1);
1441    }
1442
1443    /// Below `_types_/` nothing is stripped (a member's value is the type's
1444    /// default), so a changed type sends its member values.
1445    #[test]
1446    fn a_changed_type_sends_member_values() {
1447        let json = r#"{"tagType":"UdtType","tags":[
1448            {"name":"ApiKey","tagType":"AtomicTag","value":"fake-k"}]}"#;
1449        let s = scan("_types_/Device", json, Sends::Config);
1450        assert_eq!(
1451            s.refused,
1452            [format!("{ORIGIN}: tags.ApiKey.value (credential value)")]
1453        );
1454    }
1455
1456    /// Locations only: no output carries a value.
1457    #[test]
1458    fn outputs_never_carry_a_value() {
1459        let json = r#"{"tagType":"AtomicTag","value":"sentinel-one",
1460            "defaultValue":{"jwt":"sentinel-two"},
1461            "tooltip":{"type":"Embedded","data":{"k":"sentinel-three"}}}"#;
1462        for sends in [Sends::All, Sends::Config, Sends::Nothing] {
1463            let s = scan("F/ApiToken", json, sends);
1464            assert!(
1465                !all_text(&s).contains("sentinel"),
1466                "{sends:?}: {}",
1467                all_text(&s)
1468            );
1469            assert!(!s.refused.is_empty() || !s.warnings.is_empty());
1470        }
1471    }
1472}