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