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/// The fewest components a directory a delete emptied may have and still be
632/// removed, for the collection path `path` the delete named (a commit's
633/// delete, a restore's removal of a created file, a mirror's delete). It is
634/// [`MIRROR_MIN_COMPONENTS`], so `<module>/<type>` stays, except below the
635/// type folder of a
636/// [`PAIR_UNSYNCED_TAG_TYPES`](crate::deploy::layout::PAIR_UNSYNCED_TAG_TYPES)
637/// type, where the emptied type folder `ignition/<type>` is removed too: on a
638/// redundant pair an empty one in the mode collection alone stops the backup
639/// from syncing (debug deploy-tag-redundancy-sync, T6), and a standalone
640/// gateway does not need it. The module directory is never removed. The
641/// script hosts (`commit.sh`, `commit.ps1`) apply the same rule, and their
642/// reports are checked against it.
643pub(crate) fn emptied_dir_floor(path: &str) -> usize {
644 if crate::deploy::layout::is_pair_unsynced_tag_path(path) {
645 2
646 } else {
647 MIRROR_MIN_COMPONENTS
648 }
649}
650
651/// Refuse a staged tree and delete set before any I/O: every path valid, no
652/// path both staged and deleted, no path a file and a directory of another,
653/// bytes and entries within the read side's caps, and a `Separate` staging
654/// root that is not inside (or around) `resources_root`.
655pub(crate) fn validate_mirror_inputs(
656 tree: &RenderedTree,
657 delete_set: &DeleteSet,
658 staging: &StagingMode,
659 resources_root: &str,
660) -> Result<(), String> {
661 let mut total: u64 = 0;
662 for (path, bytes) in &tree.files {
663 let len = bytes.len() as u64;
664 if len > READ_MAX_FILE_BYTES {
665 return Err(format!(
666 "{path} is {len} bytes, over the {READ_MAX_FILE_BYTES} byte file cap"
667 ));
668 }
669 total = total.saturating_add(len);
670 if total > READ_MAX_TOTAL_BYTES {
671 return Err(format!(
672 "the staged tree exceeds {READ_MAX_TOTAL_BYTES} bytes in total (at {path})"
673 ));
674 }
675 }
676 let staged: Vec<&RelPath> = tree.files.keys().collect();
677 validate_mirror_paths(&staged, delete_set)?;
678 validate_staging_root(staging, resources_root)
679}
680
681/// The path half of [`validate_mirror_inputs`], also run by commit on the
682/// paths of a [`Staged`].
683pub(crate) fn validate_mirror_paths(
684 staged: &[&RelPath],
685 delete_set: &DeleteSet,
686) -> Result<(), String> {
687 for path in staged {
688 if let Some(why) = mirror_path_problem(path) {
689 return Err(format!("staged path refused: {why}"));
690 }
691 }
692 let mut deleting: BTreeSet<&str> = BTreeSet::new();
693 for path in &delete_set.files {
694 if let Some(why) = mirror_path_problem(path) {
695 return Err(format!("delete-set path refused: {why}"));
696 }
697 if !deleting.insert(path.as_str()) {
698 return Err(format!("delete-set path {path} is listed twice"));
699 }
700 }
701 let staging: BTreeSet<&str> = staged.iter().map(|p| p.as_str()).collect();
702 if staging.len() != staged.len() {
703 return Err("a path is staged twice".to_string());
704 }
705 if let Some(both) = deleting.iter().find(|p| staging.contains(**p)) {
706 return Err(format!("{both} is both staged and in the delete set"));
707 }
708 // A path may not be a file and the directory of another path.
709 let mut dirs: BTreeSet<&str> = BTreeSet::new();
710 for path in staging.iter().chain(deleting.iter()) {
711 for (at, _) in path.match_indices('/') {
712 dirs.insert(&path[..at]);
713 }
714 }
715 if let Some(clash) = staging
716 .iter()
717 .chain(deleting.iter())
718 .find(|p| dirs.contains(**p))
719 {
720 return Err(format!(
721 "{clash} is a file and also the directory of another path"
722 ));
723 }
724 if staging.len() + deleting.len() + dirs.len() > READ_MAX_ENTRIES {
725 return Err(format!(
726 "more than {READ_MAX_ENTRIES} entries (files, deletes and directories together)"
727 ));
728 }
729 Ok(())
730}
731
732/// The components of an absolute path spelled with `/` or `\`, or why it is
733/// not usable as a staging root.
734pub(crate) fn absolute_components(text: &str) -> Result<Vec<String>, String> {
735 if text.is_empty() || text.chars().any(char::is_control) {
736 return Err("must be a non-empty path without control characters".to_string());
737 }
738 let normal = text.replace('\\', "/");
739 let bytes = normal.as_bytes();
740 let absolute = normal.starts_with('/')
741 || (bytes.len() >= 3
742 && bytes[1] == b':'
743 && bytes[2] == b'/'
744 && bytes[0].is_ascii_alphabetic());
745 if !absolute {
746 return Err("must be an absolute path".to_string());
747 }
748 let mut parts = Vec::new();
749 for part in normal.split('/') {
750 match part {
751 "" => {}
752 "." | ".." => return Err("must not contain `.` or `..` components".to_string()),
753 other => parts.push(other.to_string()),
754 }
755 }
756 Ok(parts)
757}
758
759/// Refuse a `Separate` staging root that is relative, too shallow, inside
760/// `resources_root` (`config/resources`) or around it. `InPlace` has no root.
761pub(crate) fn validate_staging_root(
762 staging: &StagingMode,
763 resources_root: &str,
764) -> Result<(), String> {
765 validate_staging_root_for(staging, resources_root, cfg!(windows))
766}
767
768/// [`validate_staging_root`] with the comparison's case rule chosen by the
769/// HOST the paths name, not by the runner: a Windows OpenSSH host is
770/// case-insensitive, so `/c:/program files/.../staging` is inside
771/// `/C:/Program Files/.../resources` whatever OS `ign` runs on.
772pub(crate) fn validate_staging_root_for(
773 staging: &StagingMode,
774 resources_root: &str,
775 case_insensitive: bool,
776) -> Result<(), String> {
777 let StagingMode::Separate { root } = staging else {
778 return Ok(());
779 };
780 let staging_parts =
781 absolute_components(root).map_err(|why| format!("the staging root {root:?} {why}"))?;
782 if staging_parts.len() < 2 {
783 return Err(format!(
784 "the staging root {root:?} must be at least two directories deep"
785 ));
786 }
787 let resources_parts = absolute_components(resources_root)
788 .map_err(|why| format!("the resources root {resources_root:?} {why}"))?;
789 let same = |a: &String, b: &String| {
790 if case_insensitive {
791 a.eq_ignore_ascii_case(b)
792 } else {
793 a == b
794 }
795 };
796 let starts_with = |long: &[String], short: &[String]| {
797 long.len() >= short.len() && long.iter().zip(short).all(|(a, b)| same(a, b))
798 };
799 if starts_with(&staging_parts, &resources_parts) {
800 return Err(format!(
801 "the staging root {root:?} is inside config/resources; staging entries there would be scanned by the gateway"
802 ));
803 }
804 if starts_with(&resources_parts, &staging_parts) {
805 return Err(format!(
806 "the staging root {root:?} contains config/resources"
807 ));
808 }
809 Ok(())
810}
811
812/// Copy every file of `tree` into a fresh private temp dir and hash it. The
813/// dir is new and ours, so a plain `create_new` write is safe.
814pub(crate) fn copy_to_temp(
815 tree: &RenderedTree,
816) -> Result<(tempfile::TempDir, Vec<(RelPath, String)>), String> {
817 use std::io::Write;
818 let temp = tempfile::Builder::new()
819 .prefix("ign-stage-")
820 .tempdir()
821 .map_err(|e| format!("cannot create a staging dir ({})", e.kind()))?;
822 let mut files = Vec::with_capacity(tree.files.len());
823 for (path, bytes) in &tree.files {
824 let target = temp.path().join(path.as_str());
825 if let Some(parent) = target.parent() {
826 std::fs::create_dir_all(parent)
827 .map_err(|e| format!("cannot create the staging dir for {path} ({})", e.kind()))?;
828 }
829 let mut file = std::fs::OpenOptions::new()
830 .write(true)
831 .create_new(true)
832 .open(&target)
833 .map_err(|e| format!("cannot stage {path} ({})", e.kind()))?;
834 file.write_all(bytes)
835 .map_err(|e| format!("cannot stage {path} ({})", e.kind()))?;
836 files.push((path.clone(), super::tree::sha256_hex(bytes)));
837 }
838 Ok((temp, files))
839}
840
841/// The bytes of the staged copy `rel` below `base`, read through
842/// [`open_regular_file`](super::tree::open_regular_file) (descriptor-relative,
843/// no symlink followed) and required to be a regular file within the file cap
844/// whose SHA-256 is `expect_sha`.
845pub(crate) fn read_staged_copy(
846 base: &Path,
847 rel: &RelPath,
848 expect_sha: &str,
849) -> Result<Vec<u8>, String> {
850 use super::tree::{OpenError, open_regular_file, sha256_hex};
851 use std::io::Read;
852 let path = base.join(rel.as_str());
853 let before = std::fs::symlink_metadata(&path)
854 .map_err(|e| format!("the staged copy of {rel} is gone ({})", e.kind()))?;
855 if !before.file_type().is_file() {
856 return Err(format!("the staged copy of {rel} is not a regular file"));
857 }
858 let (file, held) =
859 open_regular_file(base, Path::new(rel.as_str()), &before).map_err(|e| match e {
860 OpenError::Changed => format!("the staged copy of {rel} changed while it was read"),
861 OpenError::Io(e) => format!("cannot read the staged copy of {rel} ({})", e.kind()),
862 })?;
863 if held.len() > READ_MAX_FILE_BYTES {
864 return Err(format!(
865 "the staged copy of {rel} is over the {READ_MAX_FILE_BYTES} byte file cap"
866 ));
867 }
868 let mut bytes = Vec::with_capacity(held.len() as usize);
869 file.take(READ_MAX_FILE_BYTES + 1)
870 .read_to_end(&mut bytes)
871 .map_err(|e| format!("cannot read the staged copy of {rel} ({})", e.kind()))?;
872 if bytes.len() as u64 > READ_MAX_FILE_BYTES || sha256_hex(&bytes) != expect_sha {
873 return Err(format!(
874 "the staged copy of {rel} changed after it was staged"
875 ));
876 }
877 Ok(bytes)
878}
879
880#[cfg(test)]
881mod tests {
882 use super::*;
883 use crate::deploy::target::{Ssh, TargetOs};
884
885 fn ssh_node(os: TargetOs, data_dir: &str) -> NodeTarget {
886 NodeTarget {
887 profile: "p".to_string(),
888 ssh: Some(Ssh {
889 host: "gw.example.test".to_string(),
890 port: None,
891 user: "svc".to_string(),
892 key_file: None,
893 key_env: None,
894 os,
895 data_dir: data_dir.to_string(),
896 known_hosts: "ssh/prod.known_hosts".to_string(),
897 connect_timeout_s: None,
898 op_timeout_s: None,
899 }),
900 local: None,
901 gateway_name: None,
902 }
903 }
904
905 fn repo() -> tempfile::TempDir {
906 let repo = tempfile::tempdir().expect("repo");
907 std::fs::create_dir_all(repo.path().join("ssh")).expect("ssh dir");
908 std::fs::write(
909 repo.path().join("ssh/prod.known_hosts"),
910 "gw.example.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPinnedPinnedPinnedPinnedPinnedPinnedPinned\n",
911 )
912 .expect("known_hosts");
913 repo
914 }
915
916 /// A Linux ssh node and a `local` node get their hosts; names a run may not
917 /// write under are refused before any host is built.
918 #[test]
919 fn apply_host_for_node_linux_ssh() {
920 let repo = repo();
921 let linux = ssh_node(TargetOs::Linux, "/usr/local/bin/ignition/data");
922 assert!(
923 apply_host_for_node(
924 repo.path(),
925 "targets/prod.yaml",
926 "nodes.master",
927 &linux,
928 "gw",
929 "prod"
930 )
931 .is_ok(),
932 "a Linux ssh node builds an apply host"
933 );
934 let access =
935 node_access(repo.path(), "targets/prod.yaml", "nodes.master", &linux).expect("access");
936 let ApplyHostKind::Sftp(host) =
937 apply_host_kind(access, "nodes.master", "gw", "prod").expect("host")
938 else {
939 panic!("an ssh node gets the sftp host");
940 };
941 assert!(!host.is_windows_for_tests());
942 let local = NodeTarget {
943 profile: "p".to_string(),
944 ssh: None,
945 local: Some(crate::deploy::target::Local {
946 data_dir: repo.path().join("data").display().to_string(),
947 }),
948 gateway_name: None,
949 };
950 let access =
951 node_access(repo.path(), "targets/prod.yaml", "nodes.master", &local).expect("access");
952 assert!(matches!(
953 apply_host_kind(access, "nodes.master", "gw", "prod").expect("host"),
954 ApplyHostKind::Local(_)
955 ));
956 for (gateway, mode) in [
957 (".hidden", "prod"),
958 ("gw", "core"),
959 ("gw", "local"),
960 ("g/w", "prod"),
961 ] {
962 let err = apply_host_for_node(
963 repo.path(),
964 "targets/prod.yaml",
965 "nodes.master",
966 &linux,
967 gateway,
968 mode,
969 )
970 .err()
971 .unwrap_or_else(|| panic!("{gateway}/{mode} is refused"));
972 assert_eq!(err.code(), "deploy_tree_invalid", "{gateway}/{mode}");
973 }
974 }
975
976 /// 23-20: a Windows ssh node gets the sftp host too, the one that runs the
977 /// PowerShell script. Until the PowerShell host was complete this arm was
978 /// the 23-04 `deploy_tree_invalid` refusal.
979 #[test]
980 fn apply_host_for_node_windows_ssh() {
981 let repo = repo();
982 let windows = ssh_node(TargetOs::Windows, "C:\\Ignition\\data");
983 assert!(
984 apply_host_for_node(
985 repo.path(),
986 "targets/prod.yaml",
987 "nodes.master",
988 &windows,
989 "gw",
990 "prod"
991 )
992 .is_ok(),
993 "a Windows ssh node builds an apply host"
994 );
995 let access = node_access(repo.path(), "targets/prod.yaml", "nodes.master", &windows)
996 .expect("access");
997 let ApplyHostKind::Sftp(host) =
998 apply_host_kind(access, "nodes.master", "gw", "prod").expect("host")
999 else {
1000 panic!("a Windows ssh node gets the sftp host");
1001 };
1002 assert!(host.is_windows_for_tests());
1003 // a name a run may not write under is refused for a Windows node too
1004 let err = apply_host_for_node(
1005 repo.path(),
1006 "targets/prod.yaml",
1007 "nodes.master",
1008 &windows,
1009 "gw",
1010 "core",
1011 )
1012 .err()
1013 .expect("core is refused");
1014 assert_eq!(err.code(), "deploy_tree_invalid");
1015 }
1016}