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