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