Skip to main content

ignition_core/deploy/transport/
mod.rs

1//! How a verb reads a gateway node's resource collections (Phase 22).
2//!
3//! A transport reaches the gateway HOST's `data/config/resources/<collection>/`
4//! directories. This module defines the **read half** the verbs receive:
5//! [`TreeReader`]. The write half (a mirror driven by an explicit delete set,
6//! [`TreeMirror`]) is a separate trait that is never reachable from a verb,
7//! so "plan, pull and verify never write to the host" is a compile-time
8//! fact for `actions::deploy`, not a convention.
9//!
10//! Two properties every reader keeps:
11//!
12//! - **A failed read is never an empty read.** Only an absent collection
13//!   directory reads as empty. A missing or unreadable `data_dir`, a symlink,
14//!   a special file, a non-UTF-8 name, a file over [`READ_MAX_FILE_BYTES`],
15//!   more than [`READ_MAX_ENTRIES`] entries or more than
16//!   [`READ_MAX_TOTAL_BYTES`] is [`CoreError::TransportFailed`]. A plan that
17//!   mistook an unreadable tree for deletions would be worse than no plan.
18//! - **Scoped.** Only the directories the manifest allowlists are opened
19//!   (see [`Scope`]); nothing outside them is listed or read.
20//!
21//! The write half is [`TreeMirror`]; [`mirror_for_node`] is the only way to
22//! obtain one, and a source-scan test (`write_half_unreachable`) pins that no
23//! verb or surface names it.
24//!
25//! [`for_node`] chooses the reader for a target node: `local: { data_dir }`
26//! selects [`local::LocalFsTransport`] (D-02); `ssh` selects
27//! [`sftp::SftpTransport`] over the hardened [`remote::RemoteShell`], for a
28//! Linux host with a POSIX `data_dir` and for a Windows OpenSSH host with a
29//! drive-letter `data_dir` ([`sftp::windows_sftp_root`], 22-13). A Windows read
30//! is cross-checked against a PowerShell enumeration ([`winps`]) before it is
31//! reported complete.
32
33pub mod batch;
34pub mod local;
35mod local_fs;
36pub mod process;
37pub mod projects;
38pub mod remote;
39pub mod sftp;
40pub mod winps;
41
42use std::collections::{BTreeMap, BTreeSet};
43use std::path::Path;
44use std::sync::Arc;
45
46use serde::Serialize;
47
48use super::apply::host::ApplyHost;
49use super::layout::{RelPath, TAG_TYPES};
50use super::manifest::Manifest;
51use super::rules::GATEWAY_OWNED_FILES;
52use super::target::{NodeTarget, TargetOs};
53use super::tree::RenderedTree;
54use crate::error::CoreError;
55
56/// The literal name of the base resource collection on a gateway.
57pub const CORE_COLLECTION: &str = "core";
58
59/// A single file larger than this is refused (64 MiB).
60pub const READ_MAX_FILE_BYTES: u64 = 67_108_864;
61/// All files read in one call together may not exceed this (1 GiB).
62pub const READ_MAX_TOTAL_BYTES: u64 = 1_073_741_824;
63/// More directory entries than this in one read is refused.
64pub const READ_MAX_ENTRIES: usize = 200_000;
65
66/// One live file: its bytes and their lowercase hex SHA-256.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct FileBlob {
69    /// Raw file bytes.
70    pub bytes: Vec<u8>,
71    /// Lowercase hex SHA-256 of `bytes`.
72    pub sha256: String,
73}
74
75/// What a read covers.
76///
77/// Every `types` entry is a `<module>/<type>` directory read whole. Every
78/// `tag_providers` entry `P` adds the four [`TAG_TYPES`] directories under
79/// `ignition`, each restricted to its `<P>/` subtree. No production scope
80/// reads a tag type whole: [`Scope::from_manifest`] keeps the tag types out
81/// of `types`, and pull's provider discovery lists provider directory names
82/// with [`TreeReader::list_tag_providers`] instead of reading
83/// `ignition/tag-provider`. An excluded tag provider
84/// ([`super::exclude`]) is therefore never listed, read or downloaded.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct Scope {
87    /// The target's deployment mode: the name of the mode collection.
88    pub mode: String,
89    /// `(module, type)` directories read whole.
90    pub types: Vec<(String, String)>,
91    /// Tag provider directory names.
92    pub tag_providers: Vec<String>,
93}
94
95impl Scope {
96    /// The scope of a manifest for `mode`: the manifest's non-tag types and
97    /// its tag providers minus every provider of `excluded` (the effective
98    /// exclusion set, [`super::exclude::effective`]; matched by ASCII case
99    /// fold). A `types` entry naming one of the four tag types is covered by
100    /// `tag_providers` (restricted to those providers), so it is not read
101    /// whole. Render already refuses an excluded provider that is declared;
102    /// the filter here keeps it out of every read regardless.
103    pub fn from_manifest(manifest: &Manifest, mode: &str, excluded: &[String]) -> Scope {
104        let types = manifest
105            .types
106            .iter()
107            .filter_map(|t| t.split_once('/'))
108            .filter(|(module, ty)| !(*module == "ignition" && TAG_TYPES.contains(ty)))
109            .map(|(module, ty)| (module.to_string(), ty.to_string()))
110            .collect();
111        Scope {
112            mode: mode.to_string(),
113            types,
114            tag_providers: manifest
115                .tag_providers
116                .iter()
117                .filter(|p| !super::exclude::is_excluded(p, excluded))
118                .cloned()
119                .collect(),
120        }
121    }
122}
123
124/// The live files a read found, keyed by collection-relative path
125/// (`<module>/<type>/...`).
126#[derive(Debug, Clone, Default, PartialEq, Eq)]
127pub struct ObservedFiles {
128    /// Files of the `core` collection.
129    pub core: BTreeMap<RelPath, FileBlob>,
130    /// Files of the mode collection (`Scope::mode`).
131    pub mode: BTreeMap<RelPath, FileBlob>,
132    /// Unit keys present in the `local` collection for the same scope. The
133    /// gateway lets `local` override the mode collection; plan warns about
134    /// them. Names only, no bytes.
135    pub local_units: Vec<String>,
136    /// Files of the `core` and mode collections the host holds but cannot
137    /// serve: on a Windows sftp host, a file whose host path reaches
138    /// `LONG_PATH_LIMIT` ([`winps::LONG_PATH_LIMIT`], 240 characters). Seen
139    /// only by the PowerShell enumeration, never downloaded, and never read
140    /// as absent: plan, pull, verify and apply treat the whole unit as
141    /// unreadable. Byte-sorted by `(collection, path)`. Always empty on a
142    /// Linux sftp host and on a local host.
143    pub unreadable: Vec<Unreadable>,
144}
145
146/// One file a read could not deliver (see [`ObservedFiles::unreadable`]).
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct Unreadable {
149    /// `core` or `mode`, the words of a plan entry's `live_in`.
150    pub collection: String,
151    /// Collection-relative path (`<module>/<type>/...`).
152    pub path: RelPath,
153    /// The host path length in characters (without the `\\?\` prefix).
154    pub length: usize,
155}
156
157/// The files plan proposes to delete from the mode collection: the
158/// declared-names ledger's files minus the rendered files (D-05, 22-03).
159/// Collection-relative paths, byte-sorted and unique. Serialized as
160/// `{"files": [...]}`.
161#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
162pub struct DeleteSet {
163    /// The paths, byte-sorted.
164    pub files: Vec<RelPath>,
165}
166
167/// The read half of a transport.
168#[async_trait::async_trait]
169pub trait TreeReader: Send + Sync {
170    /// The `<module>/<type>` directory pairs of `collection`, byte-sorted;
171    /// empty when the collection directory is absent.
172    async fn list_types(&self, collection: &str) -> Result<Vec<(String, String)>, CoreError>;
173
174    /// The tag provider directory names under
175    /// `config/resources/<collection>/ignition/tag-provider/`, byte-sorted;
176    /// empty when the directory is absent. Names only: no file is downloaded
177    /// and nothing below a provider directory is listed, so a provider with
178    /// no file in it is named too. A symlink is refused.
179    async fn list_tag_providers(&self, collection: &str) -> Result<Vec<String>, CoreError>;
180
181    /// Read the `core` and mode collections within `scope`, and list the
182    /// `local` units in scope. Fails closed on any error.
183    async fn read(&self, scope: &Scope) -> Result<ObservedFiles, CoreError>;
184
185    /// The declared-names ledger bytes of `gateway` and `mode` at
186    /// [`ledger_location`](super::ledger::ledger_location) below the node's
187    /// data directory. `None` means the ledger is absent (a first deploy). A
188    /// ledger that exists but cannot be read (a symlink, a non-regular file,
189    /// over [`LEDGER_MAX_BYTES`](super::ledger::LEDGER_MAX_BYTES), a read
190    /// error) is [`CoreError::DeployLedgerInvalid`], never `None` (D-07). The
191    /// bytes are not validated here; see
192    /// [`Ledger::parse`](super::ledger::Ledger::parse).
193    async fn read_ledger(&self, gateway: &str, mode: &str) -> Result<Option<Vec<u8>>, CoreError>;
194
195    /// The apply journal bytes of `gateway` and `mode` at
196    /// [`journal_location`](super::ledger::journal_location), under the same
197    /// root as the ledger. `None` means there is no journal at any level of
198    /// its path: no interrupted run. A journal that exists but cannot be read
199    /// (a symlink, a non-regular file, over
200    /// [`JOURNAL_MAX_BYTES`](super::apply::journal::JOURNAL_MAX_BYTES), a read
201    /// error) is [`CoreError::DeployRunInvalid`] naming the location, never
202    /// `None`: `plan`, `apply` and `rollback` must not proceed over a run
203    /// they cannot see (DAPL-09 fails closed). The bytes are not validated
204    /// here; see [`Journal::parse`](super::apply::journal::Journal::parse).
205    async fn read_journal(&self, gateway: &str, mode: &str) -> Result<Option<Vec<u8>>, CoreError>;
206}
207
208/// How a node's data directory is reached, resolved once for both halves.
209enum NodeAccess {
210    /// A directory on the runner's own filesystem.
211    Local(std::path::PathBuf),
212    /// A host over the hardened ssh shell.
213    Ssh {
214        shell: Box<remote::RemoteShell>,
215        /// The data directory as sftp spells it (`/opt/ignition/data`, or
216        /// `/C:/Program Files/.../data` on Windows).
217        data_dir: String,
218        /// A Windows OpenSSH host.
219        windows: bool,
220    },
221}
222
223/// Resolve `node` to an access path.
224fn node_access(
225    repo: &Path,
226    target_file: &str,
227    label: &str,
228    node: &NodeTarget,
229) -> Result<NodeAccess, CoreError> {
230    match (&node.ssh, &node.local) {
231        (Some(_), Some(_)) => Err(CoreError::deploy_tree_invalid(
232            target_file,
233            None,
234            None,
235            format!("{label}: set ssh or local, not both"),
236        )),
237        (None, Some(local)) => {
238            let configured = Path::new(&local.data_dir);
239            // A relative data_dir is relative to the repo, and `--repo` is
240            // `.` by default. The mirror validates its staging and resources
241            // roots as absolute paths, so the join is absolutized here.
242            let data_dir = if configured.is_absolute() {
243                configured.to_path_buf()
244            } else {
245                std::path::absolute(repo.join(configured)).map_err(|e| {
246                    CoreError::deploy_tree_invalid(
247                        target_file,
248                        None,
249                        None,
250                        format!(
251                            "{label}.local.data_dir {:?}: cannot be made absolute ({:?})",
252                            local.data_dir,
253                            e.kind()
254                        ),
255                    )
256                })?
257            };
258            Ok(NodeAccess::Local(data_dir))
259        }
260        (Some(ssh), None) => {
261            let windows = ssh.os == TargetOs::Windows;
262            let data_dir = if windows {
263                sftp::windows_sftp_root(&ssh.data_dir)
264            } else {
265                sftp::posix_root(&ssh.data_dir)
266            }
267            .map_err(|why| {
268                CoreError::deploy_tree_invalid(
269                    target_file,
270                    None,
271                    None,
272                    format!("{label}.ssh.data_dir: {why}"),
273                )
274            })?;
275            let shell = remote::RemoteShell::from_ssh(
276                repo,
277                target_file,
278                label,
279                ssh,
280                Arc::new(process::TokioProcessRunner),
281            )?;
282            Ok(NodeAccess::Ssh {
283                shell: Box::new(shell),
284                data_dir,
285                windows,
286            })
287        }
288        (None, None) => Err(CoreError::deploy_tree_invalid(
289            target_file,
290            None,
291            None,
292            format!("{label}: set ssh or local; this verb needs host access"),
293        )),
294    }
295}
296
297/// An sftp transport over `shell` for a Linux or a Windows host.
298fn sftp_transport(
299    shell: remote::RemoteShell,
300    data_dir: String,
301    label: &str,
302    windows: bool,
303) -> sftp::SftpTransport {
304    if windows {
305        sftp::SftpTransport::new_windows(shell, data_dir, label)
306    } else {
307        sftp::SftpTransport::new(shell, data_dir, label)
308    }
309}
310
311/// The reader for `node`, from its target block.
312///
313/// `local` builds the local-filesystem transport (a relative `data_dir`
314/// resolves against `repo`). A node with neither `ssh` nor `local` is
315/// refused as `deploy_tree_invalid` naming `label`, because a verb that
316/// reads the host needs host access (D-02).
317pub fn for_node(
318    repo: &Path,
319    target_file: &str,
320    label: &str,
321    node: &NodeTarget,
322) -> Result<Box<dyn TreeReader>, CoreError> {
323    Ok(match node_access(repo, target_file, label, node)? {
324        NodeAccess::Local(data_dir) => Box::new(local::LocalFsTransport::new(data_dir, label)),
325        NodeAccess::Ssh {
326            shell,
327            data_dir,
328            windows,
329        } => Box::new(sftp_transport(*shell, data_dir, label, windows)),
330    })
331}
332
333/// The write half of a transport: stage a rendered tree, then commit it with
334/// an explicit delete set.
335///
336/// **No Phase 22 verb may import this trait.** `render`, `plan`, `pull` and
337/// `verify` receive a [`TreeReader`] from [`for_node`] and cannot reach it;
338/// the `write_half_unreachable` test scans the verb sources for
339/// `TreeMirror`, `mirror_for_node`, `StagingMode`, `.stage(` and `.commit(`.
340/// Phase 23 is the first caller: it calls [`stage`](TreeMirror::stage) before
341/// the scan-lock and [`commit`](TreeMirror::commit) inside it.
342///
343/// What a mirror guarantees, on both transports:
344///
345/// - **Bounded writes.** [`commit`](TreeMirror::commit) writes exactly the
346///   staged files and removes exactly the delete-set files. Nothing else in
347///   the collection is touched (never `config-mode.json`, never an
348///   undeclared resource, no mirror-style removal of extras). A directory
349///   emptied by a delete is removed; the `<module>/<type>` directory and
350///   everything above it never is.
351/// - **Nothing foreign in the collection.** No temp, backup, staging or
352///   dot-named entry is created below `config/resources` (the Designer
353///   rejects such names). Staging happens in a private temp dir and, for
354///   [`StagingMode::Separate`], in a root outside `config/resources`.
355/// - **Fail closed.** Every path is validated before any I/O. The expected
356///   state (delete targets are regular files, write targets are absent or
357///   regular files, no component is a symlink, staged copies are intact) is
358///   checked before the first write; any mismatch is `transport_failed` with
359///   nothing written. Symlinks are never followed and never created.
360/// - **Bounded size.** The staged tree obeys the read side's
361///   [`READ_MAX_FILE_BYTES`], [`READ_MAX_TOTAL_BYTES`] and
362///   [`READ_MAX_ENTRIES`].
363/// - **Atomic where the platform allows.** `Separate` moves each staged file
364///   into place with a rename (a replace on the same filesystem); `InPlace`
365///   writes the target directly and is not atomic per file. A Linux `ssh`
366///   node (23-06, D-01) does both inside ONE call of an uploaded POSIX script:
367///   `Separate` moves the copies staged on the host, and `InPlace` uploads the
368///   copies with the script into a throw-away directory below the account
369///   home and moves them from there, so on that host `InPlace` is a `mv` per
370///   file too. A commit is not atomic across files: a failure part-way leaves
371///   the files already moved in place, which is why Phase 23 wraps it in a
372///   snapshot.
373///
374/// A [`Staged`] belongs to the mirror that made it and is consumed by its
375/// commit in `Separate` mode (the staged files are moved); stage again to
376/// commit again.
377#[async_trait::async_trait]
378pub trait TreeMirror: Send + Sync {
379    /// Copy `tree` into staging. Writes nothing into the collection.
380    async fn stage(&self, tree: &RenderedTree) -> Result<Staged, CoreError>;
381
382    /// Write the staged files into the mode collection and remove
383    /// `delete_set`. Refuses, with nothing written, anything that is not a
384    /// valid, in-bounds, expected-state operation.
385    async fn commit(
386        &self,
387        staged: &Staged,
388        delete_set: &DeleteSet,
389    ) -> Result<MirrorReport, CoreError>;
390}
391
392/// Where staged files wait before they are moved into the collection.
393#[derive(Debug, Clone, PartialEq, Eq)]
394pub enum StagingMode {
395    /// Staged files are uploaded to `root`, a directory OUTSIDE
396    /// `config/resources`, and moved in with a rename at commit.
397    Separate {
398        /// The staging root, as the transport spells paths (a local path, or
399        /// an absolute POSIX path on the host).
400        root: String,
401    },
402    /// Files are written straight into the collection at commit.
403    InPlace,
404}
405
406/// A rendered tree held in staging, ready to commit.
407#[derive(Debug)]
408pub struct Staged {
409    /// Each staged file with the lowercase hex SHA-256 of its bytes.
410    pub files: Vec<(RelPath, String)>,
411    /// A private temp dir holding a copy of every staged file. Commit
412    /// re-reads and re-verifies it through
413    /// [`open_regular_file`](super::tree::open_regular_file).
414    pub(crate) local: tempfile::TempDir,
415    /// The `Separate` staging root the files were copied to, `None` for
416    /// `InPlace`.
417    pub(crate) remote_root: Option<String>,
418    /// Which mirror (node and mode) staged this.
419    pub(crate) owner: String,
420}
421
422/// What a commit did, collection-relative and byte-sorted.
423#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
424pub struct MirrorReport {
425    /// Files written (staged files).
426    pub written: Vec<String>,
427    /// Files removed (the delete set).
428    pub deleted: Vec<String>,
429    /// Directories removed because a delete emptied them.
430    pub removed_dirs: Vec<String>,
431}
432
433/// The write half for `node`'s `mode` collection.
434///
435/// `local` builds the local-filesystem mirror; an `ssh` node (Linux or
436/// Windows) builds the sftp mirror over the same hardened shell as
437/// [`for_node`]. `mode` is the mode collection name; `core` and `local` are
438/// never written.
439pub fn mirror_for_node(
440    repo: &Path,
441    target_file: &str,
442    label: &str,
443    node: &NodeTarget,
444    mode: &str,
445    staging: StagingMode,
446) -> Result<Box<dyn TreeMirror>, CoreError> {
447    let invalid = |detail: String| CoreError::deploy_tree_invalid(target_file, None, None, detail);
448    Ok(match node_access(repo, target_file, label, node)? {
449        NodeAccess::Local(data_dir) => Box::new(
450            local::LocalFsTransport::new(data_dir, label)
451                .with_mirror(mode, staging)
452                .map_err(|why| invalid(format!("{label}: {why}")))?,
453        ),
454        NodeAccess::Ssh {
455            shell,
456            data_dir,
457            windows,
458        } => Box::new(
459            sftp_transport(*shell, data_dir, label, windows)
460                .with_mirror(mode, staging)
461                .map_err(|why| invalid(format!("{label}: {why}")))?,
462        ),
463    })
464}
465
466/// The apply half ([`ApplyHost`](super::apply::host::ApplyHost)) for `node`'s
467/// `mode` collection, for the target `gateway`.
468///
469/// `local` builds the local-filesystem host. An `ssh` node builds the sftp host
470/// that runs every operation as one call of the uploaded script, through a
471/// fixed stub that checks the script's and the work list's SHA-256 first
472/// (D-01): the POSIX `sh` script for a Linux node (23-06), the PowerShell
473/// script for a Windows node (23-08, 23-20). `gateway` and `mode` must be names
474/// a run may write under.
475pub fn apply_host_for_node(
476    repo: &Path,
477    target_file: &str,
478    label: &str,
479    node: &NodeTarget,
480    gateway: &str,
481    mode: &str,
482) -> Result<Box<dyn ApplyHost>, CoreError> {
483    let invalid = |detail: String| CoreError::deploy_tree_invalid(target_file, None, None, detail);
484    if let Some(why) = mirror_mode_problem(mode) {
485        return Err(invalid(format!("{label}: {why}")));
486    }
487    let access = node_access(repo, target_file, label, node)?;
488    Ok(
489        match apply_host_kind(access, label, gateway, mode).map_err(invalid)? {
490            ApplyHostKind::Local(host) => Box::new(host),
491            ApplyHostKind::Sftp(host) => host,
492        },
493    )
494}
495
496/// The concrete host an access path resolves to, before it is boxed.
497enum ApplyHostKind {
498    Local(local::LocalFsTransport),
499    Sftp(Box<sftp::SftpTransport>),
500}
501
502fn apply_host_kind(
503    access: NodeAccess,
504    label: &str,
505    gateway: &str,
506    mode: &str,
507) -> Result<ApplyHostKind, String> {
508    Ok(match access {
509        NodeAccess::Local(data_dir) => ApplyHostKind::Local(
510            local::LocalFsTransport::new(data_dir, label)
511                .with_apply(gateway, mode)
512                .map_err(|why| format!("{label}: {why}"))?,
513        ),
514        NodeAccess::Ssh {
515            shell,
516            data_dir,
517            windows,
518        } => ApplyHostKind::Sftp(Box::new(
519            sftp_transport(*shell, data_dir, label, windows)
520                .with_apply(gateway, mode)
521                .map_err(|why| format!("{label}: {why}"))?,
522        )),
523    })
524}
525
526/// The staging mode Phase 23 uses by default (Decision B(i), D-05): a
527/// [`StagingMode::Separate`] root at `ign-deploy/<gateway>/<mode>/staging/<run_id>`
528/// below the ledger root of the node's transport, outside every collection:
529///
530/// - `ssh` nodes (Linux and Windows): below the service account's home, which
531///   the server reports (`/C:/Users/svc-ignition-deploy/...` on the Windows
532///   capture host). The account can write there and not under the data
533///   directory (C6), and the commit is a rename from the home into the mode
534///   collection on the same volume.
535/// - `local` nodes: below `<data_dir>`.
536///
537/// `gateway` and `mode` are single path components and `run_id` is
538/// `[A-Za-z0-9._-]{1,128}` not starting with a dot, or the call is refused
539/// before any I/O. Only an `ssh` node touches the network (one `pwd`).
540pub async fn default_staging_mode(
541    repo: &Path,
542    target_file: &str,
543    label: &str,
544    node: &NodeTarget,
545    gateway: &str,
546    mode: &str,
547    run_id: &str,
548) -> Result<StagingMode, CoreError> {
549    let invalid = |detail: String| CoreError::deploy_tree_invalid(target_file, None, None, detail);
550    for (what, value) in [("gateway", gateway), ("mode", mode)] {
551        if let Some(why) = super::component_problem(value) {
552            return Err(invalid(format!("{label}: the staging {what} {why}")));
553        }
554    }
555    let run_ok = (1..=128).contains(&run_id.len())
556        && !run_id.starts_with('.')
557        && run_id
558            .bytes()
559            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'));
560    if !run_ok {
561        return Err(invalid(format!(
562            "{label}: the run id must be 1 to 128 characters of [A-Za-z0-9._-] and must not start with a dot"
563        )));
564    }
565    let rel = super::ledger::staging_rel(gateway, mode, run_id);
566    Ok(match node_access(repo, target_file, label, node)? {
567        NodeAccess::Local(data_dir) => {
568            let root = data_dir.join(&rel);
569            let root = root.to_str().ok_or_else(|| {
570                invalid(format!("{label}: the data directory is not valid UTF-8"))
571            })?;
572            StagingMode::Separate {
573                root: root.to_string(),
574            }
575        }
576        NodeAccess::Ssh {
577            shell,
578            data_dir,
579            windows,
580        } => {
581            sftp_transport(*shell, data_dir, label, windows)
582                .default_staging(gateway, mode, run_id)
583                .await?
584        }
585    })
586}
587
588/// A mode name that may be written: a plain path component that is not one of
589/// the collections a mirror must never touch.
590pub(crate) fn mirror_mode_problem(mode: &str) -> Option<String> {
591    if let Some(why) = super::component_problem(mode) {
592        return Some(format!("the mode {why}"));
593    }
594    if mode == CORE_COLLECTION || mode == "local" {
595        return Some(format!(
596            "the mode {mode:?} names a collection a mirror never writes"
597        ));
598    }
599    None
600}
601
602/// Why `path` may not be written or deleted by a mirror, or `None`.
603///
604/// A mirror path is `<module>/<type>/<name>...`: at least three components,
605/// no dot-named component (the Designer rejects them), no gateway-owned file
606/// name and no control character.
607pub(crate) fn mirror_path_problem(path: &RelPath) -> Option<String> {
608    let parts: Vec<&str> = path.components().collect();
609    if parts.len() < MIRROR_MIN_COMPONENTS {
610        return Some(format!(
611            "{path} is not a collection path (<module>/<type>/<name>..., at least {MIRROR_MIN_COMPONENTS} components)"
612        ));
613    }
614    for part in parts {
615        if part.starts_with('.') {
616            return Some(format!("{path} has a dot-named component {part:?}"));
617        }
618        if GATEWAY_OWNED_FILES.contains(&part) {
619            return Some(format!("{path} names the gateway-owned file {part:?}"));
620        }
621        if part.chars().any(char::is_control) {
622            return Some(format!("{path} has a control character in a name"));
623        }
624    }
625    None
626}
627
628/// The shallowest path a mirror touches: `<module>/<type>/<name>`.
629pub(crate) const MIRROR_MIN_COMPONENTS: usize = 3;
630
631/// Refuse a staged tree and delete set before any I/O: every path valid, no
632/// path both staged and deleted, no path a file and a directory of another,
633/// bytes and entries within the read side's caps, and a `Separate` staging
634/// root that is not inside (or around) `resources_root`.
635pub(crate) fn validate_mirror_inputs(
636    tree: &RenderedTree,
637    delete_set: &DeleteSet,
638    staging: &StagingMode,
639    resources_root: &str,
640) -> Result<(), String> {
641    let mut total: u64 = 0;
642    for (path, bytes) in &tree.files {
643        let len = bytes.len() as u64;
644        if len > READ_MAX_FILE_BYTES {
645            return Err(format!(
646                "{path} is {len} bytes, over the {READ_MAX_FILE_BYTES} byte file cap"
647            ));
648        }
649        total = total.saturating_add(len);
650        if total > READ_MAX_TOTAL_BYTES {
651            return Err(format!(
652                "the staged tree exceeds {READ_MAX_TOTAL_BYTES} bytes in total (at {path})"
653            ));
654        }
655    }
656    let staged: Vec<&RelPath> = tree.files.keys().collect();
657    validate_mirror_paths(&staged, delete_set)?;
658    validate_staging_root(staging, resources_root)
659}
660
661/// The path half of [`validate_mirror_inputs`], also run by commit on the
662/// paths of a [`Staged`].
663pub(crate) fn validate_mirror_paths(
664    staged: &[&RelPath],
665    delete_set: &DeleteSet,
666) -> Result<(), String> {
667    for path in staged {
668        if let Some(why) = mirror_path_problem(path) {
669            return Err(format!("staged path refused: {why}"));
670        }
671    }
672    let mut deleting: BTreeSet<&str> = BTreeSet::new();
673    for path in &delete_set.files {
674        if let Some(why) = mirror_path_problem(path) {
675            return Err(format!("delete-set path refused: {why}"));
676        }
677        if !deleting.insert(path.as_str()) {
678            return Err(format!("delete-set path {path} is listed twice"));
679        }
680    }
681    let staging: BTreeSet<&str> = staged.iter().map(|p| p.as_str()).collect();
682    if staging.len() != staged.len() {
683        return Err("a path is staged twice".to_string());
684    }
685    if let Some(both) = deleting.iter().find(|p| staging.contains(**p)) {
686        return Err(format!("{both} is both staged and in the delete set"));
687    }
688    // A path may not be a file and the directory of another path.
689    let mut dirs: BTreeSet<&str> = BTreeSet::new();
690    for path in staging.iter().chain(deleting.iter()) {
691        for (at, _) in path.match_indices('/') {
692            dirs.insert(&path[..at]);
693        }
694    }
695    if let Some(clash) = staging
696        .iter()
697        .chain(deleting.iter())
698        .find(|p| dirs.contains(**p))
699    {
700        return Err(format!(
701            "{clash} is a file and also the directory of another path"
702        ));
703    }
704    if staging.len() + deleting.len() + dirs.len() > READ_MAX_ENTRIES {
705        return Err(format!(
706            "more than {READ_MAX_ENTRIES} entries (files, deletes and directories together)"
707        ));
708    }
709    Ok(())
710}
711
712/// The components of an absolute path spelled with `/` or `\`, or why it is
713/// not usable as a staging root.
714pub(crate) fn absolute_components(text: &str) -> Result<Vec<String>, String> {
715    if text.is_empty() || text.chars().any(char::is_control) {
716        return Err("must be a non-empty path without control characters".to_string());
717    }
718    let normal = text.replace('\\', "/");
719    let bytes = normal.as_bytes();
720    let absolute = normal.starts_with('/')
721        || (bytes.len() >= 3
722            && bytes[1] == b':'
723            && bytes[2] == b'/'
724            && bytes[0].is_ascii_alphabetic());
725    if !absolute {
726        return Err("must be an absolute path".to_string());
727    }
728    let mut parts = Vec::new();
729    for part in normal.split('/') {
730        match part {
731            "" => {}
732            "." | ".." => return Err("must not contain `.` or `..` components".to_string()),
733            other => parts.push(other.to_string()),
734        }
735    }
736    Ok(parts)
737}
738
739/// Refuse a `Separate` staging root that is relative, too shallow, inside
740/// `resources_root` (`config/resources`) or around it. `InPlace` has no root.
741pub(crate) fn validate_staging_root(
742    staging: &StagingMode,
743    resources_root: &str,
744) -> Result<(), String> {
745    validate_staging_root_for(staging, resources_root, cfg!(windows))
746}
747
748/// [`validate_staging_root`] with the comparison's case rule chosen by the
749/// HOST the paths name, not by the runner: a Windows OpenSSH host is
750/// case-insensitive, so `/c:/program files/.../staging` is inside
751/// `/C:/Program Files/.../resources` whatever OS `ign` runs on.
752pub(crate) fn validate_staging_root_for(
753    staging: &StagingMode,
754    resources_root: &str,
755    case_insensitive: bool,
756) -> Result<(), String> {
757    let StagingMode::Separate { root } = staging else {
758        return Ok(());
759    };
760    let staging_parts =
761        absolute_components(root).map_err(|why| format!("the staging root {root:?} {why}"))?;
762    if staging_parts.len() < 2 {
763        return Err(format!(
764            "the staging root {root:?} must be at least two directories deep"
765        ));
766    }
767    let resources_parts = absolute_components(resources_root)
768        .map_err(|why| format!("the resources root {resources_root:?} {why}"))?;
769    let same = |a: &String, b: &String| {
770        if case_insensitive {
771            a.eq_ignore_ascii_case(b)
772        } else {
773            a == b
774        }
775    };
776    let starts_with = |long: &[String], short: &[String]| {
777        long.len() >= short.len() && long.iter().zip(short).all(|(a, b)| same(a, b))
778    };
779    if starts_with(&staging_parts, &resources_parts) {
780        return Err(format!(
781            "the staging root {root:?} is inside config/resources; staging entries there would be scanned by the gateway"
782        ));
783    }
784    if starts_with(&resources_parts, &staging_parts) {
785        return Err(format!(
786            "the staging root {root:?} contains config/resources"
787        ));
788    }
789    Ok(())
790}
791
792/// Copy every file of `tree` into a fresh private temp dir and hash it. The
793/// dir is new and ours, so a plain `create_new` write is safe.
794pub(crate) fn copy_to_temp(
795    tree: &RenderedTree,
796) -> Result<(tempfile::TempDir, Vec<(RelPath, String)>), String> {
797    use std::io::Write;
798    let temp = tempfile::Builder::new()
799        .prefix("ign-stage-")
800        .tempdir()
801        .map_err(|e| format!("cannot create a staging dir ({})", e.kind()))?;
802    let mut files = Vec::with_capacity(tree.files.len());
803    for (path, bytes) in &tree.files {
804        let target = temp.path().join(path.as_str());
805        if let Some(parent) = target.parent() {
806            std::fs::create_dir_all(parent)
807                .map_err(|e| format!("cannot create the staging dir for {path} ({})", e.kind()))?;
808        }
809        let mut file = std::fs::OpenOptions::new()
810            .write(true)
811            .create_new(true)
812            .open(&target)
813            .map_err(|e| format!("cannot stage {path} ({})", e.kind()))?;
814        file.write_all(bytes)
815            .map_err(|e| format!("cannot stage {path} ({})", e.kind()))?;
816        files.push((path.clone(), super::tree::sha256_hex(bytes)));
817    }
818    Ok((temp, files))
819}
820
821/// The bytes of the staged copy `rel` below `base`, read through
822/// [`open_regular_file`](super::tree::open_regular_file) (descriptor-relative,
823/// no symlink followed) and required to be a regular file within the file cap
824/// whose SHA-256 is `expect_sha`.
825pub(crate) fn read_staged_copy(
826    base: &Path,
827    rel: &RelPath,
828    expect_sha: &str,
829) -> Result<Vec<u8>, String> {
830    use super::tree::{OpenError, open_regular_file, sha256_hex};
831    use std::io::Read;
832    let path = base.join(rel.as_str());
833    let before = std::fs::symlink_metadata(&path)
834        .map_err(|e| format!("the staged copy of {rel} is gone ({})", e.kind()))?;
835    if !before.file_type().is_file() {
836        return Err(format!("the staged copy of {rel} is not a regular file"));
837    }
838    let (file, held) =
839        open_regular_file(base, Path::new(rel.as_str()), &before).map_err(|e| match e {
840            OpenError::Changed => format!("the staged copy of {rel} changed while it was read"),
841            OpenError::Io(e) => format!("cannot read the staged copy of {rel} ({})", e.kind()),
842        })?;
843    if held.len() > READ_MAX_FILE_BYTES {
844        return Err(format!(
845            "the staged copy of {rel} is over the {READ_MAX_FILE_BYTES} byte file cap"
846        ));
847    }
848    let mut bytes = Vec::with_capacity(held.len() as usize);
849    file.take(READ_MAX_FILE_BYTES + 1)
850        .read_to_end(&mut bytes)
851        .map_err(|e| format!("cannot read the staged copy of {rel} ({})", e.kind()))?;
852    if bytes.len() as u64 > READ_MAX_FILE_BYTES || sha256_hex(&bytes) != expect_sha {
853        return Err(format!(
854            "the staged copy of {rel} changed after it was staged"
855        ));
856    }
857    Ok(bytes)
858}
859
860#[cfg(test)]
861mod tests {
862    use super::*;
863    use crate::deploy::target::{Ssh, TargetOs};
864
865    fn ssh_node(os: TargetOs, data_dir: &str) -> NodeTarget {
866        NodeTarget {
867            profile: "p".to_string(),
868            ssh: Some(Ssh {
869                host: "gw.example.test".to_string(),
870                port: None,
871                user: "svc".to_string(),
872                key_file: None,
873                key_env: None,
874                os,
875                data_dir: data_dir.to_string(),
876                known_hosts: "ssh/prod.known_hosts".to_string(),
877                connect_timeout_s: None,
878                op_timeout_s: None,
879            }),
880            local: None,
881            gateway_name: None,
882        }
883    }
884
885    fn repo() -> tempfile::TempDir {
886        let repo = tempfile::tempdir().expect("repo");
887        std::fs::create_dir_all(repo.path().join("ssh")).expect("ssh dir");
888        std::fs::write(
889            repo.path().join("ssh/prod.known_hosts"),
890            "gw.example.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPinnedPinnedPinnedPinnedPinnedPinnedPinned\n",
891        )
892        .expect("known_hosts");
893        repo
894    }
895
896    /// A Linux ssh node and a `local` node get their hosts; names a run may not
897    /// write under are refused before any host is built.
898    #[test]
899    fn apply_host_for_node_linux_ssh() {
900        let repo = repo();
901        let linux = ssh_node(TargetOs::Linux, "/usr/local/bin/ignition/data");
902        assert!(
903            apply_host_for_node(
904                repo.path(),
905                "targets/prod.yaml",
906                "nodes.master",
907                &linux,
908                "gw",
909                "prod"
910            )
911            .is_ok(),
912            "a Linux ssh node builds an apply host"
913        );
914        let access =
915            node_access(repo.path(), "targets/prod.yaml", "nodes.master", &linux).expect("access");
916        let ApplyHostKind::Sftp(host) =
917            apply_host_kind(access, "nodes.master", "gw", "prod").expect("host")
918        else {
919            panic!("an ssh node gets the sftp host");
920        };
921        assert!(!host.is_windows_for_tests());
922        let local = NodeTarget {
923            profile: "p".to_string(),
924            ssh: None,
925            local: Some(crate::deploy::target::Local {
926                data_dir: repo.path().join("data").display().to_string(),
927            }),
928            gateway_name: None,
929        };
930        let access =
931            node_access(repo.path(), "targets/prod.yaml", "nodes.master", &local).expect("access");
932        assert!(matches!(
933            apply_host_kind(access, "nodes.master", "gw", "prod").expect("host"),
934            ApplyHostKind::Local(_)
935        ));
936        for (gateway, mode) in [
937            (".hidden", "prod"),
938            ("gw", "core"),
939            ("gw", "local"),
940            ("g/w", "prod"),
941        ] {
942            let err = apply_host_for_node(
943                repo.path(),
944                "targets/prod.yaml",
945                "nodes.master",
946                &linux,
947                gateway,
948                mode,
949            )
950            .err()
951            .unwrap_or_else(|| panic!("{gateway}/{mode} is refused"));
952            assert_eq!(err.code(), "deploy_tree_invalid", "{gateway}/{mode}");
953        }
954    }
955
956    /// 23-20: a Windows ssh node gets the sftp host too, the one that runs the
957    /// PowerShell script. Until the PowerShell host was complete this arm was
958    /// the 23-04 `deploy_tree_invalid` refusal.
959    #[test]
960    fn apply_host_for_node_windows_ssh() {
961        let repo = repo();
962        let windows = ssh_node(TargetOs::Windows, "C:\\Ignition\\data");
963        assert!(
964            apply_host_for_node(
965                repo.path(),
966                "targets/prod.yaml",
967                "nodes.master",
968                &windows,
969                "gw",
970                "prod"
971            )
972            .is_ok(),
973            "a Windows ssh node builds an apply host"
974        );
975        let access = node_access(repo.path(), "targets/prod.yaml", "nodes.master", &windows)
976            .expect("access");
977        let ApplyHostKind::Sftp(host) =
978            apply_host_kind(access, "nodes.master", "gw", "prod").expect("host")
979        else {
980            panic!("a Windows ssh node gets the sftp host");
981        };
982        assert!(host.is_windows_for_tests());
983        // a name a run may not write under is refused for a Windows node too
984        let err = apply_host_for_node(
985            repo.path(),
986            "targets/prod.yaml",
987            "nodes.master",
988            &windows,
989            "gw",
990            "core",
991        )
992        .err()
993        .expect("core is refused");
994        assert_eq!(err.code(), "deploy_tree_invalid");
995    }
996}