Skip to main content

ignition_core/deploy/
target.rs

1//! `targets/<env>.yaml`: where a rendered tree is deployed.
2//!
3//! A node reaches its host through `ssh` (SF-10) or `local` (D-02), never
4//! both; a node with neither can still be read through the config API.
5//!
6//! The `ssh` block (SF-10) is parsed now so strict parsing does not reject
7//! it when Phase 22 starts reading it. Credentials never live here:
8//! `profile` names an `ign` profile (its secret chain holds the token) and
9//! `key_env` names an environment variable, never a key.
10
11use serde::Deserialize;
12
13use super::component_problem;
14use super::exclude::list_problems;
15use super::yamlio::{SchemaVersion, strict_parse};
16use crate::error::{CoreError, DeployFinding};
17
18/// The parsed `targets/<env>.yaml`.
19#[derive(Debug, Clone, PartialEq, Deserialize)]
20#[serde(deny_unknown_fields)]
21pub struct Target {
22    /// Always 1; any other value is refused by [`SchemaVersion`].
23    pub schema_version: SchemaVersion,
24    /// Gateway directory name under `gateways/` (render, Phase 21). A single
25    /// safe path component.
26    pub gateway: String,
27    /// Deployment mode directory name under `modes/` (render, Phase 21). A
28    /// single safe path component.
29    pub mode: String,
30    /// The nodes this environment deploys to.
31    pub nodes: Nodes,
32    /// Tag provider directory names no deploy verb lists, reads, writes or
33    /// deletes in this environment (gateway-owned providers such as
34    /// `MQTT Engine`). Optional. Unioned with the manifest's list and the
35    /// built-in `System` default, and matched case-insensitively; see
36    /// [`super::exclude`]. This is what makes a bootstrap pull skip them,
37    /// before any manifest exists.
38    #[serde(default)]
39    pub exclude_tag_providers: Vec<String>,
40}
41
42/// The nodes of one environment.
43#[derive(Debug, Clone, PartialEq, Deserialize)]
44#[serde(deny_unknown_fields)]
45pub struct Nodes {
46    /// The master (or only) gateway node.
47    pub master: NodeTarget,
48    /// The redundant backup node, when the environment has one.
49    #[serde(default)]
50    pub backup: Option<NodeTarget>,
51}
52
53/// One gateway node (consumed by Phases 22 and 25).
54#[derive(Debug, Clone, PartialEq, Deserialize)]
55#[serde(deny_unknown_fields)]
56pub struct NodeTarget {
57    /// An `ign` profile NAME. The credential stays in the profile's secret
58    /// chain.
59    pub profile: String,
60    /// SSH access for host-level steps (SF-10, decided in Phase 22).
61    #[serde(default)]
62    pub ssh: Option<Ssh>,
63    /// The gateway data directory on the runner's own filesystem (D-02).
64    /// Mutually exclusive with `ssh`; selects the local-filesystem transport.
65    #[serde(default)]
66    pub local: Option<Local>,
67    /// The gateway's system name, when the target should pin it (23-07). The
68    /// `wrong_target` preflight refuses an apply whose REST gateway name is
69    /// not this value. Optional: without it the REST name is still bound to
70    /// the host's `systemName`.
71    #[serde(default)]
72    pub gateway_name: Option<String>,
73}
74
75/// Local-filesystem access to a node's gateway data directory (D-02).
76#[derive(Debug, Clone, PartialEq, Deserialize)]
77#[serde(deny_unknown_fields)]
78pub struct Local {
79    /// The gateway data directory on the runner's own filesystem; a relative
80    /// value resolves against `--repo`.
81    pub data_dir: String,
82}
83
84/// SSH access to a node's host (SF-10).
85#[derive(Debug, Clone, PartialEq, Deserialize)]
86#[serde(deny_unknown_fields)]
87pub struct Ssh {
88    /// Host name or address.
89    pub host: String,
90    /// TCP port; the SSH default applies when absent.
91    #[serde(default)]
92    pub port: Option<u16>,
93    /// Login user.
94    pub user: String,
95    /// Path to a private key file. Mutually exclusive with `key_env`.
96    #[serde(default)]
97    pub key_file: Option<String>,
98    /// Name of an environment variable holding a key. A NAME, never a key.
99    /// Mutually exclusive with `key_file`; neither means agent auth.
100    #[serde(default)]
101    pub key_env: Option<String>,
102    /// Host operating system.
103    pub os: TargetOs,
104    /// The gateway's data directory on that host.
105    pub data_dir: String,
106    /// Repo-relative path of the reviewed `known_hosts` file that pins this
107    /// host's key (D-01). Required: `ign` passes a copy of it as
108    /// `UserKnownHostsFile` with `StrictHostKeyChecking=yes` and never reads
109    /// the runner's own `~/.ssh/known_hosts`.
110    pub known_hosts: String,
111    /// Seconds ssh may take to connect (`ConnectTimeout`); 1..=120, default
112    /// [`DEFAULT_CONNECT_TIMEOUT_S`] (D-04).
113    #[serde(default)]
114    pub connect_timeout_s: Option<u32>,
115    /// Seconds one ssh or sftp invocation may run before it is killed;
116    /// 1..=3600, default [`DEFAULT_OP_TIMEOUT_S`] (D-04).
117    #[serde(default)]
118    pub op_timeout_s: Option<u32>,
119}
120
121/// Default `ssh.connect_timeout_s`. Kept at 10 by OQ5-4: the largest of 60
122/// measured sftp sessions (runs A and B, 22-LIVE-CAPTURES.md) took 3.0 s.
123pub const DEFAULT_CONNECT_TIMEOUT_S: u32 = 10;
124/// Default `ssh.op_timeout_s`: 1050 s. OQ5-4 (22-LIVE-CAPTURES.md): at least
125/// three times the larger of the read p95 and the commit p95 over both OQ5
126/// runs (348.2 s, a 152-file commit), rounded up to 30 s. It was 120 s, which
127/// a measured 152-file commit (234 to 348 s) and read (155 to 221 s) both
128/// exceeded.
129pub const DEFAULT_OP_TIMEOUT_S: u32 = 1050;
130/// Accepted `ssh.connect_timeout_s` range.
131pub const CONNECT_TIMEOUT_RANGE_S: std::ops::RangeInclusive<u32> = 1..=120;
132/// Accepted `ssh.op_timeout_s` range.
133pub const OP_TIMEOUT_RANGE_S: std::ops::RangeInclusive<u32> = 1..=3600;
134
135/// Host operating system of an SSH node.
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
137#[serde(rename_all = "lowercase")]
138pub enum TargetOs {
139    /// A Windows host.
140    Windows,
141    /// A Linux host.
142    Linux,
143}
144
145/// Whether the target is a redundant pair: it declares `nodes.backup`.
146///
147/// The one routing signal of quick 261102-cef (A2, D1): on a pair, the
148/// `tag-definition` and `tag-type-definition` units are written into `core`
149/// ([`super::layout::route`]), because Ignition 8.3.6 never syncs those types
150/// from a mode collection (debug deploy-tag-redundancy-sync); a standalone
151/// target keeps them in the mode collection. Plan, apply and verify all
152/// derive it with [`Redundancy::of`].
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum Redundancy {
155    /// No `nodes.backup`.
156    Standalone,
157    /// `nodes.backup` is declared.
158    Pair,
159}
160
161impl Redundancy {
162    /// The redundancy `target` declares.
163    pub fn of(target: &Target) -> Self {
164        if target.nodes.backup.is_some() {
165            Redundancy::Pair
166        } else {
167            Redundancy::Standalone
168        }
169    }
170}
171
172impl Target {
173    /// Parse and validate `bytes` (labelled `file` in errors).
174    pub fn parse(file: &str, bytes: &[u8]) -> Result<Target, CoreError> {
175        let target: Target = strict_parse(file, bytes)?;
176        target.validate(file)?;
177        Ok(target)
178    }
179
180    fn validate(&self, file: &str) -> Result<(), CoreError> {
181        let mut findings = Vec::new();
182        let mut push = |message: String| {
183            findings.push(DeployFinding {
184                file: file.to_string(),
185                line: None,
186                column: None,
187                message,
188            });
189        };
190
191        for (field, value) in [("gateway", &self.gateway), ("mode", &self.mode)] {
192            if let Some(why) = component_problem(value) {
193                push(format!("{field} {value:?}: {why}"));
194            }
195        }
196        for problem in list_problems(&self.exclude_tag_providers) {
197            push(problem);
198        }
199        // `core` and `local` are collections the gateway reserves. The write
200        // half refuses them (`mirror_mode_problem`); the read half would take
201        // `core` for the mode layer, or find `local` empty over sftp.
202        if self.mode == "core" || self.mode == "local" {
203            push(format!(
204                "mode {:?}: names a collection the gateway reserves",
205                self.mode
206            ));
207        }
208        // `projects` is not a gateway collection: it is the directory below
209        // `ign-deploy/<gateway>/` that holds the project release records
210        // (V5), so a mode collection of that name would share its path.
211        if self.mode == "projects" {
212            push(format!(
213                "mode {:?}: names a directory this tool reserves for project release records",
214                self.mode
215            ));
216        }
217        let backup = self.nodes.backup.as_ref().map(|n| ("nodes.backup", n));
218        for (label, node) in std::iter::once(("nodes.master", &self.nodes.master)).chain(backup) {
219            if node.profile.is_empty() {
220                push(format!("{label}.profile: must not be empty"));
221            }
222            if node.ssh.is_some() && node.local.is_some() {
223                push(format!("{label}: set ssh or local, not both"));
224            }
225            if let Some(local) = &node.local
226                && local.data_dir.is_empty()
227            {
228                push(format!("{label}.local.data_dir: must not be empty"));
229            }
230            if let Some(name) = &node.gateway_name {
231                if name.trim().is_empty() {
232                    push(format!("{label}.gateway_name: must not be empty"));
233                } else if name.chars().any(char::is_control) {
234                    push(format!(
235                        "{label}.gateway_name: must not contain control characters"
236                    ));
237                }
238            }
239            let Some(ssh) = &node.ssh else { continue };
240            for (field, value) in [
241                ("host", &ssh.host),
242                ("user", &ssh.user),
243                ("data_dir", &ssh.data_dir),
244            ] {
245                if value.is_empty() {
246                    push(format!("{label}.ssh.{field}: must not be empty"));
247                }
248            }
249            if ssh.key_file.is_some() && ssh.key_env.is_some() {
250                push(format!(
251                    "{label}.ssh: set key_file or key_env, not both (neither means agent auth)"
252                ));
253            }
254            // host and user become ssh argv: a leading `-` would be read as an
255            // option (`-oProxyCommand=...`), whitespace and control characters
256            // split or smuggle arguments, and `@` in user or host moves the
257            // user@host boundary (T-22-17).
258            for (field, value) in [("host", &ssh.host), ("user", &ssh.user)] {
259                if value.is_empty() {
260                    continue;
261                }
262                if value.starts_with('-') {
263                    push(format!(
264                        "{label}.ssh.{field}: must not start with `-` (it would be read as an ssh option)"
265                    ));
266                }
267                if value.chars().any(|c| c.is_whitespace() || c.is_control()) {
268                    push(format!(
269                        "{label}.ssh.{field}: must not contain whitespace or control characters"
270                    ));
271                }
272                if value.contains('@') {
273                    push(format!("{label}.ssh.{field}: must not contain `@`"));
274                }
275            }
276            if let Some(problem) = known_hosts_problem(&ssh.known_hosts) {
277                push(format!("{label}.ssh.known_hosts: {problem}"));
278            }
279            if let Some(n) = ssh.connect_timeout_s
280                && !CONNECT_TIMEOUT_RANGE_S.contains(&n)
281            {
282                push(format!(
283                    "{label}.ssh.connect_timeout_s: {n} is outside {}..={} seconds",
284                    CONNECT_TIMEOUT_RANGE_S.start(),
285                    CONNECT_TIMEOUT_RANGE_S.end()
286                ));
287            }
288            if let Some(n) = ssh.op_timeout_s
289                && !OP_TIMEOUT_RANGE_S.contains(&n)
290            {
291                push(format!(
292                    "{label}.ssh.op_timeout_s: {n} is outside {}..={} seconds",
293                    OP_TIMEOUT_RANGE_S.start(),
294                    OP_TIMEOUT_RANGE_S.end()
295                ));
296            }
297        }
298
299        // The two nodes of a pair are two gateways (WR-05). Nothing else
300        // stops a copy-paste target from naming one gateway twice, and then
301        // `deploy verify --node backup` reads the master as the backup and
302        // can report a converged pair. The profile's URL is only known once
303        // the profile resolves, after this check; the profile name and the
304        // location of the data directory are what a target file states.
305        if let Some(backup) = &self.nodes.backup {
306            let master = &self.nodes.master;
307            if !backup.profile.is_empty() && backup.profile == master.profile {
308                push(
309                    "nodes.backup.profile: names the same profile as nodes.master.profile"
310                        .to_string(),
311                );
312            }
313            // Only a node with exactly one transport is compared; a node that
314            // sets both is already refused above.
315            match (&master.local, &master.ssh, &backup.local, &backup.ssh) {
316                (Some(m), None, Some(b), None)
317                    if !m.data_dir.is_empty() && m.data_dir == b.data_dir =>
318                {
319                    push(
320                        "nodes.backup.local.data_dir: is the same data directory as nodes.master.local.data_dir"
321                            .to_string(),
322                    );
323                }
324                (None, Some(m), None, Some(b))
325                    if !m.host.is_empty()
326                        && m.host == b.host
327                        && m.port.unwrap_or(22) == b.port.unwrap_or(22)
328                        && m.data_dir == b.data_dir =>
329                {
330                    push(
331                        "nodes.backup.ssh: is the same host, port and data_dir as nodes.master.ssh"
332                            .to_string(),
333                    );
334                }
335                _ => {}
336            }
337        }
338
339        if findings.is_empty() {
340            Ok(())
341        } else {
342            Err(CoreError::deploy_findings(findings))
343        }
344    }
345}
346
347/// Why `path` is not a usable repo-relative `known_hosts` path, or `None`.
348///
349/// It must be relative (no leading `/`, no drive prefix, no backslash) and
350/// every `/` component must pass [`component_problem`], so `..`, an empty
351/// component and a dot-leading name are all refused.
352fn known_hosts_problem(path: &str) -> Option<String> {
353    if path.is_empty() {
354        return Some("must not be empty".to_string());
355    }
356    if path.starts_with('/') {
357        return Some("must be relative to the repo root, not absolute".to_string());
358    }
359    if path.contains('\\') {
360        return Some("must use `/` separators, not `\\`".to_string());
361    }
362    if path.as_bytes().get(1) == Some(&b':') {
363        return Some("must be relative to the repo root, not a drive path".to_string());
364    }
365    for part in path.split('/') {
366        if let Some(why) = component_problem(part) {
367            return Some(format!("component {part:?} {why}"));
368        }
369    }
370    None
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    fn findings(e: CoreError) -> Vec<DeployFinding> {
378        match e {
379            CoreError::DeployTreeInvalid(d) => d.findings,
380            other => panic!("expected deploy_tree_invalid, got {other}"),
381        }
382    }
383
384    #[test]
385    fn full_target_parses() {
386        let yaml = "schema_version: 1\ngateway: plant-a\nmode: prod\nnodes:\n  master:\n    profile: plant-a-master\n    ssh:\n      host: 10.0.0.5\n      port: 2222\n      user: svc\n      key_file: /keys/id_ed25519\n      os: windows\n      data_dir: 'C:\\Program Files\\Inductive Automation\\Ignition\\data'\n      known_hosts: ssh/plant-a.known_hosts\n  backup:\n    profile: plant-a-backup\n    ssh:\n      host: 10.0.0.6\n      user: svc\n      key_env: PLANT_A_KEY\n      os: linux\n      data_dir: /usr/local/bin/ignition/data\n      known_hosts: ssh/plant-a.known_hosts\n";
387        let t = Target::parse("targets/prod.yaml", yaml.as_bytes()).expect("parses");
388        assert_eq!((t.gateway.as_str(), t.mode.as_str()), ("plant-a", "prod"));
389        let master = t.nodes.master.ssh.expect("master ssh");
390        assert_eq!(master.port, Some(2222));
391        assert_eq!(master.os, TargetOs::Windows);
392        assert_eq!(master.key_file.as_deref(), Some("/keys/id_ed25519"));
393        assert!(master.data_dir.starts_with("C:\\"));
394        let backup = t.nodes.backup.expect("backup");
395        assert_eq!(backup.profile, "plant-a-backup");
396        let bssh = backup.ssh.expect("backup ssh");
397        assert_eq!((bssh.os, bssh.port), (TargetOs::Linux, None));
398        assert_eq!(bssh.key_env.as_deref(), Some("PLANT_A_KEY"));
399    }
400
401    #[test]
402    fn reserved_collection_modes_are_refused() {
403        // WR-02: `core` and `local` are collections the gateway reserves; a
404        // plan, pull or verify of them would read the wrong collection.
405        // `projects` is the directory `deploy projects` keeps its release
406        // records in (`ign-deploy/<gateway>/projects/`), so a mode collection
407        // of that name would share the path (T-25-30).
408        for mode in ["core", "local", "projects"] {
409            let yaml = format!(
410                "schema_version: 1\ngateway: g\nmode: {mode}\nnodes:\n  master:\n    profile: p\n"
411            );
412            let all: Vec<String> =
413                findings(Target::parse("targets/x.yaml", yaml.as_bytes()).expect_err(mode))
414                    .into_iter()
415                    .map(|f| f.message)
416                    .collect();
417            assert_eq!(all.len(), 1, "{all:?}");
418            assert!(
419                all[0].contains(mode) && all[0].contains("reserves"),
420                "{all:?}"
421            );
422        }
423    }
424
425    #[test]
426    fn minimal_target_parses() {
427        let t = Target::parse(
428            "targets/dev.yaml",
429            b"schema_version: 1\ngateway: g\nmode: dev\nnodes:\n  master:\n    profile: dev\n",
430        )
431        .expect("parses");
432        assert_eq!(t.nodes.backup, None);
433        assert_eq!(t.nodes.master.ssh, None);
434        assert_eq!(t.nodes.master.local, None);
435    }
436
437    #[test]
438    fn local_node_parses() {
439        let yaml = "schema_version: 1\ngateway: g\nmode: dev\nnodes:\n  master:\n    profile: dev\n    local:\n      data_dir: /var/ignition/data\n";
440        let t = Target::parse("targets/dev.yaml", yaml.as_bytes()).expect("parses");
441        let local = t.nodes.master.local.expect("local");
442        assert_eq!(local.data_dir, "/var/ignition/data");
443    }
444
445    #[test]
446    fn ssh_and_local_together_are_refused() {
447        let yaml = "schema_version: 1\ngateway: g\nmode: m\nnodes:\n  master:\n    profile: p\n    local:\n      data_dir: /d\n  backup:\n    profile: q\n    local:\n      data_dir: /d\n    ssh:\n      host: h\n      user: u\n      os: linux\n      data_dir: /d\n      known_hosts: kh\n";
448        let all: Vec<String> =
449            findings(Target::parse("targets/x.yaml", yaml.as_bytes()).expect_err("both"))
450                .into_iter()
451                .map(|f| f.message)
452                .collect();
453        assert_eq!(all, vec!["nodes.backup: set ssh or local, not both"]);
454    }
455
456    fn messages(yaml: &str) -> Vec<String> {
457        findings(Target::parse("targets/x.yaml", yaml.as_bytes()).expect_err("refused"))
458            .into_iter()
459            .map(|f| f.message)
460            .collect()
461    }
462
463    #[test]
464    fn backup_profile_equal_to_master_profile_is_refused() {
465        // WR-05: `deploy verify --node backup` would read the master as the
466        // backup and could report a converged pair.
467        let yaml = "schema_version: 1\ngateway: g\nmode: m\nnodes:\n  master:\n    profile: prod\n  backup:\n    profile: prod\n";
468        assert_eq!(
469            messages(yaml),
470            vec!["nodes.backup.profile: names the same profile as nodes.master.profile"]
471        );
472        // two different profiles are fine, and so is a master alone
473        let ok = "schema_version: 1\ngateway: g\nmode: m\nnodes:\n  master:\n    profile: prod-a\n  backup:\n    profile: prod-b\n";
474        Target::parse("targets/x.yaml", ok.as_bytes()).expect("distinct profiles parse");
475    }
476
477    #[test]
478    fn backup_on_the_masters_data_dir_is_refused() {
479        let local = "schema_version: 1\ngateway: g\nmode: m\nnodes:\n  master:\n    profile: a\n    local:\n      data_dir: /d\n  backup:\n    profile: b\n    local:\n      data_dir: /d\n";
480        assert_eq!(
481            messages(local),
482            vec![
483                "nodes.backup.local.data_dir: is the same data directory as nodes.master.local.data_dir"
484            ]
485        );
486        let ssh = |backup_host: &str, backup_port: &str, backup_dir: &str| {
487            format!(
488                "schema_version: 1\ngateway: g\nmode: m\nnodes:\n  master:\n    profile: a\n    ssh:\n      host: h1\n      user: u\n      os: linux\n      data_dir: /d\n      known_hosts: kh\n  backup:\n    profile: b\n    ssh:\n      host: {backup_host}\n      {backup_port}\n      user: u\n      os: linux\n      data_dir: {backup_dir}\n      known_hosts: kh\n"
489            )
490        };
491        assert_eq!(
492            messages(&ssh("h1", "port: 22", "/d")),
493            vec!["nodes.backup.ssh: is the same host, port and data_dir as nodes.master.ssh"]
494        );
495        // another host, or another port, is another gateway
496        Target::parse("targets/x.yaml", ssh("h2", "port: 22", "/d").as_bytes())
497            .expect("other host");
498        Target::parse("targets/x.yaml", ssh("h1", "port: 2222", "/d").as_bytes())
499            .expect("other port");
500        // different data dirs on the same host are a legitimate layout
501        Target::parse("targets/x.yaml", ssh("h1", "port: 22", "/d2").as_bytes())
502            .expect("other data dir");
503    }
504
505    #[test]
506    fn empty_local_data_dir_is_refused() {
507        let yaml = "schema_version: 1\ngateway: g\nmode: m\nnodes:\n  master:\n    profile: p\n    local:\n      data_dir: ''\n";
508        let all: Vec<String> =
509            findings(Target::parse("targets/x.yaml", yaml.as_bytes()).expect_err("empty"))
510                .into_iter()
511                .map(|f| f.message)
512                .collect();
513        assert_eq!(all, vec!["nodes.master.local.data_dir: must not be empty"]);
514    }
515
516    #[test]
517    fn target_unknown_ssh_field_is_positioned() {
518        let yaml = "schema_version: 1\ngateway: g\nmode: m\nnodes:\n  master:\n    profile: p\n    ssh:\n      host: h\n      user: u\n      os: linux\n      data_dir: /d\n      bogus: 1\n      known_hosts: kh\n";
519        let f = findings(Target::parse("targets/x.yaml", yaml.as_bytes()).expect_err("unknown"))
520            .remove(0);
521        assert_eq!((f.line, f.column), (Some(12), Some(7)));
522        assert!(f.message.contains("bogus"), "{}", f.message);
523    }
524
525    #[test]
526    fn target_os_rejects_unknown() {
527        let yaml = "schema_version: 1\ngateway: g\nmode: m\nnodes:\n  master:\n    profile: p\n    ssh:\n      host: h\n      user: u\n      os: macos\n      data_dir: /d\n      known_hosts: kh\n";
528        let f =
529            findings(Target::parse("targets/x.yaml", yaml.as_bytes()).expect_err("os")).remove(0);
530        assert_eq!(f.line, Some(10));
531        assert!(f.column.is_some());
532        assert!(f.message.contains("unknown variant"), "{}", f.message);
533        assert!(!f.message.contains("macos"), "{}", f.message);
534        assert!(
535            f.message.contains("expected `windows` or `linux`"),
536            "{}",
537            f.message
538        );
539    }
540
541    #[test]
542    fn target_semantic_findings() {
543        let yaml = "schema_version: 1\ngateway: ../x\nmode: .hidden\nnodes:\n  master:\n    profile: ''\n    ssh:\n      host: h\n      user: u\n      key_file: /k\n      key_env: K\n      os: linux\n      data_dir: ''\n      known_hosts: kh\n";
544        let found =
545            findings(Target::parse("targets/x.yaml", yaml.as_bytes()).expect_err("invalid"));
546        let all: Vec<&str> = found.iter().map(|f| f.message.as_str()).collect();
547        let has = |needle: &str| all.iter().any(|m| m.contains(needle));
548        assert!(has("gateway") && has("path separator"), "{all:?}");
549        assert!(has("mode") && has("start with `.`"), "{all:?}");
550        assert!(has("nodes.master.profile"), "{all:?}");
551        assert!(has("key_file or key_env"), "{all:?}");
552        assert!(has("nodes.master.ssh.data_dir"), "{all:?}");
553    }
554
555    #[test]
556    fn gateway_name_is_optional_and_parses_on_either_node() {
557        let none = Target::parse(
558            "targets/dev.yaml",
559            b"schema_version: 1\ngateway: g\nmode: dev\nnodes:\n  master:\n    profile: dev\n",
560        )
561        .expect("parses");
562        assert_eq!(none.nodes.master.gateway_name, None);
563
564        let yaml = "schema_version: 1\ngateway: g\nmode: dev\nnodes:\n  master:\n    profile: dev\n    gateway_name: plant-a\n  backup:\n    profile: dev2\n    gateway_name: 'plant a backup'\n";
565        let t = Target::parse("targets/dev.yaml", yaml.as_bytes()).expect("parses");
566        assert_eq!(t.nodes.master.gateway_name.as_deref(), Some("plant-a"));
567        assert_eq!(
568            t.nodes.backup.expect("backup").gateway_name.as_deref(),
569            Some("plant a backup")
570        );
571    }
572
573    #[test]
574    fn gateway_name_empty_or_with_control_characters_is_refused() {
575        for (value, label) in [
576            ("''", "nodes.master.gateway_name"),
577            ("'   '", "nodes.master.gateway_name"),
578            ("\"a\\nb\"", "nodes.master.gateway_name"),
579            ("\"a\\u001bb\"", "nodes.master.gateway_name"),
580        ] {
581            let yaml = format!(
582                "schema_version: 1\ngateway: g\nmode: dev\nnodes:\n  master:\n    profile: dev\n    gateway_name: {value}\n"
583            );
584            let all = messages(&yaml);
585            assert!(
586                all.iter().any(|m| m.starts_with(&format!("{label}:"))),
587                "{value}: {all:?}"
588            );
589        }
590        let yaml = "schema_version: 1\ngateway: g\nmode: dev\nnodes:\n  master:\n    profile: dev\n  backup:\n    profile: b\n    gateway_name: ''\n";
591        assert!(
592            messages(yaml)
593                .iter()
594                .any(|m| m.starts_with("nodes.backup.gateway_name:"))
595        );
596    }
597
598    fn ssh_yaml(extra: &str) -> String {
599        format!(
600            "schema_version: 1\ngateway: g\nmode: m\nnodes:\n  master:\n    profile: p\n    ssh:\n      host: h\n      user: u\n      os: linux\n      data_dir: /d\n{extra}"
601        )
602    }
603
604    #[test]
605    fn target_ssh_fields() {
606        // known_hosts is required (strict parse error, positioned)
607        let f = findings(
608            Target::parse("targets/x.yaml", ssh_yaml("").as_bytes()).expect_err("missing"),
609        )
610        .remove(0);
611        assert!(f.message.contains("known_hosts"), "{}", f.message);
612
613        // timeouts default when absent and parse when present
614        let t = Target::parse(
615            "targets/x.yaml",
616            ssh_yaml("      known_hosts: ssh/kh\n").as_bytes(),
617        )
618        .expect("parses");
619        let ssh = t.nodes.master.ssh.expect("ssh");
620        assert_eq!((ssh.connect_timeout_s, ssh.op_timeout_s), (None, None));
621        assert_eq!(DEFAULT_CONNECT_TIMEOUT_S, 10);
622        assert_eq!(DEFAULT_OP_TIMEOUT_S, 1050);
623        assert!(OP_TIMEOUT_RANGE_S.contains(&DEFAULT_OP_TIMEOUT_S));
624        let t = Target::parse(
625            "targets/x.yaml",
626            ssh_yaml(
627                "      known_hosts: ssh/kh\n      connect_timeout_s: 120\n      op_timeout_s: 1\n",
628            )
629            .as_bytes(),
630        )
631        .expect("edges parse");
632        let ssh = t.nodes.master.ssh.expect("ssh");
633        assert_eq!(
634            (ssh.connect_timeout_s, ssh.op_timeout_s),
635            (Some(120), Some(1))
636        );
637
638        // known_hosts shapes
639        for bad in [
640            "/etc/ssh/kh",
641            "C:/kh",
642            "a\\\\b",
643            "../kh",
644            "a/../kh",
645            "a//kh",
646            ".hidden/kh",
647            "dir/.kh",
648            "''",
649        ] {
650            let all = messages(&ssh_yaml(&format!("      known_hosts: {bad}\n")));
651            assert!(
652                all.iter()
653                    .any(|m| m.starts_with("nodes.master.ssh.known_hosts:")),
654                "{bad}: {all:?}"
655            );
656        }
657
658        // timeouts out of range name the field
659        for (field, value) in [
660            ("connect_timeout_s", 0),
661            ("connect_timeout_s", 121),
662            ("op_timeout_s", 0),
663            ("op_timeout_s", 3601),
664        ] {
665            let all = messages(&ssh_yaml(&format!(
666                "      known_hosts: kh\n      {field}: {value}\n"
667            )));
668            assert!(
669                all.iter()
670                    .any(|m| m.starts_with(&format!("nodes.master.ssh.{field}:"))),
671                "{field}={value}: {all:?}"
672            );
673        }
674    }
675
676    #[test]
677    fn ssh_host_and_user_cannot_be_read_as_options() {
678        let cases = [
679            ("host", "-oProxyCommand=x"),
680            ("user", "-l"),
681            ("user", "a@b"),
682            ("host", "'a b'"),
683            ("host", "\"a\\tb\""),
684            ("user", "'a b'"),
685            ("host", "a@b"),
686        ];
687        for (field, value) in cases {
688            let other = if field == "host" {
689                "user: u"
690            } else {
691                "host: h"
692            };
693            let yaml = format!(
694                "schema_version: 1\ngateway: g\nmode: m\nnodes:\n  master:\n    profile: p\n    ssh:\n      {field}: {value}\n      {other}\n      os: linux\n      data_dir: /d\n      known_hosts: kh\n"
695            );
696            let all = messages(&yaml);
697            assert!(
698                all.iter()
699                    .any(|m| m.starts_with(&format!("nodes.master.ssh.{field}:"))),
700                "{field}={value}: {all:?}"
701            );
702        }
703    }
704
705    /// The bad `exclude_tag_providers` entries of P1 and P2, each with the
706    /// index the finding names and a word of its reason.
707    const BAD_EXCLUDE_LISTS: [(&str, usize, &str); 6] = [
708        ("[\"\"]", 0, "empty"),
709        ("[\"   \"]", 0, "blank"),
710        ("[\" MQTT Engine\"]", 0, "whitespace"),
711        ("[\"a/b\"]", 0, "separator"),
712        ("[\".x\"]", 0, "`.`"),
713        ("[\"MQTT Engine\", \"mqtt engine\"]", 1, "duplicate"),
714    ];
715
716    #[test]
717    fn exclude_tag_providers_is_optional_and_parses() {
718        let base =
719            "schema_version: 1\ngateway: g\nmode: dev\nnodes:\n  master:\n    profile: dev\n";
720        let t = Target::parse("targets/dev.yaml", base.as_bytes()).expect("absent");
721        assert!(t.exclude_tag_providers.is_empty());
722        let yaml = format!("{base}exclude_tag_providers: [\"MQTT Engine\"]\n");
723        let t = Target::parse("targets/dev.yaml", yaml.as_bytes()).expect("listed");
724        assert_eq!(t.exclude_tag_providers, ["MQTT Engine"]);
725    }
726
727    #[test]
728    fn exclude_tag_providers_bad_entries_are_refused() {
729        for (list, index, why) in BAD_EXCLUDE_LISTS {
730            let yaml = format!(
731                "schema_version: 1\ngateway: g\nmode: dev\nnodes:\n  master:\n    profile: dev\nexclude_tag_providers: {list}\n"
732            );
733            let all = messages(&yaml);
734            assert_eq!(all.len(), 1, "{list}: {all:?}");
735            assert!(
736                all[0].starts_with(&format!("exclude_tag_providers[{index}] "))
737                    && all[0].contains(why),
738                "{list}: {all:?}"
739            );
740        }
741    }
742
743    #[test]
744    fn exclude_tag_provider_misspelled_key_is_refused() {
745        let yaml = "schema_version: 1\ngateway: g\nmode: dev\nnodes:\n  master:\n    profile: dev\nexclude_tag_provider: [x]\n";
746        let f = findings(Target::parse("targets/x.yaml", yaml.as_bytes()).expect_err("unknown"))
747            .remove(0);
748        assert_eq!(f.line, Some(7));
749        assert!(f.message.contains("exclude_tag_provider"), "{}", f.message);
750    }
751}