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