Skip to main content

ignition_core/deploy/transport/
mod.rs

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