Skip to main content

ignition_core/deploy/
tree.rs

1//! The in-memory source and rendered trees, and the render digest.
2//!
3//! [`SourceTree`] is what render reads: gateway-relative path to raw bytes.
4//! [`RenderedTree`] is what render produces: collection path to raw bytes.
5//! Both are `BTreeMap`s keyed by [`RelPath`], so iteration is byte-sorted
6//! on every platform and never depends on directory order.
7//!
8//! ## `render_sha256` listing format (documented in the README)
9//!
10//! For every rendered file, in byte-sorted collection-path order, one entry:
11//!
12//! ```text
13//! <collection path> NUL <lowercase hex sha256 of the file bytes> LF
14//! ```
15//!
16//! The digest is the lowercase hex SHA-256 of the concatenated entries. An
17//! empty tree hashes the empty listing ([`EMPTY_RENDER_SHA256`]).
18
19use std::collections::{BTreeMap, BTreeSet};
20use std::io::{Read, Write};
21use std::path::{Path, PathBuf};
22
23use serde::{Deserialize, Serialize};
24use sha2::{Digest, Sha256};
25
26use super::layout::RelPath;
27use super::transport::READ_MAX_FILE_BYTES;
28use crate::error::{CoreError, DeployFinding};
29
30/// SHA-256 of the empty listing (the digest of a render that produced no
31/// files). Equal to the SHA-256 of zero bytes.
32pub const EMPTY_RENDER_SHA256: &str =
33    "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
34
35/// Lowercase hex SHA-256 of `bytes`.
36pub fn sha256_hex(bytes: &[u8]) -> String {
37    format!("{:x}", Sha256::digest(bytes))
38}
39
40/// Source files of one gateway directory, keyed by gateway-relative path
41/// (`resources/<module>/<type>/...`).
42#[derive(Debug, Clone, Default, PartialEq, Eq)]
43pub struct SourceTree {
44    /// Gateway-relative path to raw file bytes.
45    pub files: BTreeMap<RelPath, Vec<u8>>,
46}
47
48/// The rendered files, keyed by collection path (`<module>/<type>/...`).
49#[derive(Debug, Clone, Default, PartialEq, Eq)]
50pub struct RenderedTree {
51    /// Collection path to raw file bytes.
52    pub files: BTreeMap<RelPath, Vec<u8>>,
53}
54
55impl RenderedTree {
56    /// Total size of every rendered file in bytes.
57    pub fn bytes(&self) -> usize {
58        self.files.values().map(Vec::len).sum()
59    }
60
61    /// The `render_sha256` digest (see the module doc for the listing).
62    pub fn render_sha256(&self) -> String {
63        let mut hasher = Sha256::new();
64        for (path, bytes) in &self.files {
65            hasher.update(path.as_str().as_bytes());
66            hasher.update([0u8]);
67            hasher.update(sha256_hex(bytes).as_bytes());
68            hasher.update(b"\n");
69        }
70        format!("{:x}", hasher.finalize())
71    }
72}
73
74impl SourceTree {
75    /// Build a tree in memory (tests). Later entries replace earlier ones
76    /// with the same path.
77    pub fn from_files<I, P, B>(files: I) -> Result<SourceTree, String>
78    where
79        I: IntoIterator<Item = (P, B)>,
80        P: AsRef<str>,
81        B: Into<Vec<u8>>,
82    {
83        let mut map = BTreeMap::new();
84        for (path, bytes) in files {
85            map.insert(RelPath::new(path.as_ref())?, bytes.into());
86        }
87        Ok(SourceTree { files: map })
88    }
89
90    /// Read `resources/`, `tags/` and `modes/<env>/` of one gateway directory.
91    ///
92    /// Every entry is inspected with `symlink_metadata` and never followed;
93    /// each directory's entries are sorted by name bytes before recursing.
94    /// At the gateway root `manifest.yaml` (not collected, the action reads
95    /// it), `resources/`, `tags/` (walked), `modes/` and `.gitkeep` (skipped)
96    /// are accepted; any other root entry is a finding. Under `modes/` only
97    /// the directory named exactly `env` is opened; other entries that are
98    /// directories belong to other environments and are skipped unopened, so
99    /// their content can never fail or change this render. Under
100    /// `modes/<env>/` only `resources/` and `tags/` are accepted. Overlay
101    /// files are keyed by their full gateway-relative path
102    /// (`modes/<env>/resources/...`); render's overlay stage resolves them.
103    /// Symlinks, non-regular files and unusable names are findings. All
104    /// findings are raised together as one `deploy_tree_invalid`.
105    pub fn read_gateway(gateway_dir: &Path, env: &str) -> Result<SourceTree, CoreError> {
106        let gateway = gateway_dir
107            .file_name()
108            .map(|n| n.to_string_lossy().into_owned())
109            .unwrap_or_default();
110        let mut reader = Reader {
111            prefix: format!("gateways/{gateway}"),
112            env: env.to_string(),
113            files: BTreeMap::new(),
114            findings: Vec::new(),
115        };
116        reader.read_root(gateway_dir);
117        if reader.findings.is_empty() {
118            Ok(SourceTree {
119                files: reader.files,
120            })
121        } else {
122            Err(CoreError::deploy_findings(reader.findings))
123        }
124    }
125}
126
127/// File name of the marker `--out` writes at the root of a rendered tree.
128pub const RENDER_MARKER: &str = "render.json";
129
130/// Suffix of the temporary file a write goes through before its rename.
131pub(crate) const TMP_SUFFIX: &str = ".ign-render-tmp";
132
133/// `render.json`: ign's own record of a render written with `--out`. It is
134/// not a gateway file and is never part of `render_sha256`. Phase 22 reads
135/// it (`plan --base`), so its shape is versioned by `schema_version`.
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(deny_unknown_fields)]
138pub struct RenderMarker {
139    /// Marker schema version (1).
140    pub schema_version: u32,
141    /// Gateway directory name the render came from.
142    pub gateway: String,
143    /// Environment the render was made for.
144    pub env: String,
145    /// Deployment mode from the target.
146    pub mode: String,
147    /// The render's `render_sha256` (equals the envelope's).
148    pub render_sha256: String,
149    /// Every written file, byte-sorted by path.
150    pub files: Vec<MarkerFile>,
151}
152
153/// One file entry of [`RenderMarker`].
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
155#[serde(deny_unknown_fields)]
156pub struct MarkerFile {
157    /// Collection path, relative to the `--out` directory.
158    pub path: String,
159    /// Lowercase hex SHA-256 of the file bytes.
160    pub sha256: String,
161    /// File size in bytes.
162    pub bytes: u64,
163}
164
165impl RenderMarker {
166    /// The marker for `tree` (files in byte-sorted path order).
167    pub fn for_tree(gateway: &str, env: &str, mode: &str, tree: &RenderedTree) -> RenderMarker {
168        RenderMarker {
169            schema_version: 1,
170            gateway: gateway.to_string(),
171            env: env.to_string(),
172            mode: mode.to_string(),
173            render_sha256: tree.render_sha256(),
174            files: tree
175                .files
176                .iter()
177                .map(|(path, bytes)| MarkerFile {
178                    path: path.as_str().to_string(),
179                    sha256: sha256_hex(bytes),
180                    bytes: bytes.len() as u64,
181                })
182                .collect(),
183        }
184    }
185
186    /// The marker as written: pretty JSON plus one LF.
187    pub fn to_bytes(&self) -> Result<Vec<u8>, CoreError> {
188        let mut text = serde_json::to_string_pretty(self)
189            .map_err(|e| CoreError::Internal(format!("cannot serialize {RENDER_MARKER}: {e}")))?;
190        text.push('\n');
191        Ok(text.into_bytes())
192    }
193}
194
195fn refuse(out: &Path, reason: impl std::fmt::Display) -> CoreError {
196    CoreError::InvalidInput {
197        reason: format!(
198            "--out {}: {reason}; remove the directory or choose an empty or absent one",
199            out.display()
200        ),
201    }
202}
203
204/// What the check phase found.
205enum OutPlan {
206    /// The path is absent: create it.
207    Create,
208    /// An existing empty directory.
209    Empty,
210    /// A prior render of the same gateway and environment: its listed
211    /// files (unchanged) may be replaced.
212    Refresh(BTreeSet<String>),
213}
214
215/// Inspect `out` without writing anything and decide whether the render may
216/// be written there.
217fn check_out(tree: &RenderedTree, out: &Path, marker: &RenderMarker) -> Result<OutPlan, CoreError> {
218    if tree.files.keys().any(|p| p.as_str() == RENDER_MARKER) {
219        return Err(refuse(
220            out,
221            format!("the render holds a file named {RENDER_MARKER}"),
222        ));
223    }
224    let meta = match std::fs::symlink_metadata(out) {
225        Ok(meta) => meta,
226        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(OutPlan::Create),
227        Err(e) => return Err(refuse(out, format!("cannot inspect it ({})", e.kind()))),
228    };
229    if meta.file_type().is_symlink() {
230        return Err(refuse(out, "is a symlink; symlinks are refused"));
231    }
232    if !meta.file_type().is_dir() {
233        return Err(refuse(out, "exists and is not a directory"));
234    }
235
236    let mut files: BTreeMap<String, PathBuf> = BTreeMap::new();
237    let mut dirs: BTreeSet<String> = BTreeSet::new();
238    walk_out(out, "", &mut files, &mut dirs).map_err(|why| refuse(out, why))?;
239    if files.is_empty() && dirs.is_empty() {
240        return Ok(OutPlan::Empty);
241    }
242
243    let marker_path = match files.get(RENDER_MARKER) {
244        Some(path) => path,
245        None => {
246            let first = files
247                .keys()
248                .next()
249                .cloned()
250                .or_else(|| dirs.iter().next().cloned())
251                .unwrap_or_default();
252            return Err(refuse(
253                out,
254                format!("is not empty and holds no {RENDER_MARKER} (first entry: {first})"),
255            ));
256        }
257    };
258    let prior: RenderMarker = std::fs::read(marker_path)
259        .map_err(|e| refuse(out, format!("cannot read {RENDER_MARKER} ({})", e.kind())))
260        .and_then(|bytes| {
261            serde_json::from_slice(&bytes)
262                .map_err(|_| refuse(out, format!("{RENDER_MARKER} is not a valid render marker")))
263        })?;
264    if prior.schema_version != 1 {
265        return Err(refuse(
266            out,
267            format!(
268                "{RENDER_MARKER} has schema_version {}, expected 1",
269                prior.schema_version
270            ),
271        ));
272    }
273    if prior.gateway != marker.gateway || prior.env != marker.env {
274        return Err(refuse(
275            out,
276            format!(
277                "{RENDER_MARKER} belongs to gateway {} env {}, not gateway {} env {}",
278                prior.gateway, prior.env, marker.gateway, marker.env
279            ),
280        ));
281    }
282
283    let mut listed: BTreeSet<String> = BTreeSet::new();
284    for entry in &prior.files {
285        if !listed.insert(entry.path.clone()) {
286            return Err(refuse(
287                out,
288                format!("{RENDER_MARKER} lists {} twice", entry.path),
289            ));
290        }
291    }
292    // every regular file other than the marker is listed with its sha256
293    for (rel, path) in &files {
294        if rel == RENDER_MARKER {
295            continue;
296        }
297        let Some(entry) = prior.files.iter().find(|f| &f.path == rel) else {
298            return Err(refuse(
299                out,
300                format!("{rel} is not listed in {RENDER_MARKER}"),
301            ));
302        };
303        let bytes = std::fs::read(path)
304            .map_err(|e| refuse(out, format!("cannot read {rel} ({})", e.kind())))?;
305        if sha256_hex(&bytes) != entry.sha256 {
306            return Err(refuse(
307                out,
308                format!("{rel} was modified since it was rendered"),
309            ));
310        }
311    }
312    // every listed file is present, and still produced by the new render
313    for rel in &listed {
314        if !files.contains_key(rel) {
315            return Err(refuse(
316                out,
317                format!("{rel} is listed in {RENDER_MARKER} but missing"),
318            ));
319        }
320        if !tree.files.keys().any(|p| p.as_str() == rel) {
321            return Err(refuse(
322                out,
323                format!("{rel} was rendered before but this render no longer produces it"),
324            ));
325        }
326    }
327    // no directory that holds no listed file
328    for dir in &dirs {
329        let prefix = format!("{dir}/");
330        if !listed.iter().any(|rel| rel.starts_with(&prefix)) {
331            return Err(refuse(out, format!("{dir}/ holds no rendered file")));
332        }
333    }
334    // a new path may not collide with an existing file or directory
335    for path in tree.files.keys() {
336        let rel = path.as_str();
337        if listed.contains(rel) {
338            continue;
339        }
340        if files.contains_key(rel) || dirs.contains(rel) {
341            return Err(refuse(out, format!("{rel} already exists")));
342        }
343        let mut at = 0;
344        while let Some(i) = rel[at..].find('/') {
345            at += i;
346            if files.contains_key(&rel[..at]) {
347                return Err(refuse(
348                    out,
349                    format!("{} is a file, not a directory", &rel[..at]),
350                ));
351            }
352            at += 1;
353        }
354    }
355    Ok(OutPlan::Refresh(listed))
356}
357
358/// Walk `dir` with `symlink_metadata`, entries sorted by name bytes.
359/// Symlinks, special files and non-UTF-8 names are errors naming the path.
360/// Shared by `--out` checks and the live-tree readers (plan, pull).
361pub(crate) fn walk_out(
362    dir: &Path,
363    rel: &str,
364    files: &mut BTreeMap<String, PathBuf>,
365    dirs: &mut BTreeSet<String>,
366) -> Result<(), String> {
367    walk_capped(dir, rel, files, dirs, usize::MAX)
368}
369
370/// [`walk_out`] that stops with an error as soon as `files` and `dirs`
371/// together hold more than `max_entries` entries, so a hostile tree cannot
372/// be enumerated in full before a cap is checked.
373pub(crate) fn walk_capped(
374    dir: &Path,
375    rel: &str,
376    files: &mut BTreeMap<String, PathBuf>,
377    dirs: &mut BTreeSet<String>,
378    max_entries: usize,
379) -> Result<(), String> {
380    let read = std::fs::read_dir(dir)
381        .map_err(|e| format!("cannot read directory {} ({})", join(rel, ""), e.kind()))?;
382    let listing = read.map(|entry| entry.map(|entry| (entry.file_name(), entry.path())));
383    let entries = collect_capped(listing, rel, files.len() + dirs.len(), max_entries)?;
384    for (name, path) in entries {
385        let child = join(rel, &name);
386        let meta = std::fs::symlink_metadata(&path)
387            .map_err(|e| format!("cannot inspect {child} ({})", e.kind()))?;
388        let kind = meta.file_type();
389        if kind.is_symlink() {
390            return Err(format!("{child} is a symlink; symlinks are refused"));
391        } else if kind.is_dir() {
392            dirs.insert(child.clone());
393            if files.len() + dirs.len() > max_entries {
394                return Err(format!("more than {max_entries} entries (at {child})"));
395            }
396            walk_capped(&path, &child, files, dirs, max_entries)?;
397        } else if kind.is_file() {
398            files.insert(child.clone(), path);
399            if files.len() + dirs.len() > max_entries {
400                return Err(format!("more than {max_entries} entries (at {child})"));
401            }
402        } else {
403            return Err(format!("{child} is not a regular file or directory"));
404        }
405    }
406    Ok(())
407}
408
409/// Collect one directory's entries, sorted by name bytes, stopping with an
410/// error as soon as `taken` (entries the walk already holds) plus the entries
411/// collected here exceed `max_entries`, so a flat directory of millions of
412/// entries is never enumerated or allocated in full.
413fn collect_capped(
414    listing: impl Iterator<Item = std::io::Result<(std::ffi::OsString, PathBuf)>>,
415    rel: &str,
416    taken: usize,
417    max_entries: usize,
418) -> Result<Vec<(String, PathBuf)>, String> {
419    let mut entries: Vec<(String, PathBuf)> = Vec::new();
420    for entry in listing {
421        let (raw, path) =
422            entry.map_err(|e| format!("cannot read a directory entry ({})", e.kind()))?;
423        let name = raw.into_string().map_err(|raw| {
424            format!(
425                "{} has a name that is not valid UTF-8",
426                join(rel, &raw.to_string_lossy())
427            )
428        })?;
429        // The sftp transport refuses control characters in a listed name;
430        // the local walk does the same so the two transports agree and a
431        // name can never carry an escape sequence into a plan or its output.
432        if name.chars().any(char::is_control) {
433            return Err(format!(
434                "{} holds an entry whose name has a control character; refused",
435                join(rel, "")
436            ));
437        }
438        if taken.saturating_add(entries.len()) >= max_entries {
439            return Err(format!(
440                "more than {max_entries} entries (at {})",
441                join(rel, &name)
442            ));
443        }
444        entries.push((name, path));
445    }
446    entries.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
447    Ok(entries)
448}
449
450/// Why [`open_regular_file`] refused.
451#[derive(Debug)]
452pub(crate) enum OpenError {
453    /// The open or a metadata call failed.
454    Io(std::io::Error),
455    /// The opened handle is not the regular file that was inspected: it was
456    /// swapped (for a symlink target or another file) after the check.
457    Changed,
458}
459
460/// Open `rel` beneath the directory `root` and validate the HANDLE against
461/// `before`, the `symlink_metadata` taken when the path was inspected.
462///
463/// On unix the open is descriptor-relative: `root` is opened once as a
464/// directory fd, then every component of `rel` is opened with `openat`
465/// relative to the previous fd, intermediate directories with
466/// `O_NOFOLLOW | O_DIRECTORY | O_CLOEXEC` and the final file with
467/// `O_NOFOLLOW | O_CLOEXEC` (read-only). A component that is, or has been
468/// swapped for, a symlink fails the open (`ELOOP` / `ENOTDIR`) instead of
469/// redirecting it, so no component at any depth can lead outside `root` even
470/// when it is replaced after the walk and the metadata check. The handle is
471/// then `fstat`ed: it must be a regular file and the same device and inode as
472/// `before`. `root` itself is the operator's own directory and is opened
473/// through a final symlink (callers that refuse a symlinked root do so first).
474///
475/// On windows the full path is opened with `FILE_FLAG_OPEN_REPARSE_POINT` and
476/// the handle must not be a reparse point. Windows has no descriptor-relative
477/// open in std, so an intermediate directory swapped for a link between the
478/// caller's check and the open remains a residual race there.
479///
480/// Returns the file and its handle metadata (take sizes from that).
481pub(crate) fn open_regular_file(
482    root: &Path,
483    rel: &Path,
484    before: &std::fs::Metadata,
485) -> Result<(std::fs::File, std::fs::Metadata), OpenError> {
486    #[cfg(unix)]
487    let file = open_beneath(root, rel)?;
488    #[cfg(windows)]
489    let file = {
490        use std::os::windows::fs::OpenOptionsExt;
491        let mut options = std::fs::OpenOptions::new();
492        // FILE_FLAG_OPEN_REPARSE_POINT: open the link itself, never its target.
493        options.read(true).custom_flags(0x0020_0000);
494        options.open(root.join(rel)).map_err(OpenError::Io)?
495    };
496    #[cfg(not(any(unix, windows)))]
497    let file = std::fs::File::open(root.join(rel)).map_err(OpenError::Io)?;
498
499    let held = file.metadata().map_err(OpenError::Io)?;
500    if held.file_type().is_symlink() || !held.is_file() {
501        return Err(OpenError::Changed);
502    }
503    #[cfg(unix)]
504    {
505        use std::os::unix::fs::MetadataExt;
506        if held.dev() != before.dev() || held.ino() != before.ino() {
507            return Err(OpenError::Changed);
508        }
509    }
510    #[cfg(not(unix))]
511    let _ = before;
512    Ok((file, held))
513}
514
515/// The descriptor-relative open behind [`open_regular_file`] (unix).
516#[cfg(unix)]
517fn open_beneath(root: &Path, rel: &Path) -> Result<std::fs::File, OpenError> {
518    use rustix::fs::{Mode, OFlags, open, openat};
519    use rustix::io::Errno;
520    use std::path::Component;
521
522    // A swapped or non-directory component: the tree changed under us.
523    let map = |e: Errno| -> OpenError {
524        if e == Errno::LOOP || e == Errno::NOTDIR {
525            OpenError::Changed
526        } else {
527            OpenError::Io(e.into())
528        }
529    };
530
531    let mut parts = Vec::new();
532    for component in rel.components() {
533        match component {
534            Component::Normal(name) => parts.push(name),
535            // `.`, `..`, a root or a prefix could leave `root`.
536            _ => return Err(OpenError::Changed),
537        }
538    }
539    let Some((last, parents)) = parts.split_last() else {
540        return Err(OpenError::Changed);
541    };
542
543    let mut dir = open(
544        root,
545        OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
546        Mode::empty(),
547    )
548    .map_err(map)?;
549    let dir_flags = OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::DIRECTORY | OFlags::CLOEXEC;
550    for part in parents {
551        dir = openat(&dir, *part, dir_flags, Mode::empty()).map_err(map)?;
552    }
553    // NONBLOCK: a FIFO swapped in after the caller's check must fail the
554    // fstat comparison, not block `open(2)` until a writer appears. A regular
555    // file is unaffected.
556    let fd = openat(
557        &dir,
558        *last,
559        OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC,
560        Mode::empty(),
561    )
562    .map_err(map)?;
563    Ok(std::fs::File::from(fd))
564}
565
566/// Descriptor-relative mutation helpers (unix), the write-side twin of
567/// [`open_regular_file`]'s read chain.
568///
569/// Every function takes a directory handle and ONE name, never a path, and
570/// every open of a directory uses `O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC`. A
571/// component that is, or has been swapped for, a symlink (or any non-directory)
572/// fails with [`FdError::Changed`] instead of redirecting the operation, so a
573/// mutation can never leave the directory it was rooted in however the tree
574/// changes underneath. The root of a chain is the operator's own directory and
575/// is opened without following a final symlink.
576///
577/// Windows has no descriptor-relative primitives in std; its callers stay
578/// path-based and carry that as a documented residual.
579#[cfg(unix)]
580pub(crate) mod dirfd {
581    use std::fs::File;
582    use std::io::Read;
583    use std::os::unix::fs::MetadataExt;
584    use std::path::Path;
585
586    use rustix::fs::{
587        AtFlags, FileType, Mode, OFlags, mkdirat, openat, renameat, statat, unlinkat,
588    };
589    use rustix::io::Errno;
590
591    /// Why a descriptor-relative operation was refused.
592    #[derive(Debug)]
593    pub(crate) enum FdError {
594        /// A component is, or became, a symlink or a non-directory (`ELOOP`,
595        /// `ENOTDIR`, `ENXIO`), or an opened handle is not what was inspected.
596        Changed,
597        Missing,
598        Exists,
599        NotEmpty,
600        Io(std::io::Error),
601    }
602
603    fn map(e: Errno) -> FdError {
604        if e == Errno::LOOP || e == Errno::NOTDIR || e == Errno::NXIO {
605            FdError::Changed
606        } else if e == Errno::NOENT {
607            FdError::Missing
608        } else if e == Errno::EXIST {
609            FdError::Exists
610        } else if e == Errno::NOTEMPTY {
611            FdError::NotEmpty
612        } else {
613            FdError::Io(e.into())
614        }
615    }
616
617    /// What a name is inside a directory (never followed).
618    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
619    pub(crate) enum Kind {
620        Missing,
621        Dir,
622        File,
623        Symlink,
624        Other,
625    }
626
627    const DIR_FLAGS: OFlags = OFlags::RDONLY
628        .union(OFlags::DIRECTORY)
629        .union(OFlags::NOFOLLOW)
630        .union(OFlags::CLOEXEC);
631
632    /// Open `path` as a directory without following a final symlink.
633    pub(crate) fn open_root(path: &Path) -> Result<File, FdError> {
634        rustix::fs::open(path, DIR_FLAGS, Mode::empty())
635            .map(File::from)
636            .map_err(map)
637    }
638
639    /// Open the directory `name` inside `dir`.
640    pub(crate) fn open_dir(dir: &File, name: &str) -> Result<File, FdError> {
641        openat(dir, name, DIR_FLAGS, Mode::empty())
642            .map(File::from)
643            .map_err(map)
644    }
645
646    /// Open `parts` below `dir`, one `openat` per component.
647    pub(crate) fn walk(dir: &File, parts: &[&str]) -> Result<File, FdError> {
648        let mut current = dir.try_clone().map_err(FdError::Io)?;
649        for part in parts {
650            current = open_dir(&current, part)?;
651        }
652        Ok(current)
653    }
654
655    /// Open the directory `name` inside `dir`, creating it (`mkdirat`, mode
656    /// 0777 under the umask like `create_dir`) when absent.
657    pub(crate) fn ensure_dir(dir: &File, name: &str) -> Result<File, FdError> {
658        match open_dir(dir, name) {
659            Err(FdError::Missing) => {
660                match mkdirat(dir, name, Mode::from_raw_mode(0o777)) {
661                    Ok(()) | Err(Errno::EXIST) => {}
662                    Err(e) => return Err(map(e)),
663                }
664                open_dir(dir, name)
665            }
666            other => other,
667        }
668    }
669
670    /// [`walk`] that creates each missing level.
671    pub(crate) fn ensure_walk(dir: &File, parts: &[&str]) -> Result<File, FdError> {
672        let mut current = dir.try_clone().map_err(FdError::Io)?;
673        for part in parts {
674            current = ensure_dir(&current, part)?;
675        }
676        Ok(current)
677    }
678
679    /// What `name` is inside `dir`, without following it.
680    pub(crate) fn kind_at(dir: &File, name: &str) -> Result<Kind, FdError> {
681        match statat(dir, name, AtFlags::SYMLINK_NOFOLLOW) {
682            Ok(stat) => Ok(match FileType::from_raw_mode(stat.st_mode) {
683                FileType::Directory => Kind::Dir,
684                FileType::RegularFile => Kind::File,
685                FileType::Symlink => Kind::Symlink,
686                _ => Kind::Other,
687            }),
688            Err(Errno::NOENT) => Ok(Kind::Missing),
689            Err(e) => Err(map(e)),
690        }
691    }
692
693    /// `(device, inode)` of `name` inside `dir`, `None` when absent.
694    #[allow(clippy::unnecessary_cast)] // the raw widths differ per platform
695    pub(crate) fn ident_at(dir: &File, name: &str) -> Result<Option<(u64, u64)>, FdError> {
696        match statat(dir, name, AtFlags::SYMLINK_NOFOLLOW) {
697            Ok(stat) => Ok(Some((stat.st_dev as u64, stat.st_ino as u64))),
698            Err(Errno::NOENT) => Ok(None),
699            Err(e) => Err(map(e)),
700        }
701    }
702
703    /// Create `name` in `dir`, which must not exist: `O_CREAT | O_EXCL |
704    /// O_NOFOLLOW`, mode 0666 under the umask like `File::create_new`.
705    pub(crate) fn create_new(dir: &File, name: &str) -> Result<File, FdError> {
706        openat(
707            dir,
708            name,
709            OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC,
710            Mode::from_raw_mode(0o666),
711        )
712        .map(File::from)
713        .map_err(map)
714    }
715
716    /// Open `name` in `dir` for an in-place overwrite (create when absent,
717    /// truncate when present), never following a symlink and never touching a
718    /// non-regular file: the handle is checked with `fstat` BEFORE the
719    /// truncate.
720    pub(crate) fn open_overwrite(dir: &File, name: &str) -> Result<File, FdError> {
721        let fd = openat(
722            dir,
723            name,
724            OFlags::WRONLY | OFlags::CREATE | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC,
725            Mode::from_raw_mode(0o666),
726        )
727        .map_err(map)?;
728        let file = File::from(fd);
729        if !file.metadata().map_err(FdError::Io)?.is_file() {
730            return Err(FdError::Changed);
731        }
732        file.set_len(0).map_err(FdError::Io)?;
733        Ok(file)
734    }
735
736    /// Open the regular file `name` in `dir` for reading (no symlink, no
737    /// blocking on a FIFO) and read at most `cap + 1` bytes. Returns the
738    /// handle's identity and the bytes; a non-regular file is
739    /// [`FdError::Changed`].
740    pub(crate) fn read_file(
741        dir: &File,
742        name: &str,
743        cap: u64,
744    ) -> Result<((u64, u64), Vec<u8>), FdError> {
745        let fd = openat(
746            dir,
747            name,
748            OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC,
749            Mode::empty(),
750        )
751        .map_err(map)?;
752        let mut file = File::from(fd);
753        let meta = file.metadata().map_err(FdError::Io)?;
754        if !meta.is_file() {
755            return Err(FdError::Changed);
756        }
757        let mut bytes = Vec::with_capacity(meta.len().min(cap) as usize);
758        (&mut file)
759            .take(cap + 1)
760            .read_to_end(&mut bytes)
761            .map_err(FdError::Io)?;
762        Ok(((meta.dev(), meta.ino()), bytes))
763    }
764
765    /// `renameat` between directory handles (replaces an existing file).
766    pub(crate) fn rename_at(
767        from_dir: &File,
768        from: &str,
769        to_dir: &File,
770        to: &str,
771    ) -> Result<(), FdError> {
772        renameat(from_dir, from, to_dir, to).map_err(map)
773    }
774
775    /// `unlinkat` of a non-directory (a symlink is removed, never its target).
776    pub(crate) fn unlink_at(dir: &File, name: &str) -> Result<(), FdError> {
777        unlinkat(dir, name, AtFlags::empty()).map_err(map)
778    }
779
780    /// `unlinkat(AT_REMOVEDIR)`: removes an EMPTY directory only
781    /// ([`FdError::NotEmpty`] otherwise).
782    pub(crate) fn rmdir_at(dir: &File, name: &str) -> Result<(), FdError> {
783        unlinkat(dir, name, AtFlags::REMOVEDIR).map_err(map)
784    }
785}
786
787/// `symlink_metadata` of `repo/rel`, as a finding on failure.
788fn tree_meta(repo: &Path, rel: &str, hint: Option<&str>) -> Result<std::fs::Metadata, CoreError> {
789    match std::fs::symlink_metadata(repo.join(rel)) {
790        Ok(meta) if meta.file_type().is_symlink() => Err(CoreError::deploy_tree_invalid(
791            rel,
792            None,
793            None,
794            "is a symlink; symlinks are refused",
795        )),
796        Ok(meta) => Ok(meta),
797        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
798            let message = match hint {
799                Some(hint) => format!("does not exist ({hint})"),
800                None => "does not exist".to_string(),
801            };
802            Err(CoreError::deploy_tree_invalid(rel, None, None, message))
803        }
804        Err(e) => Err(CoreError::deploy_tree_invalid(
805            rel,
806            None,
807            None,
808            format!("cannot inspect ({})", e.kind()),
809        )),
810    }
811}
812
813/// Require `repo/rel` to be a real directory (not a symlink, not a file).
814pub(crate) fn require_repo_dir(repo: &Path, rel: &str) -> Result<(), CoreError> {
815    let meta = tree_meta(repo, rel, None)?;
816    if meta.file_type().is_dir() {
817        Ok(())
818    } else {
819        Err(CoreError::deploy_tree_invalid(
820            rel,
821            None,
822            None,
823            "must be a directory",
824        ))
825    }
826}
827
828/// Read `repo/rel` as a regular file; a symlink or non-file is refused.
829/// Every parent directory is checked, so a symlinked parent at any depth
830/// cannot redirect the read. The file is then opened first and the handle
831/// validated against the inspected metadata, and at most
832/// [`READ_MAX_FILE_BYTES`] are read.
833///
834/// The open is descriptor-relative on unix (see [`open_regular_file`]): a
835/// parent directory swapped for a symlink after the parent check is refused
836/// as "changed while it was read" rather than followed. On windows that
837/// intermediate-directory race remains (std has no descriptor-relative open);
838/// the repo is the operator's own checkout.
839pub(crate) fn read_repo_file(
840    repo: &Path,
841    rel: &str,
842    hint: Option<&str>,
843) -> Result<Vec<u8>, CoreError> {
844    if let Some((parent, _)) = rel.rsplit_once('/') {
845        let mut prefix = String::new();
846        for part in parent.split('/') {
847            if !prefix.is_empty() {
848                prefix.push('/');
849            }
850            prefix.push_str(part);
851            match std::fs::symlink_metadata(repo.join(&prefix)) {
852                // A missing component is reported by the file check below.
853                Err(e) if e.kind() == std::io::ErrorKind::NotFound => break,
854                _ => require_repo_dir(repo, &prefix)?,
855            }
856        }
857    }
858    let meta = tree_meta(repo, rel, hint)?;
859    if !meta.file_type().is_file() {
860        return Err(CoreError::deploy_tree_invalid(
861            rel,
862            None,
863            None,
864            "is not a regular file",
865        ));
866    }
867    let (file, held) = open_regular_file(repo, Path::new(rel), &meta).map_err(|e| match e {
868        OpenError::Changed => CoreError::deploy_tree_invalid(
869            rel,
870            None,
871            None,
872            "changed while it was read; symlinks are refused",
873        ),
874        OpenError::Io(e) => {
875            CoreError::deploy_tree_invalid(rel, None, None, format!("cannot read ({})", e.kind()))
876        }
877    })?;
878    if held.len() > READ_MAX_FILE_BYTES {
879        return Err(CoreError::deploy_tree_invalid(
880            rel,
881            None,
882            None,
883            format!("is over the {READ_MAX_FILE_BYTES} byte file cap"),
884        ));
885    }
886    let mut bytes = Vec::new();
887    file.take(READ_MAX_FILE_BYTES + 1)
888        .read_to_end(&mut bytes)
889        .map_err(|e| {
890            CoreError::deploy_tree_invalid(rel, None, None, format!("cannot read ({})", e.kind()))
891        })?;
892    if bytes.len() as u64 > READ_MAX_FILE_BYTES {
893        return Err(CoreError::deploy_tree_invalid(
894            rel,
895            None,
896            None,
897            format!("grew past the {READ_MAX_FILE_BYTES} byte file cap while it was read"),
898        ));
899    }
900    Ok(bytes)
901}
902
903fn io_failure(what: &str, path: &Path, e: &std::io::Error) -> CoreError {
904    if e.kind() == std::io::ErrorKind::AlreadyExists {
905        return CoreError::InvalidInput {
906            reason: format!(
907                "--out: {} already exists (another writer won the race); nothing was replaced",
908                path.display()
909            ),
910        };
911    }
912    CoreError::Internal(format!("cannot {what} {}: {e}", path.display()))
913}
914
915/// Write `bytes` to `<path>.ign-render-tmp` (created new) and rename it
916/// over `path`.
917fn write_replacing(path: &Path, bytes: &[u8]) -> Result<(), CoreError> {
918    let mut tmp = path.as_os_str().to_owned();
919    tmp.push(TMP_SUFFIX);
920    let tmp = PathBuf::from(tmp);
921    let mut file = std::fs::OpenOptions::new()
922        .write(true)
923        .create_new(true)
924        .open(&tmp)
925        .map_err(|e| io_failure("create", &tmp, &e))?;
926    file.write_all(bytes)
927        .map_err(|e| io_failure("write", &tmp, &e))?;
928    drop(file);
929    std::fs::rename(&tmp, path).map_err(|e| io_failure("replace", path, &e))
930}
931
932/// Write `tree` and its `marker` under `out`, in two phases.
933///
934/// Check phase (no writes): `out` must be absent, an empty directory, or a
935/// directory that is exactly a previous render of the same gateway and
936/// environment (see the README `--out` section); anything else is
937/// `invalid_input` naming the reason and the first offending path. Symlinks
938/// and special files anywhere under `out` are refused.
939///
940/// Write phase: new paths are opened with `create_new`, listed paths are
941/// replaced through a temporary file and a rename, and `render.json` goes
942/// last the same way. Nothing is ever removed.
943pub fn write_out(tree: &RenderedTree, out: &Path, marker: &RenderMarker) -> Result<(), CoreError> {
944    let plan = check_out(tree, out, marker)?;
945    let marker_bytes = marker.to_bytes()?;
946
947    match &plan {
948        OutPlan::Create => {
949            if let Some(parent) = out.parent().filter(|p| !p.as_os_str().is_empty()) {
950                std::fs::create_dir_all(parent).map_err(|e| io_failure("create", parent, &e))?;
951            }
952            std::fs::create_dir(out).map_err(|e| io_failure("create", out, &e))?;
953        }
954        OutPlan::Empty | OutPlan::Refresh(_) => {}
955    }
956    let listed = match &plan {
957        OutPlan::Refresh(listed) => Some(listed),
958        _ => None,
959    };
960
961    for (path, bytes) in &tree.files {
962        let rel = path.as_str();
963        let dest = rel.split('/').fold(out.to_path_buf(), |acc, c| acc.join(c));
964        if let Some(parent) = dest.parent() {
965            std::fs::create_dir_all(parent).map_err(|e| io_failure("create", parent, &e))?;
966        }
967        if listed.is_some_and(|l| l.contains(rel)) {
968            write_replacing(&dest, bytes)?;
969        } else {
970            let mut file = std::fs::OpenOptions::new()
971                .write(true)
972                .create_new(true)
973                .open(&dest)
974                .map_err(|e| io_failure("create", &dest, &e))?;
975            file.write_all(bytes)
976                .map_err(|e| io_failure("write", &dest, &e))?;
977        }
978    }
979    write_replacing(&out.join(RENDER_MARKER), &marker_bytes)
980}
981
982struct Reader {
983    /// `gateways/<name>`, the repo-relative prefix used in findings.
984    prefix: String,
985    /// The environment whose `modes/<env>/` overlay is read.
986    env: String,
987    files: BTreeMap<RelPath, Vec<u8>>,
988    findings: Vec<DeployFinding>,
989}
990
991impl Reader {
992    fn finding(&mut self, rel: &str, message: impl Into<String>) {
993        let file = if rel.is_empty() {
994            self.prefix.clone()
995        } else {
996            format!("{}/{rel}", self.prefix)
997        };
998        self.findings.push(DeployFinding {
999            file,
1000            line: None,
1001            column: None,
1002            message: message.into(),
1003        });
1004    }
1005
1006    /// Entries of `dir` sorted by name bytes; read errors become findings.
1007    fn sorted_entries(&mut self, dir: &Path, rel: &str) -> Vec<(String, std::path::PathBuf)> {
1008        let read = match std::fs::read_dir(dir) {
1009            Ok(read) => read,
1010            Err(e) => {
1011                self.finding(rel, format!("cannot read directory ({})", e.kind()));
1012                return Vec::new();
1013            }
1014        };
1015        let mut entries: Vec<(String, std::path::PathBuf)> = Vec::new();
1016        for entry in read {
1017            let entry = match entry {
1018                Ok(entry) => entry,
1019                Err(e) => {
1020                    self.finding(rel, format!("cannot read directory entry ({})", e.kind()));
1021                    continue;
1022                }
1023            };
1024            match entry.file_name().into_string() {
1025                Ok(name) => entries.push((name, entry.path())),
1026                Err(raw) => {
1027                    let lossy = raw.to_string_lossy();
1028                    let at = join(rel, &lossy);
1029                    self.finding(&at, "name is not valid UTF-8");
1030                }
1031            }
1032        }
1033        entries.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
1034        entries
1035    }
1036
1037    fn read_root(&mut self, dir: &Path) {
1038        for (name, path) in self.sorted_entries(dir, "") {
1039            if self.dot_entry(&path, &name, &name) {
1040                continue;
1041            }
1042            match name.as_str() {
1043                "manifest.yaml" => {
1044                    self.check_regular(&path, &name);
1045                }
1046                "resources" | "tags" => self.walk(&path, &name),
1047                "modes" => self.read_modes(&path, &name),
1048                _ => self.finding(
1049                    &name,
1050                    "unexpected entry at the gateway root (expected: manifest.yaml, resources/, tags/, modes/)",
1051                ),
1052            }
1053        }
1054    }
1055
1056    /// `modes/`: open only the directory named for this render's
1057    /// environment.
1058    fn read_modes(&mut self, dir: &Path, rel: &str) {
1059        if !self.require_dir(dir, rel) {
1060            return;
1061        }
1062        for (name, path) in self.sorted_entries(dir, rel) {
1063            let child = join(rel, &name);
1064            if name == ".gitkeep" {
1065                self.dot_entry(&path, &child, &name);
1066                continue;
1067            }
1068            if name == self.env {
1069                self.read_env(&path, &child);
1070                continue;
1071            }
1072            match std::fs::symlink_metadata(&path) {
1073                // another environment's tree: skipped without being opened
1074                Ok(meta) if meta.file_type().is_dir() => {}
1075                Ok(_) => self.finding(
1076                    &child,
1077                    "unexpected entry under modes/ (expected one directory per environment)",
1078                ),
1079                Err(e) => self.finding(&child, format!("cannot inspect ({})", e.kind())),
1080            }
1081        }
1082    }
1083
1084    /// `modes/<env>/`: `resources/` and `tags/` only.
1085    fn read_env(&mut self, dir: &Path, rel: &str) {
1086        if !self.require_dir(dir, rel) {
1087            return;
1088        }
1089        for (name, path) in self.sorted_entries(dir, rel) {
1090            let child = join(rel, &name);
1091            if self.dot_entry(&path, &child, &name) {
1092                continue;
1093            }
1094            match name.as_str() {
1095                "resources" | "tags" => self.walk(&path, &child),
1096                _ => self.finding(
1097                    &child,
1098                    "unexpected entry under modes/<env>/ (expected: resources/, tags/)",
1099                ),
1100            }
1101        }
1102    }
1103
1104    /// Whether `dir` is a real directory (not a symlink); findings otherwise.
1105    fn require_dir(&mut self, dir: &Path, rel: &str) -> bool {
1106        match std::fs::symlink_metadata(dir) {
1107            Ok(meta) if meta.file_type().is_symlink() => {
1108                self.finding(rel, "is a symlink; symlinks are refused");
1109                false
1110            }
1111            Ok(meta) if meta.file_type().is_dir() => true,
1112            Ok(_) => {
1113                self.finding(rel, "must be a directory");
1114                false
1115            }
1116            Err(e) => {
1117                self.finding(rel, format!("cannot inspect ({})", e.kind()));
1118                false
1119            }
1120        }
1121    }
1122
1123    /// Reject a non-regular entry; returns whether `path` is a regular file.
1124    fn check_regular(&mut self, path: &Path, rel: &str) -> bool {
1125        match std::fs::symlink_metadata(path) {
1126            Ok(meta) if meta.file_type().is_symlink() => {
1127                self.finding(rel, "is a symlink; symlinks are refused");
1128                false
1129            }
1130            Ok(meta) if meta.file_type().is_file() => true,
1131            Ok(_) => {
1132                self.finding(rel, "is not a regular file or directory");
1133                false
1134            }
1135            Err(e) => {
1136                self.finding(rel, format!("cannot inspect ({})", e.kind()));
1137                false
1138            }
1139        }
1140    }
1141
1142    /// Handle a dot-named entry: a regular file named `.gitkeep` is skipped
1143    /// at any depth, everything else that starts with `.` is a finding and is
1144    /// neither read nor walked. Only the first character decides, so
1145    /// `com.inductiveautomation.perspective` is not a dot-name. Returns
1146    /// whether `name` was a dot-name (and so is handled).
1147    fn dot_entry(&mut self, path: &Path, rel: &str, name: &str) -> bool {
1148        if !name.starts_with('.') {
1149            return false;
1150        }
1151        match std::fs::symlink_metadata(path) {
1152            Ok(meta) if name == ".gitkeep" && meta.file_type().is_file() => {}
1153            Ok(meta) if meta.file_type().is_symlink() => {
1154                self.finding(rel, "is a symlink; symlinks are refused");
1155            }
1156            _ => self.finding(
1157                rel,
1158                "name starts with `.`; dot-named entries are refused (only a `.gitkeep` file is skipped)",
1159            ),
1160        }
1161        true
1162    }
1163
1164    /// Walk directory `dir` (repo-gateway-relative `rel`), collecting files.
1165    fn walk(&mut self, dir: &Path, rel: &str) {
1166        if !self.require_dir(dir, rel) {
1167            return;
1168        }
1169        for (name, path) in self.sorted_entries(dir, rel) {
1170            let child = join(rel, &name);
1171            if self.dot_entry(&path, &child, &name) {
1172                continue;
1173            }
1174            let meta = match std::fs::symlink_metadata(&path) {
1175                Ok(meta) => meta,
1176                Err(e) => {
1177                    self.finding(&child, format!("cannot inspect ({})", e.kind()));
1178                    continue;
1179                }
1180            };
1181            let kind = meta.file_type();
1182            if kind.is_symlink() {
1183                self.finding(&child, "is a symlink; symlinks are refused");
1184            } else if kind.is_dir() {
1185                self.walk(&path, &child);
1186            } else if kind.is_file() {
1187                match RelPath::new(&child) {
1188                    Err(why) => self.finding(&child, format!("unusable path: {why}")),
1189                    Ok(key) => match std::fs::read(&path) {
1190                        Ok(bytes) => {
1191                            self.files.insert(key, bytes);
1192                        }
1193                        Err(e) => self.finding(&child, format!("cannot read file ({})", e.kind())),
1194                    },
1195                }
1196            } else {
1197                self.finding(&child, "is not a regular file or directory");
1198            }
1199        }
1200    }
1201}
1202
1203fn join(rel: &str, name: &str) -> String {
1204    if rel.is_empty() {
1205        name.to_string()
1206    } else {
1207        format!("{rel}/{name}")
1208    }
1209}
1210
1211#[cfg(test)]
1212mod tests {
1213    use super::*;
1214
1215    #[test]
1216    fn empty_listing_digest_is_sha256_of_nothing() {
1217        assert_eq!(RenderedTree::default().render_sha256(), EMPTY_RENDER_SHA256);
1218        assert_eq!(sha256_hex(b""), EMPTY_RENDER_SHA256);
1219    }
1220
1221    #[test]
1222    fn digest_follows_the_documented_listing() {
1223        let mut tree = RenderedTree::default();
1224        tree.files
1225            .insert(RelPath::new("b/x").unwrap(), b"two".to_vec());
1226        tree.files
1227            .insert(RelPath::new("a/x").unwrap(), b"one".to_vec());
1228        let mut listing = Vec::new();
1229        for (p, c) in [("a/x", &b"one"[..]), ("b/x", &b"two"[..])] {
1230            listing.extend_from_slice(p.as_bytes());
1231            listing.push(0);
1232            listing.extend_from_slice(sha256_hex(c).as_bytes());
1233            listing.push(b'\n');
1234        }
1235        assert_eq!(tree.render_sha256(), sha256_hex(&listing));
1236        assert_eq!(tree.bytes(), 6);
1237    }
1238
1239    #[cfg(unix)]
1240    #[test]
1241    fn walk_capped_refuses_a_control_character_in_a_name() {
1242        // WR-05: a local file name must not reach the plan renderer raw.
1243        let dir = tempfile::tempdir().expect("tempdir");
1244        std::fs::write(dir.path().join("ok.json"), b"{}").expect("write");
1245        std::fs::write(dir.path().join("evil\u{1b}[31m.json"), b"{}").expect("write");
1246        let (mut files, mut dirs) = (BTreeMap::new(), BTreeSet::new());
1247        let why = walk_capped(dir.path(), "root", &mut files, &mut dirs, 10)
1248            .expect_err("a control character is refused");
1249        assert!(why.contains("control character"), "{why}");
1250        assert!(
1251            !why.contains('\u{1b}'),
1252            "the message is itself sanitised: {why:?}"
1253        );
1254    }
1255
1256    #[test]
1257    fn walk_capped_stops_at_the_entry_cap() {
1258        let dir = tempfile::tempdir().expect("tempdir");
1259        for name in ["a", "b", "c"] {
1260            std::fs::write(dir.path().join(name), b"x").expect("write");
1261        }
1262        let (mut files, mut dirs) = (BTreeMap::new(), BTreeSet::new());
1263        walk_capped(dir.path(), "root", &mut files, &mut dirs, 3).expect("three fit");
1264        assert_eq!(files.len(), 3);
1265
1266        let (mut files, mut dirs) = (BTreeMap::new(), BTreeSet::new());
1267        let why = walk_capped(dir.path(), "root", &mut files, &mut dirs, 2)
1268            .expect_err("three entries exceed a cap of two");
1269        assert!(why.contains("more than 2 entries"), "{why}");
1270    }
1271
1272    #[test]
1273    fn walk_capped_stops_a_flat_directory_before_enumerating_it() {
1274        // A lazy listing of far more entries than the cap: the collector must
1275        // stop pulling as soon as the cap is crossed, not drain the iterator.
1276        let pulled = std::cell::Cell::new(0_usize);
1277        let listing = (0..1_000_000_usize).map(|i| {
1278            pulled.set(pulled.get() + 1);
1279            Ok((
1280                std::ffi::OsString::from(format!("e{i}")),
1281                PathBuf::from("x"),
1282            ))
1283        });
1284        let why = collect_capped(listing, "root", 0, 5)
1285            .expect_err("a flat directory over the cap must fail");
1286        assert!(why.contains("more than 5 entries (at root/"), "{why}");
1287        assert!(pulled.get() <= 6, "pulled {} entries", pulled.get());
1288
1289        // Entries already held by the walk count against the same cap, and
1290        // the entries that fit stay sorted by name bytes.
1291        let fits = [("b", "p"), ("a", "p")]
1292            .into_iter()
1293            .map(|(n, p)| Ok((std::ffi::OsString::from(n), PathBuf::from(p))));
1294        let got = collect_capped(fits, "root", 3, 5).expect("two more fit under five");
1295        assert_eq!(
1296            got.iter().map(|e| e.0.as_str()).collect::<Vec<_>>(),
1297            ["a", "b"]
1298        );
1299        let fits = [("b", "p"), ("a", "p")]
1300            .into_iter()
1301            .map(|(n, p)| Ok((std::ffi::OsString::from(n), PathBuf::from(p))));
1302        assert!(collect_capped(fits, "root", 4, 5).is_err());
1303    }
1304
1305    #[test]
1306    fn walk_capped_counts_a_flat_directory_on_disk() {
1307        let dir = tempfile::tempdir().expect("tempdir");
1308        for i in 0..10 {
1309            std::fs::write(dir.path().join(format!("f{i}")), b"x").expect("write");
1310        }
1311        let (mut files, mut dirs) = (BTreeMap::new(), BTreeSet::new());
1312        let why = walk_capped(dir.path(), "root", &mut files, &mut dirs, 4)
1313            .expect_err("ten entries exceed a cap of four");
1314        assert!(why.contains("more than 4 entries"), "{why}");
1315    }
1316
1317    #[cfg(unix)]
1318    #[test]
1319    fn open_regular_file_rejects_a_swapped_inode() {
1320        let dir = tempfile::tempdir().expect("tempdir");
1321        let outside = tempfile::tempdir().expect("tempdir");
1322        let inside = dir.path().join("f");
1323        std::fs::write(&inside, b"inside").expect("write");
1324        let secret = outside.path().join("secret");
1325        std::fs::write(&secret, b"outside").expect("write");
1326
1327        // The check-time metadata is of the real file.
1328        let before = std::fs::symlink_metadata(&inside).expect("meta");
1329        let (_, held) =
1330            open_regular_file(dir.path(), Path::new("f"), &before).expect("unswapped file opens");
1331        assert_eq!(held.len(), 6);
1332
1333        // Swap the file for a symlink to an outside file after the check.
1334        // Rename the original aside rather than delete it: a freed inode
1335        // number can be handed to the next file created (Linux does), which
1336        // would give the replacement below the checked file's (dev, ino).
1337        std::fs::rename(&inside, dir.path().join("original")).expect("rename");
1338        std::os::unix::fs::symlink(&secret, &inside).expect("symlink");
1339        assert!(matches!(
1340            open_regular_file(dir.path(), Path::new("f"), &before),
1341            Err(OpenError::Changed)
1342        ));
1343
1344        // Swap in a different regular file: also a changed inode.
1345        std::fs::remove_file(&inside).expect("remove");
1346        std::fs::write(&inside, b"replaced").expect("write");
1347        assert!(matches!(
1348            open_regular_file(dir.path(), Path::new("f"), &before),
1349            Err(OpenError::Changed)
1350        ));
1351    }
1352
1353    #[cfg(unix)]
1354    #[test]
1355    fn open_regular_file_does_not_block_on_a_swapped_in_fifo() {
1356        // WR-04: a regular file replaced by a FIFO after the check must be
1357        // refused, not waited on until a writer appears.
1358        let dir = tempfile::tempdir().expect("tempdir");
1359        let path = dir.path().join("f");
1360        std::fs::write(&path, b"regular").expect("write");
1361        let before = std::fs::symlink_metadata(&path).expect("meta");
1362        std::fs::remove_file(&path).expect("remove");
1363        let made = std::process::Command::new("mkfifo")
1364            .arg(&path)
1365            .status()
1366            .expect("mkfifo runs");
1367        assert!(made.success(), "mkfifo");
1368
1369        let root = dir.path().to_path_buf();
1370        let (tx, rx) = std::sync::mpsc::channel();
1371        std::thread::spawn(move || {
1372            let outcome = open_regular_file(&root, Path::new("f"), &before).map(|_| ());
1373            let _ = tx.send(outcome);
1374        });
1375        let outcome = rx
1376            .recv_timeout(std::time::Duration::from_secs(5))
1377            .expect("opening a FIFO must not block");
1378        assert!(matches!(outcome, Err(OpenError::Changed)), "{outcome:?}");
1379    }
1380
1381    #[cfg(unix)]
1382    #[test]
1383    fn open_regular_file_does_not_follow_a_swapped_intermediate_directory() {
1384        let root = tempfile::tempdir().expect("tempdir");
1385        let outside = tempfile::tempdir().expect("tempdir");
1386        // `a/b/f` resolves outside when `a/b` is swapped (outside/f) and when
1387        // `a` is swapped (outside/b/f).
1388        std::fs::create_dir(outside.path().join("b")).expect("mkdir");
1389        std::fs::write(outside.path().join("f"), b"outside-secret").expect("write");
1390        std::fs::write(outside.path().join("b/f"), b"outside-secret").expect("write");
1391        std::fs::create_dir_all(root.path().join("a/b")).expect("mkdir");
1392        std::fs::write(root.path().join("a/b/f"), b"inside").expect("write");
1393        let rel = Path::new("a/b/f");
1394
1395        let before = std::fs::symlink_metadata(root.path().join(rel)).expect("meta");
1396        let (mut file, held) = open_regular_file(root.path(), rel, &before).expect("plain open");
1397        assert_eq!(held.len(), 6);
1398        let mut text = String::new();
1399        file.read_to_string(&mut text).expect("read");
1400        assert_eq!(text, "inside");
1401
1402        // Swap the deepest directory, then a shallower one, for a symlink to
1403        // an outside directory holding a same-named regular file. The stale
1404        // metadata is what a fresh `symlink_metadata(path)` would return
1405        // after the swap: the outside file, since it follows parents.
1406        for swapped in ["a/b", "a"] {
1407            let target = root.path().join(swapped);
1408            let moved = root.path().join("moved");
1409            std::fs::rename(&target, &moved).expect("move aside");
1410            std::os::unix::fs::symlink(outside.path(), &target).expect("symlink");
1411            let after = std::fs::symlink_metadata(root.path().join(rel)).expect("meta");
1412            assert!(after.is_file(), "the full path still resolves to a file");
1413            assert!(
1414                matches!(
1415                    open_regular_file(root.path(), rel, &after),
1416                    Err(OpenError::Changed)
1417                ),
1418                "a symlinked `{swapped}` must be refused even with fresh metadata"
1419            );
1420            std::fs::remove_file(&target).expect("unlink");
1421            std::fs::rename(&moved, &target).expect("restore");
1422        }
1423
1424        // A symlinked final component and path escapes are refused too.
1425        std::fs::remove_file(root.path().join(rel)).expect("remove");
1426        std::os::unix::fs::symlink(outside.path().join("f"), root.path().join(rel))
1427            .expect("symlink");
1428        let after = std::fs::symlink_metadata(root.path().join(rel)).expect("meta");
1429        assert!(matches!(
1430            open_regular_file(root.path(), rel, &after),
1431            Err(OpenError::Changed)
1432        ));
1433        for escape in ["../x", "/etc/passwd", "a/../f", ""] {
1434            assert!(
1435                matches!(
1436                    open_regular_file(root.path(), Path::new(escape), &before),
1437                    Err(OpenError::Changed)
1438                ),
1439                "{escape:?}"
1440            );
1441        }
1442        // An absent file is an I/O error, not a swap.
1443        assert!(matches!(
1444            open_regular_file(root.path(), Path::new("a/b/missing"), &before),
1445            Err(OpenError::Io(e)) if e.kind() == std::io::ErrorKind::NotFound
1446        ));
1447    }
1448
1449    #[cfg(unix)]
1450    #[test]
1451    fn read_repo_file_refuses_a_symlinked_deep_parent_and_caps_bytes() {
1452        let repo = tempfile::tempdir().expect("tempdir");
1453        let outside = tempfile::tempdir().expect("tempdir");
1454        std::fs::write(outside.path().join("m.json"), b"{}").expect("write");
1455        std::fs::create_dir_all(repo.path().join("a")).expect("mkdir");
1456        std::os::unix::fs::symlink(outside.path(), repo.path().join("a/b")).expect("symlink");
1457        let err = read_repo_file(repo.path(), "a/b/m.json", None)
1458            .expect_err("a symlinked second component must be refused");
1459        assert!(err.to_string().contains("symlink"), "{err}");
1460
1461        std::fs::write(repo.path().join("a/ok.json"), b"{}").expect("write");
1462        assert_eq!(
1463            read_repo_file(repo.path(), "a/ok.json", None).expect("plain read"),
1464            b"{}"
1465        );
1466    }
1467
1468    #[test]
1469    fn from_files_rejects_hostile_paths() {
1470        assert!(SourceTree::from_files([("../x", "a")]).is_err());
1471        assert!(SourceTree::from_files([("resources/a/b/c", "a")]).is_ok());
1472    }
1473}