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