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}