Skip to main content

submilli_engine/runtime/
vfs.rs

1//! VFS root directory plumbing.
2//!
3//! The interpreter is agnostic about *how* a VFS came to exist — it only
4//! cares about the directory on disk that backs it. [`Vfs`] carries that
5//! path and, when the interpreter allocated it, owns cleanup.
6//!
7//! It also carries the capability handle every guest path is opened through.
8//! The root is opened once with ambient authority at construction; from then on
9//! every host function resolves against that handle, so a symlink pointing out of
10//! the root is refused by the kernel rather than checked for.
11//!
12//! A VFS may also carry mounts: other directories (named volumes) grafted at
13//! fixed guest paths below the root. Each has its own handle, access mode and
14//! size limit, and a guest path is routed to exactly one of them by
15//! [`Vfs::locate`] before anything is opened.
16
17use std::ffi::{OsStr, OsString};
18use std::fmt;
19use std::io::{self, Read};
20use std::path::{Component, Path, PathBuf};
21use std::sync::Arc;
22
23use cap_fs_ext::{DirExt, FollowSymlinks, OpenOptionsFollowExt};
24use cap_std::ambient_authority;
25use cap_std::fs::Dir;
26use tempfile::TempDir;
27
28use crate::runtime::disk_quota::DiskQuota;
29use crate::runtime::fs::{FileIdentity, dir_identity};
30
31/// Which blueprint VFS mode this directory backs. Kept independent of the
32/// `submilli-blueprint` enum so the interpreter doesn't depend on that crate;
33/// the server maps one onto the other.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
35pub enum VfsMode {
36    None,
37    Ephemeral,
38    PerSession,
39    Named,
40}
41
42/// Whether guest code may change what a volume holds.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
44pub enum Access {
45    ReadOnly,
46    ReadWrite,
47}
48
49impl Access {
50    pub fn as_str(self) -> &'static str {
51        match self {
52            Self::ReadOnly => "read_only",
53            Self::ReadWrite => "read_write",
54        }
55    }
56}
57
58#[derive(Debug, Clone)]
59pub struct Vfs {
60    root: PathBuf,
61    cwd: Arc<str>,
62    ancestors: Arc<[FileIdentity]>,
63    mode: VfsMode,
64    /// `None` only for [`VfsMode::None`], which backs no directory.
65    dir: Option<Arc<Dir>>,
66    _owned: Option<Arc<TempDir>>,
67    /// The blueprint's `size_limit`, shared by every clone so the git worker and
68    /// the program charge one counter.
69    quota: Option<Arc<DiskQuota>>,
70    access: Access,
71    /// The named volume backing the root, under [`VfsMode::Named`].
72    volume: Option<Arc<str>>,
73    mounts: Arc<[Mount]>,
74}
75
76/// A named volume grafted at a guest path below the root.
77#[derive(Debug, Clone)]
78pub struct Mount {
79    /// The guest path, such as `/memory`.
80    guest: Arc<str>,
81    /// The same path relative to the root, such as `memory`.
82    rel: PathBuf,
83    volume: Arc<str>,
84    dir: Arc<Dir>,
85    /// The host directory `dir` was opened on.
86    host: Arc<Path>,
87    access: Access,
88    quota: Option<Arc<DiskQuota>>,
89    /// The identity and exact name of each directory on the way to the mount
90    /// point in the root, the mount point last. A case-insensitive filesystem
91    /// opens these under other spellings too, some not ASCII at all, so the
92    /// mount-point guard recognizes them by identity rather than by name.
93    placeholders: Arc<[Placeholder]>,
94    ancestors: Arc<[FileIdentity]>,
95}
96
97impl Mount {
98    pub fn guest_path(&self) -> &str {
99        &self.guest
100    }
101
102    pub fn volume(&self) -> &str {
103        &self.volume
104    }
105
106    pub fn access(&self) -> Access {
107        self.access
108    }
109
110    pub fn quota(&self) -> Option<&Arc<DiskQuota>> {
111        self.quota.as_ref()
112    }
113
114    pub(crate) fn rel(&self) -> &Path {
115        &self.rel
116    }
117
118    pub fn dir(&self) -> &Arc<Dir> {
119        &self.dir
120    }
121
122    pub fn host(&self) -> &Path {
123        &self.host
124    }
125
126    pub(crate) fn placeholders(&self) -> &[Placeholder] {
127        &self.placeholders
128    }
129}
130
131/// A directory in the root on the way to a mount point, or the mount point
132/// itself: what a spelling that reaches it must be named as.
133#[derive(Debug, Clone)]
134pub(crate) struct Placeholder {
135    pub(crate) identity: FileIdentity,
136    /// Its parent, exactly as the mount spells it; `""` for the root.
137    pub(crate) parent: PathBuf,
138    pub(crate) name: OsString,
139}
140
141/// What the server hands [`Vfs::with_mount`]: a volume it has resolved by name.
142pub struct MountSpec {
143    /// Absolute guest path, already validated by the blueprint parser; checked
144    /// again here so a direct embedder cannot build an ambiguous table.
145    pub guest_path: String,
146    pub host: PathBuf,
147    pub volume: String,
148    pub access: Access,
149    /// Shared by every VFS that mounts the same volume, so one limit spans them.
150    pub quota: Option<Arc<DiskQuota>>,
151}
152
153/// The volume a resolved guest path lives in: where writes are charged and
154/// whether they are allowed at all.
155#[derive(Debug, Clone)]
156pub struct Placement {
157    access: Access,
158    quota: Option<Arc<DiskQuota>>,
159    /// The mount's guest path, or `None` for the root volume.
160    mount: Option<Arc<str>>,
161    /// The mounts below this volume; only the root has any. A change to the root
162    /// must not remove, replace or write through one of their mount points.
163    nested: Arc<[Mount]>,
164    /// The host directory the volume's handle was opened on.
165    host: Arc<Path>,
166    volume: Option<Arc<str>>,
167    ancestors: Arc<[FileIdentity]>,
168    protected_roots: Arc<[FileIdentity]>,
169    protected_ancestors: Arc<[FileIdentity]>,
170}
171
172impl Placement {
173    pub fn access(&self) -> Access {
174        self.access
175    }
176
177    pub fn quota(&self) -> Option<&Arc<DiskQuota>> {
178        self.quota.as_ref()
179    }
180
181    /// The guest path of the volume's mount point: `/` for the root.
182    pub fn mount_point(&self) -> &str {
183        self.mount.as_deref().unwrap_or("/")
184    }
185
186    pub(crate) fn ancestors(&self) -> &[FileIdentity] {
187        &self.ancestors
188    }
189
190    pub(crate) fn protects(&self, identity: FileIdentity, recursive: bool) -> bool {
191        self.protected_roots.contains(&identity)
192            || (recursive && self.protected_ancestors.contains(&identity))
193    }
194
195    pub(crate) fn protects_descendants(&self, identity: FileIdentity) -> bool {
196        self.protected_ancestors.contains(&identity)
197    }
198
199    pub(crate) fn nested(&self) -> &[Mount] {
200        &self.nested
201    }
202
203    /// The host directory the volume's handle was opened on. Only Git uses it,
204    /// to let gix open a repository in place after checking that the path
205    /// names the directory the handle holds; see `stdlib::git::location`.
206    pub(crate) fn host(&self) -> &Path {
207        &self.host
208    }
209
210    /// Whether two placements are the same volume, so a rename between them
211    /// stays within one directory handle and one size limit.
212    pub fn same_volume(&self, other: &Self) -> bool {
213        match (&self.volume, &other.volume) {
214            (Some(left), Some(right)) => left == right,
215            (None, None) => self.mount == other.mount,
216            _ => false,
217        }
218    }
219}
220
221/// Why a mount could not be added.
222#[derive(Debug)]
223pub enum MountError {
224    /// The VFS has no root directory to mount below (`vfs: none`).
225    RootDisabled,
226    /// The guest path is not an absolute, normalized path.
227    BadPath(String),
228    /// `/` is the root itself.
229    AtRoot,
230    /// The guest path names Git metadata.
231    ProtectedPath(String),
232    /// One mount would sit inside another.
233    Nested {
234        outer: String,
235        inner: String,
236    },
237    /// The same volume is already mounted, or backs the root.
238    DuplicateVolume {
239        volume: String,
240        at: String,
241    },
242    TooMany,
243    /// The mount point exists in the root but is not a plain directory, or
244    /// does not exist in a read-only root, which it may not create.
245    MountPointUnavailable {
246        path: String,
247        reason: &'static str,
248    },
249    /// Preparing the mount point in the root failed.
250    RootIo(io::Error),
251    /// Opening the volume's directory failed. Carries no host path; the
252    /// caller logs that.
253    Io(io::Error),
254}
255
256impl fmt::Display for MountError {
257    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
258        match self {
259            Self::RootDisabled => {
260                f.write_str("mounts need a filesystem root, and vfs mode `none` has none")
261            }
262            Self::BadPath(path) => write!(
263                f,
264                "mount path `{path}` must be absolute and normalized, made of ASCII letters, \
265                 digits, `.`, `_` and `-`, such as `/memory`"
266            ),
267            Self::AtRoot => f.write_str("a volume cannot be mounted at `/`; that is the root"),
268            Self::ProtectedPath(path) => {
269                write!(f, "mount path `{path}` names Git metadata")
270            }
271            Self::Nested { outer, inner } => {
272                write!(
273                    f,
274                    "mount `{inner}` is inside mount `{outer}`; mounts may not nest"
275                )
276            }
277            Self::DuplicateVolume { volume, at } => {
278                write!(f, "volume `{volume}` is already mounted at `{at}`")
279            }
280            Self::TooMany => write!(f, "more than {MAX_MOUNTS} mounts"),
281            Self::MountPointUnavailable { path, reason } => {
282                write!(f, "mount point `{path}` {reason}")
283            }
284            Self::RootIo(err) => write!(f, "the mount point could not be prepared: {err}"),
285            Self::Io(err) => write!(f, "{err}"),
286        }
287    }
288}
289
290impl std::error::Error for MountError {}
291
292/// The most mounts one VFS carries; routing a path scans them all. The blueprint
293/// parser caps `vfs.mounts` at the same number (`submilli_blueprint::MAX_MOUNTS`)
294/// so a valid blueprint never reaches this; it holds for direct embedders.
295pub const MAX_MOUNTS: usize = 16;
296
297/// The name prefix of every directory [`Vfs::tempdir`] and [`Vfs::tempdir_in`]
298/// create, so a host can recognise its leftover scratch.
299pub const TEMPDIR_PREFIX: &str = "submilli-vfs-";
300
301impl Vfs {
302    /// A VFS that backs no directory. Every `submilli:fs.*` call traps; only
303    /// `fs.info()` works (reporting `mode: none`).
304    pub fn none() -> Self {
305        Self {
306            root: PathBuf::new(),
307            mode: VfsMode::None,
308            dir: None,
309            _owned: None,
310            quota: None,
311            access: Access::ReadWrite,
312            volume: None,
313            mounts: Arc::from([]),
314            cwd: Arc::from("/"),
315            ancestors: Arc::from([]),
316        }
317    }
318
319    /// Mount an existing host directory the interpreter does not own. Used for
320    /// `named` roots and for `per_session` dirs owned by the server.
321    pub fn external_with_mode(root: PathBuf, mode: VfsMode) -> io::Result<Self> {
322        let meta = std::fs::metadata(&root)
323            .map_err(|e| io::Error::new(e.kind(), format!("VFS root {}: {e}", root.display())))?;
324        if !meta.is_dir() {
325            return Err(io::Error::new(
326                io::ErrorKind::NotADirectory,
327                format!("VFS root {} is not a directory", root.display()),
328            ));
329        }
330        let dir = open_root(&root)?;
331        let identity = dir_identity(&dir, Path::new("."), &dir.dir_metadata()?)?;
332        Ok(Self {
333            root,
334            mode,
335            dir: Some(dir),
336            _owned: None,
337            quota: None,
338            access: Access::ReadWrite,
339            volume: None,
340            mounts: Arc::from([]),
341            cwd: Arc::from("/"),
342            ancestors: Arc::from([identity]),
343        })
344    }
345
346    /// An existing host directory, as `submilli run --vfs` exposes it.
347    pub fn external(root: PathBuf) -> io::Result<Self> {
348        Self::external_with_mode(root, VfsMode::Named)
349    }
350
351    pub fn tempdir() -> io::Result<Self> {
352        let td = tempfile::Builder::new().prefix(TEMPDIR_PREFIX).tempdir()?;
353        Self::from_owned(td)
354    }
355
356    /// Like [`tempdir`](Self::tempdir) but allocated under `parent`, which must
357    /// already exist. Lets the host place ephemeral scratch on a chosen volume,
358    /// and decide itself whether a missing one may be created.
359    pub fn tempdir_in(parent: &Path) -> io::Result<Self> {
360        let td = tempfile::Builder::new()
361            .prefix(TEMPDIR_PREFIX)
362            .tempdir_in(parent)?;
363        Self::from_owned(td)
364    }
365
366    fn from_owned(td: TempDir) -> io::Result<Self> {
367        let root = td.path().to_path_buf();
368        let dir = open_root(&root)?;
369        let identity = dir_identity(&dir, Path::new("."), &dir.dir_metadata()?)?;
370        Ok(Self {
371            root,
372            mode: VfsMode::Ephemeral,
373            dir: Some(dir),
374            _owned: Some(Arc::new(td)),
375            quota: None,
376            access: Access::ReadWrite,
377            volume: None,
378            mounts: Arc::from([]),
379            cwd: Arc::from("/"),
380            ancestors: Arc::from([identity]),
381        })
382    }
383
384    /// The host path backing this VFS. Server-side logging and test setup only —
385    /// it is never the thing a guest path is resolved against. Empty for
386    /// [`VfsMode::None`].
387    pub fn root(&self) -> &Path {
388        &self.root
389    }
390
391    /// The capability handle guest paths resolve against, or `None` under
392    /// [`VfsMode::None`].
393    ///
394    /// A handle outlives the directory it was opened on: after the root is deleted
395    /// the handle still answers `is_dir()`, so it is not a liveness check.
396    pub fn dir(&self) -> Option<&Arc<Dir>> {
397        self.dir.as_ref()
398    }
399
400    pub fn mode(&self) -> VfsMode {
401        self.mode
402    }
403
404    /// Whether dropping the last owner automatically removes this directory.
405    pub fn is_disposable(&self) -> bool {
406        self._owned.is_some()
407    }
408
409    /// Enforce `limit` bytes on the files this VFS holds, starting from what the
410    /// directory holds now; see [`with_measured_limit`](Self::with_measured_limit).
411    pub fn with_size_limit(self, limit: u64) -> Self {
412        let measured = self.measure_usage().ok();
413        self.with_measured_limit(limit, measured)
414    }
415
416    /// Enforce `limit` bytes, starting from `used`, what a walk of the directory
417    /// found. A directory already over the limit still opens: reads and removals
418    /// work, and writes that grow it are refused until it is back under. One that
419    /// couldn't be measured (`None`) opens the same way, as if full, so a program
420    /// can still remove files and let a later run measure it.
421    pub fn with_measured_limit(mut self, limit: u64, used: Option<u64>) -> Self {
422        let quota = match used {
423            Some(used) => DiskQuota::new(limit, used),
424            None => DiskQuota::unmeasured(limit),
425        };
426        self.quota = Some(Arc::new(quota));
427        self
428    }
429
430    /// Charge the root to `quota`, a limit the server shares between every VFS
431    /// that opens the same named volume.
432    pub fn with_shared_quota(mut self, quota: Arc<DiskQuota>) -> Self {
433        self.quota = Some(quota);
434        self
435    }
436
437    /// The root's size limit and its running count, when one applies.
438    pub fn quota(&self) -> Option<&Arc<DiskQuota>> {
439        self.quota.as_ref()
440    }
441
442    /// Whether guest code may change the root. Mounts carry their own access.
443    pub fn with_access(mut self, access: Access) -> Self {
444        self.access = access;
445        self
446    }
447
448    pub fn access(&self) -> Access {
449        self.access
450    }
451
452    /// Record the named volume backing the root, for `fs.info()`.
453    pub fn with_volume_name(mut self, volume: &str) -> Self {
454        self.volume = Some(Arc::from(volume));
455        self
456    }
457
458    pub fn volume(&self) -> Option<&str> {
459        self.volume.as_deref()
460    }
461
462    pub fn mounts(&self) -> &[Mount] {
463        &self.mounts
464    }
465
466    /// Graft the volume `spec` describes at its guest path.
467    ///
468    /// In a writable root the mount point is created as an empty directory, so
469    /// listing its parent shows it like any other directory. A read-only root is
470    /// never written, so there the mount point must already exist.
471    pub fn with_mount(self, spec: MountSpec) -> Result<Self, MountError> {
472        self.with_mount_subpath(spec, None)
473    }
474
475    pub fn with_mount_subpath(
476        mut self,
477        spec: MountSpec,
478        sub_path: Option<&str>,
479    ) -> Result<Self, MountError> {
480        let root = self.dir.as_ref().ok_or(MountError::RootDisabled)?;
481        if self.mounts.len() >= MAX_MOUNTS {
482            return Err(MountError::TooMany);
483        }
484        let rel = mount_rel(&spec.guest_path)?;
485        if crate::runtime::fs::protected_metadata(&rel) {
486            return Err(MountError::ProtectedPath(spec.guest_path));
487        }
488        // Case is folded as the blueprint parser folds it, so two spellings of one
489        // directory on a case-insensitive filesystem cannot both be mounted.
490        for mount in self.mounts.iter() {
491            let (outer, inner) = if starts_with_folded(&rel, &mount.rel) {
492                (mount.guest.to_string(), spec.guest_path.clone())
493            } else if starts_with_folded(&mount.rel, &rel) {
494                (spec.guest_path.clone(), mount.guest.to_string())
495            } else {
496                continue;
497            };
498            return Err(MountError::Nested { outer, inner });
499        }
500        let placeholders = prepare_mount_point(root, &rel, self.access, &spec.guest_path)?;
501        let (dir, ancestors) = select_directory(
502            open_volume(&spec.host).map_err(MountError::Io)?,
503            sub_path,
504            spec.access,
505        )
506        .map_err(MountError::Io)?;
507        let mount = Mount {
508            guest: Arc::from(spec.guest_path.as_str()),
509            rel,
510            volume: Arc::from(spec.volume.as_str()),
511            dir,
512            host: Arc::from(
513                sub_path.map_or_else(|| spec.host.clone(), |path| spec.host.join(path)),
514            ),
515            access: spec.access,
516            quota: spec.quota,
517            placeholders: Arc::from(placeholders),
518            ancestors: Arc::from(ancestors),
519        };
520        let mut mounts = self.mounts.to_vec();
521        mounts.push(mount);
522        self.mounts = Arc::from(mounts);
523        Ok(self)
524    }
525
526    /// Charge the volume mounted at `guest_path` to `quota`, the limit the
527    /// server shares between every VFS that mounts it. No mount there is a
528    /// no-op.
529    pub fn with_mount_quota(mut self, guest_path: &str, quota: Arc<DiskQuota>) -> Self {
530        let mounts: Vec<Mount> = self
531            .mounts
532            .iter()
533            .cloned()
534            .map(|mut mount| {
535                if &*mount.guest == guest_path {
536                    mount.quota = Some(Arc::clone(&quota));
537                }
538                mount
539            })
540            .collect();
541        self.mounts = Arc::from(mounts);
542        self
543    }
544
545    /// Route a root-relative path (as the lexical pass produced it) to the
546    /// volume that holds it: the handle to open it through, the path relative
547    /// to that handle, and the volume's placement. `None` under
548    /// [`VfsMode::None`].
549    ///
550    /// Matching is by whole components, so `/memoryx` is not under `/memory`.
551    /// A mount point itself resolves to its volume's root, `.`.
552    pub fn locate(&self, rel: &Path) -> Option<(Arc<Dir>, PathBuf, Placement)> {
553        let root = self.dir.as_ref()?;
554        let (protected_roots, protected_ancestors) = self.protected_directories();
555        for mount in self.mounts.iter() {
556            if let Ok(rest) = rel.strip_prefix(&mount.rel) {
557                let rest = if rest.as_os_str().is_empty() {
558                    PathBuf::from(".")
559                } else {
560                    rest.to_path_buf()
561                };
562                let placement = Placement {
563                    access: mount.access,
564                    quota: mount.quota.clone(),
565                    mount: Some(Arc::clone(&mount.guest)),
566                    nested: Arc::from([]),
567                    host: Arc::clone(&mount.host),
568                    volume: Some(Arc::clone(&mount.volume)),
569                    ancestors: Arc::clone(&mount.ancestors),
570                    protected_roots,
571                    protected_ancestors,
572                };
573                return Some((Arc::clone(&mount.dir), rest, placement));
574            }
575        }
576        let placement = Placement {
577            access: self.access,
578            quota: self.quota.clone(),
579            mount: None,
580            nested: Arc::clone(&self.mounts),
581            host: Arc::from(self.root.as_path()),
582            volume: self.volume.clone(),
583            ancestors: Arc::clone(&self.ancestors),
584            protected_roots,
585            protected_ancestors,
586        };
587        Some((Arc::clone(root), rel.to_path_buf(), placement))
588    }
589
590    fn protected_directories(&self) -> (Arc<[FileIdentity]>, Arc<[FileIdentity]>) {
591        let mut roots = Vec::new();
592        let mut ancestors = self
593            .ancestors
594            .split_last()
595            .map_or(&[][..], |(_, parents)| parents)
596            .to_vec();
597        if let Some(identity) = self.ancestors.last() {
598            roots.push(*identity);
599            if !self.mounts.is_empty() {
600                ancestors.push(*identity);
601            }
602        }
603        for mount in self.mounts.iter() {
604            if let Some(identity) = mount.ancestors.last() {
605                roots.push(*identity);
606            }
607            if let Some((_, parents)) = mount.ancestors.split_last() {
608                ancestors.extend_from_slice(parents);
609            }
610            // Git must also avoid publishing inside hidden placeholders.
611            for placeholder in mount.placeholders.iter() {
612                ancestors.push(placeholder.identity);
613            }
614            if let Some(placeholder) = mount.placeholders.last() {
615                roots.push(placeholder.identity);
616            }
617        }
618        (Arc::from(roots), Arc::from(ancestors))
619    }
620
621    /// Guest working directory shared by I/O, policy, and introspection.
622    pub fn cwd(&self) -> &str {
623        &self.cwd
624    }
625
626    pub fn with_cwd(self, cwd: &str) -> Result<Self, crate::runtime::fs::ContainError> {
627        use crate::runtime::fs::{ContainError, resolve_content};
628        let vfs = self.with_cwd_path(cwd)?;
629        let target = resolve_content(&vfs, "/", cwd)?;
630        match target.open_dir() {
631            Ok(_) => {}
632            Err(ContainError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
633                target.create_dir_all()?;
634                target.open_dir()?;
635            }
636            Err(error) => return Err(error),
637        }
638        Ok(vfs)
639    }
640
641    /// Set the logical working directory without creating it on disk.
642    pub fn with_cwd_path(mut self, cwd: &str) -> Result<Self, crate::runtime::fs::ContainError> {
643        use crate::runtime::fs::{ContainError, guest_normalize};
644        if !cwd.starts_with('/') || cwd.len() > 4096 || guest_normalize("/", cwd)? != cwd {
645            return Err(ContainError::Io(io::Error::new(
646                io::ErrorKind::InvalidInput,
647                "cwd must be an absolute normalized guest path",
648            )));
649        }
650        self.cwd = Arc::from(cwd);
651        Ok(self)
652    }
653
654    /// Select a directory through the volume handle, without ambient reopening.
655    pub fn with_subpath(mut self, sub_path: Option<&str>) -> io::Result<Self> {
656        let root = self
657            .dir
658            .clone()
659            .ok_or_else(|| io::Error::other("filesystem disabled"))?;
660        let (dir, ancestors) = select_directory(root, sub_path, self.access)?;
661        self.dir = Some(dir);
662        if let Some(path) = sub_path {
663            self.root.push(path);
664        }
665        self.ancestors = Arc::from(ancestors);
666        Ok(self)
667    }
668
669    /// The bytes held by regular files under the root; see [`measure_dir`].
670    pub fn measure_usage(&self) -> io::Result<u64> {
671        match &self.dir {
672            Some(root) => measure_dir(root),
673            None => Ok(0),
674        }
675    }
676}
677
678/// The bytes held by regular files under the host directory `root`, as a size
679/// limit starting from it counts them. The error carries no host path; a tree with too
680/// many entries or nested too deep is [`io::ErrorKind::InvalidData`].
681pub fn measure_host_dir(root: &Path) -> io::Result<u64> {
682    measure_dir(&*open_volume(root)?)
683}
684
685/// As [`measure_host_dir`], but an entry removed mid-walk is not counted instead of
686/// failing the measure: for a tree being copied, never for a size limit's accounting.
687pub fn measure_host_dir_skipping_vanished(root: &Path) -> io::Result<u64> {
688    let mut total: u64 = 0;
689    let root = open_volume(root)?;
690    for_each_regular_file(&root, MAX_MEASURED_ENTRIES, true, |_, bytes| {
691        total = total.saturating_add(bytes);
692    })?;
693    Ok(total)
694}
695
696/// As [`measure_host_dir_skipping_vanished`], for the directory `sub` below the volume
697/// `volume`, which is no link all the way down. A `sub` that is not there is empty.
698pub fn measure_host_subdir_skipping_vanished(volume: &Path, sub: &Path) -> io::Result<u64> {
699    let Some(root) = open_host_subdir(volume, sub)? else {
700        return Ok(0);
701    };
702    let mut total: u64 = 0;
703    for_each_regular_file(&root, MAX_MEASURED_ENTRIES, true, |_, bytes| {
704        total = total.saturating_add(bytes);
705    })?;
706    Ok(total)
707}
708
709/// Opens the directory `sub` below the host directory `volume`, one component at a time
710/// without following links, so a link anywhere in `sub` is an error. `None` when a
711/// component is not there. `sub` must be relative with no `.` or `..`.
712pub fn open_host_subdir(volume: &Path, sub: &Path) -> io::Result<Option<Arc<Dir>>> {
713    let mut dir = open_volume(volume)?;
714    for component in sub.components() {
715        let Component::Normal(name) = component else {
716            return Err(io::Error::new(
717                io::ErrorKind::InvalidInput,
718                "a sub-path must be a normalized relative path",
719            ));
720        };
721        dir = match dir.open_dir_nofollow(name) {
722            Ok(child) => Arc::new(child),
723            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
724            Err(error) => {
725                return Err(io::Error::new(
726                    error.kind(),
727                    format!("{}: {error}", sub.display()),
728                ));
729            }
730        };
731    }
732    Ok(Some(dir))
733}
734
735/// The bytes held by regular files under `root`; see [`for_each_regular_file`].
736pub fn measure_dir(root: &Dir) -> io::Result<u64> {
737    let mut total: u64 = 0;
738    for_each_regular_file(root, MAX_MEASURED_ENTRIES, false, |_, bytes| {
739        total = total.saturating_add(bytes);
740    })?;
741    Ok(total)
742}
743
744/// Every regular file under `root` and its size, as a removal of the tree frees
745/// them; more than `max_entries` entries is an [`io::ErrorKind::InvalidData`] error.
746pub fn regular_files(root: &Dir, max_entries: usize) -> io::Result<Vec<(FileIdentity, u64)>> {
747    let mut files = Vec::new();
748    for_each_regular_file(root, max_entries, false, |file, bytes| {
749        files.push((file, bytes));
750    })?;
751    Ok(files)
752}
753
754/// Visit each regular file under `root`. Links are never followed, so a link cannot
755/// make a file count twice. The walk goes depth first, holding one open directory
756/// per level, so its descriptors and memory grow with depth, not with the tree.
757///
758/// An entry that cannot be read is an error, so a size limit treats the tree as full;
759/// with `skip_vanished` one that was removed meanwhile (`NotFound`) is left out instead.
760fn for_each_regular_file(
761    root: &Dir,
762    max_entries: usize,
763    skip_vanished: bool,
764    mut visit: impl FnMut(FileIdentity, u64),
765) -> io::Result<()> {
766    let mut entries: usize = 0;
767    let mut open: Vec<cap_std::fs::ReadDir> = vec![root.entries()?];
768    while let Some(dir) = open.last_mut() {
769        let Some(entry) = dir.next() else {
770            open.pop();
771            continue;
772        };
773        let entry = entry?;
774        entries += 1;
775        if entries > max_entries {
776            return Err(io::Error::new(
777                io::ErrorKind::InvalidData,
778                format!("more than {max_entries} entries"),
779            ));
780        }
781        let Some(kind) = vanished(entry.file_type(), skip_vanished)? else {
782            continue;
783        };
784        if kind.is_dir() {
785            if open.len() > MAX_MEASURED_DEPTH {
786                return Err(io::Error::new(
787                    io::ErrorKind::InvalidData,
788                    format!("directories nested more than {MAX_MEASURED_DEPTH} deep"),
789                ));
790            }
791            let Some(child) = vanished(
792                entry.open_dir().and_then(|dir| dir.entries()),
793                skip_vanished,
794            )?
795            else {
796                continue;
797            };
798            open.push(child);
799        } else if kind.is_file() {
800            // Full metadata, which Windows reads through a handle, carries the
801            // file's identity.
802            let Some(metadata) = vanished(
803                cap_fs_ext::DirEntryExt::full_metadata(&entry),
804                skip_vanished,
805            )?
806            else {
807                continue;
808            };
809            visit(FileIdentity::of(&metadata)?, metadata.len());
810        }
811    }
812    Ok(())
813}
814
815/// `None` when the entry was removed meanwhile and `skip` is set.
816fn vanished<T>(result: io::Result<T>, skip: bool) -> io::Result<Option<T>> {
817    match result {
818        Ok(value) => Ok(Some(value)),
819        Err(error) if skip && error.kind() == io::ErrorKind::NotFound => Ok(None),
820        Err(error) => Err(error),
821    }
822}
823
824/// Why [`copy_host_dir`] stopped.
825#[derive(Debug)]
826pub enum CopyDirError {
827    Io(io::Error),
828    /// The copy went past its byte cap.
829    OverCap,
830    /// More entries or deeper nesting than a walk goes through, as for [`measure_host_dir`].
831    TooMany,
832}
833
834impl From<io::Error> for CopyDirError {
835    fn from(error: io::Error) -> Self {
836        Self::Io(error)
837    }
838}
839
840/// Copies the host directory `from` to `to`, adding the bytes copied to `bytes` and stopping
841/// once they pass `cap`.
842///
843/// Nothing under `from` is followed: every entry is opened relative to its already open
844/// parent without following links, so a program swapping an entry for a link mid-copy
845/// cannot get a file from outside the tree copied. A link is recreated as a link; an entry
846/// removed meanwhile is skipped. Only `from` itself, which the caller vouches for, is
847/// resolved by path.
848pub fn copy_host_dir(
849    from: &Path,
850    to: &Path,
851    cap: u64,
852    bytes: &mut u64,
853) -> Result<(), CopyDirError> {
854    let source = open_volume(from)?;
855    std::fs::create_dir_all(to)?;
856    let target = Dir::open_ambient_dir(to, ambient_authority())?;
857    let mut copy = TreeCopy {
858        cap,
859        bytes,
860        entries: 0,
861        rel: PathBuf::new(),
862    };
863    copy.dir(&source, &target, 0)
864}
865
866/// As [`copy_host_dir`], for the directory `sub` below the volume `volume`, copied to
867/// `to_root/sub`. `sub` is walked without following links, so one that is a link, or
868/// passes through one, is refused. `to_root` is made either way; a `sub` that is not in
869/// `volume` copies nothing.
870pub fn copy_host_subdir(
871    volume: &Path,
872    sub: &Path,
873    to_root: &Path,
874    cap: u64,
875    bytes: &mut u64,
876) -> Result<(), CopyDirError> {
877    std::fs::create_dir_all(to_root)?;
878    let Some(source) = open_host_subdir(volume, sub)? else {
879        return Ok(());
880    };
881    let mut target = Dir::open_ambient_dir(to_root, ambient_authority())?;
882    for component in sub.components() {
883        target = match target.create_dir(component) {
884            Ok(()) => target.open_dir_nofollow(component)?,
885            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
886                target.open_dir_nofollow(component)?
887            }
888            Err(error) => return Err(error.into()),
889        };
890    }
891    let mut copy = TreeCopy {
892        cap,
893        bytes,
894        entries: 0,
895        rel: sub.to_path_buf(),
896    };
897    copy.dir(&source, &target, 0)
898}
899
900#[derive(Clone, Copy)]
901enum EntryKind {
902    Dir,
903    File,
904    Link,
905    Other,
906}
907
908impl EntryKind {
909    fn of(file_type: cap_std::fs::FileType) -> Self {
910        if file_type.is_dir() {
911            Self::Dir
912        } else if file_type.is_file() {
913            Self::File
914        } else if file_type.is_symlink() {
915            Self::Link
916        } else {
917            Self::Other
918        }
919    }
920}
921
922struct TreeCopy<'a> {
923    cap: u64,
924    bytes: &'a mut u64,
925    entries: usize,
926    /// The directory being copied, relative to the root, for errors.
927    rel: PathBuf,
928}
929
930impl TreeCopy<'_> {
931    /// An error naming the entry it came from, relative to the root.
932    fn at(&self, name: &OsStr, error: io::Error) -> CopyDirError {
933        let path = self.rel.join(name);
934        let shown = if path.as_os_str().is_empty() {
935            Path::new(".")
936        } else {
937            &path
938        };
939        io::Error::new(error.kind(), format!("{}: {error}", shown.display())).into()
940    }
941
942    fn dir(&mut self, source: &Dir, target: &Dir, depth: usize) -> Result<(), CopyDirError> {
943        let entries = source
944            .entries()
945            .map_err(|error| self.at(OsStr::new(""), error))?;
946        for entry in entries {
947            let entry = entry.map_err(|error| self.at(OsStr::new(""), error))?;
948            self.entries += 1;
949            if self.entries > MAX_MEASURED_ENTRIES {
950                return Err(CopyDirError::TooMany);
951            }
952            let Some(file_type) = vanished(entry.file_type(), true)? else {
953                continue;
954            };
955            let kind = EntryKind::of(file_type);
956            self.entry(source, target, &entry.file_name(), kind, depth)?;
957        }
958        Ok(())
959    }
960
961    /// Copies the entry `name` of `source`, which was listed as `kind`. An entry that is
962    /// another kind by the time it is opened is copied as what it is, whatever it was listed as.
963    fn entry(
964        &mut self,
965        source: &Dir,
966        target: &Dir,
967        name: &OsStr,
968        kind: EntryKind,
969        depth: usize,
970    ) -> Result<(), CopyDirError> {
971        match kind {
972            EntryKind::Dir => self.subdir(source, target, name, depth),
973            EntryKind::File => self.file(source, target, name, false),
974            EntryKind::Link => self.link(source, target, name, false),
975            EntryKind::Other => Ok(()),
976        }
977    }
978
979    fn subdir(
980        &mut self,
981        source: &Dir,
982        target: &Dir,
983        name: &OsStr,
984        depth: usize,
985    ) -> Result<(), CopyDirError> {
986        if depth >= MAX_MEASURED_DEPTH {
987            return Err(CopyDirError::TooMany);
988        }
989        let child = match source.open_dir_nofollow(name) {
990            Ok(child) => child,
991            Err(error) => return self.changed(source, target, name, EntryKind::Dir, error, false),
992        };
993        match target.create_dir(name) {
994            Ok(()) => {}
995            // Listed twice.
996            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => return Ok(()),
997            Err(error) => return Err(self.at(name, error)),
998        }
999        let copied = target
1000            .open_dir(name)
1001            .map_err(|error| self.at(name, error))?;
1002        self.rel.push(name);
1003        let result = self.dir(&child, &copied, depth + 1);
1004        self.rel.pop();
1005        result
1006    }
1007
1008    /// `reclassified`: the entry already turned out another kind than it was listed as.
1009    fn file(
1010        &mut self,
1011        source: &Dir,
1012        target: &Dir,
1013        name: &OsStr,
1014        reclassified: bool,
1015    ) -> Result<(), CopyDirError> {
1016        let mut options = cap_std::fs::OpenOptions::new();
1017        options.read(true).follow(FollowSymlinks::No);
1018        // A device or pipe swapped in must not block the open, nor become our terminal.
1019        #[cfg(unix)]
1020        cap_std::fs::OpenOptionsExt::custom_flags(&mut options, libc::O_NONBLOCK | libc::O_NOCTTY);
1021        let mut input = match source.open_with(name, &options) {
1022            Ok(file) => file,
1023            Err(error) => {
1024                return self.changed(source, target, name, EntryKind::File, error, reclassified);
1025            }
1026        };
1027        let metadata = input.metadata().map_err(|error| self.at(name, error))?;
1028        if !metadata.is_file() {
1029            return Ok(());
1030        }
1031        let mut output = match target.open_with(
1032            name,
1033            cap_std::fs::OpenOptions::new().write(true).create_new(true),
1034        ) {
1035            Ok(file) => file,
1036            // Listed twice.
1037            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => return Ok(()),
1038            Err(error) => return Err(self.at(name, error)),
1039        };
1040        // One byte past what is left is enough to tell the cap was passed.
1041        let room = self.cap.saturating_sub(*self.bytes).saturating_add(1);
1042        let copied = io::copy(&mut input.by_ref().take(room), &mut output)
1043            .map_err(|error| self.at(name, error))?;
1044        *self.bytes = self.bytes.saturating_add(copied);
1045        if *self.bytes > self.cap {
1046            return Err(CopyDirError::OverCap);
1047        }
1048        output
1049            .set_permissions(metadata.permissions())
1050            .map_err(|error| self.at(name, error))?;
1051        Ok(())
1052    }
1053
1054    fn link(
1055        &mut self,
1056        source: &Dir,
1057        target: &Dir,
1058        name: &OsStr,
1059        reclassified: bool,
1060    ) -> Result<(), CopyDirError> {
1061        // The target is kept as written, where `read_link` would refuse one that leaves
1062        // the directory; it is never resolved here.
1063        let to = match source.read_link_contents(name) {
1064            Ok(to) => to,
1065            Err(error) => {
1066                return self.changed(source, target, name, EntryKind::Link, error, reclassified);
1067            }
1068        };
1069        #[cfg(not(windows))]
1070        match target.symlink_contents(to, name) {
1071            Ok(()) => {}
1072            // Listed twice.
1073            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
1074            Err(error) => return Err(self.at(name, error)),
1075        }
1076        #[cfg(windows)]
1077        let _ = to;
1078        Ok(())
1079    }
1080
1081    /// Settles an entry that could not be handled as `listed`: gone is skipped, and one that
1082    /// is now another kind is copied as that, by its type without following links. An entry
1083    /// still of the kind listed fails the copy as unreadable, unless the error says it is not
1084    /// that kind (it flipped back meanwhile), which skips it like a vanished one. An entry is
1085    /// re-classified once: one that changed kind again meanwhile is skipped the same way, so a
1086    /// program flipping it cannot keep the copy at it.
1087    fn changed(
1088        &mut self,
1089        source: &Dir,
1090        target: &Dir,
1091        name: &OsStr,
1092        listed: EntryKind,
1093        error: io::Error,
1094        reclassified: bool,
1095    ) -> Result<(), CopyDirError> {
1096        if error.kind() == io::ErrorKind::NotFound {
1097            return Ok(());
1098        }
1099        if reclassified {
1100            return if wrong_kind(&error) {
1101                Ok(())
1102            } else {
1103                Err(self.at(name, error))
1104            };
1105        }
1106        let now = match source.symlink_metadata(name) {
1107            Ok(meta) => EntryKind::of(meta.file_type()),
1108            Err(gone) if gone.kind() == io::ErrorKind::NotFound => return Ok(()),
1109            Err(_) => return Err(self.at(name, error)),
1110        };
1111        match (listed, now) {
1112            (EntryKind::Dir | EntryKind::Link, EntryKind::File) => {
1113                self.file(source, target, name, true)
1114            }
1115            (EntryKind::Dir | EntryKind::File, EntryKind::Link) => {
1116                self.link(source, target, name, true)
1117            }
1118            // Swapped for a directory since it was listed: skipped like a vanished entry.
1119            (EntryKind::File | EntryKind::Link, EntryKind::Dir) | (_, EntryKind::Other) => Ok(()),
1120            // Still the kind listed: skipped only if the error says it is not that kind, as
1121            // when it flipped back since the check.
1122            _ if wrong_kind(&error) => Ok(()),
1123            _ => Err(self.at(name, error)),
1124        }
1125    }
1126}
1127
1128/// Whether `error` is the one an open or `read_link` gives an entry of another kind than
1129/// asked for: not a directory, a followed link refused, or not a link.
1130fn wrong_kind(error: &io::Error) -> bool {
1131    #[cfg(unix)]
1132    {
1133        matches!(
1134            error.raw_os_error(),
1135            Some(libc::ENOTDIR | libc::ELOOP | libc::EINVAL)
1136        )
1137    }
1138    #[cfg(not(unix))]
1139    {
1140        let _ = error;
1141        false
1142    }
1143}
1144
1145/// How many entries [`for_each_regular_file`] walks before it gives up, so a directory
1146/// crafted to be huge can't stall every run that opens it.
1147const MAX_MEASURED_ENTRIES: usize = 1_000_000;
1148
1149/// How many directories deep below the root [`for_each_regular_file`] descends
1150/// before it gives up, which bounds the directories it holds open at once.
1151pub const MAX_MEASURED_DEPTH: usize = 64;
1152
1153/// The root-relative form of a mount's guest path, refusing anything that is
1154/// not absolute, normalized, and made of ASCII letters, digits, `.`, `_` and
1155/// `-`. ASCII alone is what the case-folded mount-point guard compares
1156/// reliably on hosts whose filesystems fold case. The blueprint parser applies
1157/// the same rule; this repeats it for direct embedders.
1158fn mount_rel(guest: &str) -> Result<PathBuf, MountError> {
1159    let bad = || MountError::BadPath(guest.to_string());
1160    let Some(rest) = guest.strip_prefix('/') else {
1161        return Err(bad());
1162    };
1163    if rest.is_empty() {
1164        return Err(MountError::AtRoot);
1165    }
1166    let mut rel = PathBuf::new();
1167    for part in rest.split('/') {
1168        let plain = part
1169            .chars()
1170            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'));
1171        // A trailing dot is dropped by Windows, which would make `memory.` and
1172        // `memory` one directory.
1173        if part.is_empty() || part.ends_with('.') || !plain {
1174            return Err(bad());
1175        }
1176        rel.push(part);
1177    }
1178    Ok(rel)
1179}
1180
1181/// Whether `path` is `prefix` or below it, comparing whole components with
1182/// ASCII case folded.
1183fn starts_with_folded(path: &Path, prefix: &Path) -> bool {
1184    let mut parts = path.components();
1185    prefix.components().all(|want| {
1186        parts.next().is_some_and(|have| {
1187            have.as_os_str()
1188                .as_encoded_bytes()
1189                .eq_ignore_ascii_case(want.as_os_str().as_encoded_bytes())
1190        })
1191    })
1192}
1193
1194/// Make sure the mount point is a plain directory in the root, creating it (and
1195/// its parents) only when the root may be written.
1196///
1197/// Returns each directory on the way, the mount point last; see
1198/// [`placeholders_on`].
1199fn prepare_mount_point(
1200    root: &Dir,
1201    rel: &Path,
1202    access: Access,
1203    guest: &str,
1204) -> Result<Vec<Placeholder>, MountError> {
1205    let unavailable = |reason| MountError::MountPointUnavailable {
1206        path: guest.to_string(),
1207        reason,
1208    };
1209    let mut prefix = PathBuf::new();
1210    for part in rel.components() {
1211        prefix.push(part);
1212        match root.symlink_metadata(&prefix) {
1213            Ok(meta) if meta.is_dir() => {
1214                match spelling_of(root, &prefix).map_err(MountError::RootIo)? {
1215                    Spelling::Exact => {}
1216                    Spelling::Variant => {
1217                        return Err(unavailable(
1218                            "exists in the root under a different letter case; rename it to \
1219                             match",
1220                        ));
1221                    }
1222                    Spelling::Unknown => {
1223                        return Err(unavailable(
1224                            "sits beside too many entries to check its spelling; move some \
1225                             of them into a subdirectory",
1226                        ));
1227                    }
1228                }
1229            }
1230            Ok(_) => return Err(unavailable("exists in the root and is not a directory")),
1231            Err(err) if err.kind() == io::ErrorKind::NotFound => {
1232                if access == Access::ReadOnly {
1233                    return Err(unavailable(
1234                        "does not exist, and a read-only root cannot create it",
1235                    ));
1236                }
1237                match root.create_dir(&prefix) {
1238                    Ok(()) => {}
1239                    // Another run created it between the check and here.
1240                    Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {}
1241                    Err(err) => return Err(MountError::RootIo(err)),
1242                }
1243            }
1244            Err(err) => return Err(MountError::RootIo(err)),
1245        }
1246    }
1247    // Recheck: a racing writer may have swapped a link in after creation.
1248    match root.symlink_metadata(rel) {
1249        Ok(meta) if meta.is_dir() => {}
1250        Ok(_) => return Err(unavailable("exists in the root and is not a directory")),
1251        Err(err) => return Err(MountError::RootIo(err)),
1252    }
1253    placeholders_on(root, rel).map_err(MountError::RootIo)
1254}
1255
1256/// The identity and exact name of each directory on the way to `rel`, `rel`
1257/// last, taken after the recheck so a directory swapped in before it isn't the
1258/// one recorded. An identity that can't be read fails the mount: without it an
1259/// alias of the mount point would go unrecognized.
1260fn placeholders_on(root: &Dir, rel: &Path) -> io::Result<Vec<Placeholder>> {
1261    let mut placeholders = Vec::new();
1262    let mut prefix = PathBuf::new();
1263    for part in rel.components() {
1264        prefix.push(part);
1265        let meta = root.symlink_metadata(&prefix)?;
1266        placeholders.push(Placeholder {
1267            identity: dir_identity(root, &prefix, &meta)?,
1268            parent: prefix.parent().map(Path::to_path_buf).unwrap_or_default(),
1269            name: part.as_os_str().to_os_string(),
1270        });
1271    }
1272    Ok(placeholders)
1273}
1274
1275/// How an existing directory on the way to a mount point is spelled on disk.
1276enum Spelling {
1277    Exact,
1278    /// Found only through a case-insensitive lookup.
1279    Variant,
1280    /// Too many entries beside it to tell.
1281    Unknown,
1282}
1283
1284/// Whether the directory entry at `path` is spelled exactly so, not merely found
1285/// through a case-insensitive lookup. Recursive listings descend into a mount by
1286/// matching the entry's name, so a case variant standing in as the mount point
1287/// would hide the volume behind the root's own directory.
1288///
1289/// Checked on every host, since a Linux filesystem can fold case too (ext4
1290/// casefold, or a directory shared in from a Mac). Where the other case finds
1291/// nothing, or a different directory, the filesystem distinguishes case here and
1292/// the exact lookup that found `path` settles it; only a case-folding directory
1293/// is scanned for the exact name.
1294fn spelling_of(root: &Dir, path: &Path) -> io::Result<Spelling> {
1295    let Some(name) = path.file_name() else {
1296        return Ok(Spelling::Exact);
1297    };
1298    let flipped: String = name
1299        .to_string_lossy()
1300        .chars()
1301        .map(|c| {
1302            if c.is_ascii_lowercase() {
1303                c.to_ascii_uppercase()
1304            } else {
1305                c.to_ascii_lowercase()
1306            }
1307        })
1308        .collect();
1309    if flipped.as_str() == name {
1310        return Ok(Spelling::Exact);
1311    }
1312    let parent = path.parent().unwrap_or(Path::new(""));
1313    let other = match root.symlink_metadata(parent.join(&flipped)) {
1314        Ok(meta) => meta,
1315        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Spelling::Exact),
1316        Err(err) => return Err(err),
1317    };
1318    let this = root.symlink_metadata(path)?;
1319    if let (Ok(this), Ok(other)) = (FileIdentity::of(&this), FileIdentity::of(&other))
1320        && this != other
1321    {
1322        return Ok(Spelling::Exact);
1323    }
1324    let entries = if parent.as_os_str().is_empty() {
1325        root.entries()?
1326    } else {
1327        root.read_dir(parent)?
1328    };
1329    for (index, entry) in entries.enumerate() {
1330        if index >= MAX_SPELLING_SCAN {
1331            return Ok(Spelling::Unknown);
1332        }
1333        if entry?.file_name() == name {
1334            return Ok(Spelling::Exact);
1335        }
1336    }
1337    Ok(Spelling::Variant)
1338}
1339
1340/// How many entries [`spelling_of`] reads beside a mount point before giving
1341/// up, so a huge directory can't slow every run that mounts below it.
1342const MAX_SPELLING_SCAN: usize = 100_000;
1343
1344fn open_volume(host: &Path) -> io::Result<Arc<Dir>> {
1345    let meta = std::fs::metadata(host)?;
1346    if !meta.is_dir() {
1347        return Err(io::Error::new(
1348            io::ErrorKind::NotADirectory,
1349            "volume is not a directory",
1350        ));
1351    }
1352    Dir::open_ambient_dir(host, ambient_authority()).map(Arc::new)
1353}
1354
1355fn select_directory(
1356    mut dir: Arc<Dir>,
1357    sub_path: Option<&str>,
1358    access: Access,
1359) -> io::Result<(Arc<Dir>, Vec<FileIdentity>)> {
1360    let mut ancestors = vec![dir_identity(&dir, Path::new("."), &dir.dir_metadata()?)?];
1361    let Some(path) = sub_path else {
1362        return Ok((dir, ancestors));
1363    };
1364    if path.is_empty()
1365        || path.len() > 4096
1366        || path.split('/').any(|part| {
1367            part.is_empty() || part == "." || part == ".." || part.contains(['\\', '\0'])
1368        })
1369    {
1370        return Err(io::Error::new(
1371            io::ErrorKind::InvalidInput,
1372            "subPath must be a normalized relative path",
1373        ));
1374    }
1375    for component in path.split('/') {
1376        let child = match dir.open_dir_nofollow(component) {
1377            Ok(child) => child,
1378            Err(error)
1379                if error.kind() == io::ErrorKind::NotFound && access == Access::ReadWrite =>
1380            {
1381                match dir.create_dir(component) {
1382                    Ok(()) => {}
1383                    Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
1384                    Err(error) => return Err(error),
1385                }
1386                dir.open_dir_nofollow(component)?
1387            }
1388            Err(error) => return Err(error),
1389        };
1390        ancestors.push(dir_identity(
1391            &child,
1392            Path::new("."),
1393            &child.dir_metadata()?,
1394        )?);
1395        dir = Arc::new(child);
1396    }
1397    Ok((dir, ancestors))
1398}
1399
1400fn open_root(root: &Path) -> io::Result<Arc<Dir>> {
1401    Dir::open_ambient_dir(root, ambient_authority())
1402        .map(Arc::new)
1403        .map_err(|e| io::Error::new(e.kind(), format!("VFS root {}: {e}", root.display())))
1404}
1405
1406#[cfg(test)]
1407mod tests {
1408    use super::*;
1409
1410    #[test]
1411    fn subpath_creation_and_readonly_cwd() {
1412        let volume = tempfile::tempdir().unwrap();
1413        let vfs = Vfs::external(volume.path().into())
1414            .unwrap()
1415            .with_subpath(Some("users/ada"))
1416            .unwrap();
1417        assert!(volume.path().join("users/ada").is_dir());
1418        let vfs = vfs.with_cwd("/notes/drafts").unwrap();
1419        assert_eq!(vfs.cwd(), "/notes/drafts");
1420        assert!(volume.path().join("users/ada/notes/drafts").is_dir());
1421        let readonly = vfs.with_access(Access::ReadOnly);
1422        assert!(readonly.clone().with_cwd("/notes").is_ok());
1423        assert!(readonly.with_cwd("/missing").is_err());
1424        assert!(
1425            Vfs::external(volume.path().into())
1426                .unwrap()
1427                .with_access(Access::ReadOnly)
1428                .with_subpath(Some("absent"))
1429                .is_err()
1430        );
1431    }
1432
1433    #[cfg(unix)]
1434    #[test]
1435    fn subpath_selection_rejects_symlinks() {
1436        let volume = tempfile::tempdir().unwrap();
1437        std::fs::create_dir_all(volume.path().join("users/ada")).unwrap();
1438        std::os::unix::fs::symlink("ada", volume.path().join("users/alias")).unwrap();
1439        let vfs = Vfs::external(volume.path().into()).unwrap();
1440        assert!(vfs.with_subpath(Some("users/alias")).is_err());
1441    }
1442
1443    #[test]
1444    fn tempdir_is_deleted_on_drop() {
1445        let vfs = Vfs::tempdir().expect("tempdir");
1446        let path = vfs.root().to_path_buf();
1447        assert!(path.is_dir());
1448        drop(vfs);
1449        assert!(!path.exists(), "tempdir should be deleted on drop");
1450    }
1451
1452    #[test]
1453    fn external_does_not_delete_directory() {
1454        let outer = tempfile::tempdir().expect("outer tempdir");
1455        let path = outer.path().to_path_buf();
1456        let vfs = Vfs::external(path.clone()).expect("external");
1457        drop(vfs);
1458        assert!(path.is_dir(), "external path must survive Vfs drop");
1459    }
1460
1461    #[test]
1462    fn external_rejects_missing_path() {
1463        let outer = tempfile::tempdir().expect("outer tempdir");
1464        let missing = outer.path().join("does-not-exist");
1465        let err = Vfs::external(missing).expect_err("must reject missing path");
1466        assert!(
1467            err.to_string().contains("VFS root"),
1468            "error should mention VFS root: {err}"
1469        );
1470    }
1471
1472    #[test]
1473    fn real_directory_yields_a_usable_handle() {
1474        let outer = tempfile::tempdir().expect("outer tempdir");
1475        std::fs::write(outer.path().join("a.txt"), b"hi").expect("write");
1476        let vfs = Vfs::external(outer.path().to_path_buf()).expect("external");
1477        let dir = vfs.dir().expect("an external root backs a directory");
1478        assert_eq!(dir.read("a.txt").expect("read through handle"), b"hi");
1479    }
1480
1481    #[test]
1482    fn none_backs_no_handle() {
1483        let vfs = Vfs::none();
1484        assert!(vfs.dir().is_none());
1485        assert_eq!(vfs.mode(), VfsMode::None);
1486    }
1487
1488    #[test]
1489    fn tempdir_modes_carry_a_handle() {
1490        let vfs = Vfs::tempdir().expect("tempdir");
1491        assert!(
1492            vfs.dir().is_some(),
1493            "ephemeral roots resolve through a handle too"
1494        );
1495    }
1496
1497    #[test]
1498    fn handle_survives_a_rename_of_the_root() {
1499        let outer = tempfile::tempdir().expect("outer tempdir");
1500        let from = outer.path().join("before");
1501        let to = outer.path().join("after");
1502        std::fs::create_dir(&from).expect("mkdir");
1503        std::fs::write(from.join("a.txt"), b"hi").expect("write");
1504
1505        let vfs = Vfs::external(from.clone()).expect("external");
1506        std::fs::rename(&from, &to).expect("rename");
1507
1508        // The handle tracks the inode, not the name — which is also why it cannot be
1509        // used as a liveness check for the directory it was opened on.
1510        let dir = vfs.dir().expect("handle");
1511        assert_eq!(dir.read("a.txt").expect("read after rename"), b"hi");
1512    }
1513
1514    #[test]
1515    fn external_rejects_file() {
1516        let outer = tempfile::tempdir().expect("outer tempdir");
1517        let file = outer.path().join("a.txt");
1518        std::fs::write(&file, b"hi").expect("write");
1519        let err = Vfs::external(file).expect_err("must reject file");
1520        assert!(
1521            err.to_string().contains("not a directory"),
1522            "error should mention not a directory: {err}"
1523        );
1524    }
1525
1526    #[test]
1527    fn size_limit_starts_from_what_the_directory_holds() {
1528        let outer = tempfile::tempdir().expect("outer tempdir");
1529        std::fs::write(outer.path().join("a.txt"), vec![0u8; 300]).expect("write");
1530        std::fs::create_dir(outer.path().join("sub")).expect("mkdir");
1531        std::fs::write(outer.path().join("sub/b.txt"), vec![0u8; 200]).expect("write");
1532        #[cfg(unix)]
1533        std::os::unix::fs::symlink("a.txt", outer.path().join("link")).expect("symlink");
1534
1535        let vfs = Vfs::external_with_mode(outer.path().to_path_buf(), VfsMode::PerSession)
1536            .expect("external")
1537            .with_size_limit(1_000);
1538        let quota = vfs.quota().expect("quota attached");
1539        assert_eq!(quota.used(), 500, "links count as nothing");
1540        assert_eq!(quota.limit(), 1_000);
1541        assert!(
1542            vfs.clone().quota().is_some_and(|q| Arc::ptr_eq(q, quota)),
1543            "clones share it"
1544        );
1545    }
1546
1547    #[test]
1548    fn measuring_many_directories_holds_one_open_per_level() {
1549        let outer = tempfile::tempdir().expect("outer tempdir");
1550        let mut path = outer.path().to_path_buf();
1551        for depth in 0..3_000 {
1552            if depth % 60 == 0 {
1553                path = outer.path().join(format!("branch{depth}"));
1554            } else {
1555                path = path.join("d");
1556            }
1557            std::fs::create_dir_all(&path).expect("mkdir");
1558        }
1559        std::fs::write(path.join("leaf.txt"), b"12345").expect("write");
1560        let root = Vfs::external(outer.path().to_path_buf()).expect("external");
1561        assert_eq!(root.measure_usage().expect("measure"), 5);
1562    }
1563
1564    #[test]
1565    fn listing_files_stops_past_its_entry_cap() {
1566        let outer = tempfile::tempdir().expect("outer tempdir");
1567        std::fs::write(outer.path().join("a"), b"1").expect("write");
1568        std::fs::create_dir(outer.path().join("d")).expect("mkdir");
1569        let root = Vfs::external(outer.path().to_path_buf()).expect("external");
1570        let dir = root.dir().expect("dir");
1571        assert_eq!(regular_files(dir, 2).expect("two entries fit").len(), 1);
1572        std::fs::write(outer.path().join("d/b"), b"2").expect("write");
1573        let error = regular_files(dir, 2).expect_err("a third entry passes the cap");
1574        assert_eq!(error.kind(), io::ErrorKind::InvalidData);
1575    }
1576
1577    #[test]
1578    fn a_tree_nested_past_the_measured_depth_opens_as_full() {
1579        let outer = tempfile::tempdir().expect("outer tempdir");
1580        let mut path = outer.path().to_path_buf();
1581        for _ in 0..MAX_MEASURED_DEPTH {
1582            path = path.join("d");
1583        }
1584        std::fs::create_dir_all(&path).expect("mkdir");
1585        let fits = Vfs::external(outer.path().to_path_buf()).expect("external");
1586        assert_eq!(fits.measure_usage().expect("measure"), 0);
1587
1588        std::fs::create_dir(path.join("d")).expect("mkdir");
1589        let vfs = Vfs::external(outer.path().to_path_buf())
1590            .expect("external")
1591            .with_size_limit(1_000);
1592        assert!(vfs.quota().is_some_and(|quota| quota.is_unmeasured()));
1593    }
1594
1595    #[cfg(unix)]
1596    #[test]
1597    fn a_directory_that_cannot_be_read_opens_as_full() {
1598        use std::os::unix::fs::PermissionsExt;
1599        let outer = tempfile::tempdir().expect("outer tempdir");
1600        let locked = outer.path().join("locked");
1601        std::fs::create_dir(&locked).expect("mkdir");
1602        std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).expect("chmod");
1603        let vfs = Vfs::external(outer.path().to_path_buf())
1604            .expect("external")
1605            .with_size_limit(1_000);
1606        let unmeasured = vfs.quota().is_some_and(|quota| quota.is_unmeasured());
1607        std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755))
1608            .expect("chmod back");
1609        assert!(unmeasured, "a failed walk leaves the VFS treated as full");
1610    }
1611
1612    #[test]
1613    fn only_a_walk_that_skips_vanished_entries_survives_one() {
1614        let gone = || io::Result::<u8>::Err(io::ErrorKind::NotFound.into());
1615        // A size limit's measure fails, so the directory opens as full.
1616        assert!(vanished(gone(), false).is_err());
1617        assert_eq!(vanished(gone(), true).expect("skipped"), None);
1618        let denied = io::Result::<u8>::Err(io::ErrorKind::PermissionDenied.into());
1619        assert!(vanished(denied, true).is_err());
1620        assert_eq!(vanished(Ok(1u8), false).expect("ok"), Some(1));
1621        let dir = tempfile::tempdir().expect("dir");
1622        std::fs::write(dir.path().join("a"), "abc").expect("write");
1623        assert_eq!(measure_host_dir(dir.path()).expect("strict"), 3);
1624        assert_eq!(
1625            measure_host_dir_skipping_vanished(dir.path()).expect("lenient"),
1626            3
1627        );
1628    }
1629
1630    fn copied(from: &Path, cap: u64) -> (tempfile::TempDir, Result<u64, CopyDirError>) {
1631        let to = tempfile::tempdir().expect("to");
1632        let mut bytes = 0;
1633        let result = copy_host_dir(from, &to.path().join("out"), cap, &mut bytes).map(|()| bytes);
1634        (to, result)
1635    }
1636
1637    #[test]
1638    fn a_copy_has_the_files_and_stops_at_the_cap() {
1639        let from = tempfile::tempdir().expect("from");
1640        std::fs::create_dir(from.path().join("sub")).expect("mkdir");
1641        std::fs::write(from.path().join("sub/a.txt"), "hello").expect("write");
1642        let (to, result) = copied(from.path(), 100);
1643        assert_eq!(result.expect("copied"), 5);
1644        assert_eq!(
1645            std::fs::read_to_string(to.path().join("out/sub/a.txt")).expect("read"),
1646            "hello"
1647        );
1648        let (_to, result) = copied(from.path(), 4);
1649        assert!(matches!(result, Err(CopyDirError::OverCap)));
1650    }
1651
1652    #[cfg(unix)]
1653    #[test]
1654    fn a_copy_recreates_links_and_never_follows_them() {
1655        let outside = tempfile::tempdir().expect("outside");
1656        std::fs::write(outside.path().join("secret"), "host file").expect("write");
1657        std::fs::create_dir(outside.path().join("dir")).expect("mkdir");
1658        std::fs::write(outside.path().join("dir/inner"), "host dir file").expect("write");
1659        let from = tempfile::tempdir().expect("from");
1660        std::os::unix::fs::symlink(outside.path().join("secret"), from.path().join("file-link"))
1661            .expect("symlink");
1662        std::os::unix::fs::symlink(outside.path().join("dir"), from.path().join("dir-link"))
1663            .expect("symlink");
1664        let (to, result) = copied(from.path(), 1_000);
1665        assert_eq!(result.expect("copied"), 0, "no linked content is copied");
1666        for name in ["file-link", "dir-link"] {
1667            let copy = to.path().join("out").join(name);
1668            assert!(std::fs::symlink_metadata(&copy).expect("kept").is_symlink());
1669        }
1670        assert!(!to.path().join("out/dir-link-copy").exists());
1671    }
1672
1673    #[cfg(unix)]
1674    #[test]
1675    fn an_entry_that_is_a_link_when_opened_is_not_followed() {
1676        let outside = tempfile::tempdir().expect("outside");
1677        std::fs::write(outside.path().join("secret"), "host file").expect("write");
1678        std::fs::create_dir(outside.path().join("dir")).expect("mkdir");
1679        let from = tempfile::tempdir().expect("from");
1680        let to = tempfile::tempdir().expect("to");
1681        std::os::unix::fs::symlink(outside.path().join("secret"), from.path().join("swapped"))
1682            .expect("symlink");
1683        std::os::unix::fs::symlink(outside.path().join("dir"), from.path().join("swapped-dir"))
1684            .expect("symlink");
1685        let source = Dir::open_ambient_dir(from.path(), ambient_authority()).expect("source");
1686        let target = Dir::open_ambient_dir(to.path(), ambient_authority()).expect("target");
1687        let mut bytes = 0;
1688        let mut copy = TreeCopy {
1689            cap: 1_000,
1690            bytes: &mut bytes,
1691            entries: 0,
1692            rel: PathBuf::new(),
1693        };
1694        // Listed as a file and a directory, as they were before being swapped.
1695        copy.entry(&source, &target, OsStr::new("swapped"), EntryKind::File, 0)
1696            .expect("file");
1697        copy.entry(
1698            &source,
1699            &target,
1700            OsStr::new("swapped-dir"),
1701            EntryKind::Dir,
1702            0,
1703        )
1704        .expect("dir");
1705        assert_eq!(bytes, 0);
1706        for name in ["swapped", "swapped-dir"] {
1707            let copy = to.path().join(name);
1708            assert!(std::fs::symlink_metadata(&copy).expect("kept").is_symlink());
1709        }
1710    }
1711
1712    #[test]
1713    fn an_entry_removed_since_it_was_listed_is_skipped() {
1714        let from = tempfile::tempdir().expect("from");
1715        let to = tempfile::tempdir().expect("to");
1716        let source = Dir::open_ambient_dir(from.path(), ambient_authority()).expect("source");
1717        let target = Dir::open_ambient_dir(to.path(), ambient_authority()).expect("target");
1718        let mut bytes = 0;
1719        let mut copy = TreeCopy {
1720            cap: 1_000,
1721            bytes: &mut bytes,
1722            entries: 0,
1723            rel: PathBuf::new(),
1724        };
1725        for kind in [EntryKind::Dir, EntryKind::File, EntryKind::Link] {
1726            copy.entry(&source, &target, OsStr::new("gone"), kind, 0)
1727                .expect("skipped");
1728        }
1729        assert_eq!(std::fs::read_dir(to.path()).expect("list").count(), 0);
1730    }
1731
1732    #[test]
1733    fn an_entry_that_changed_kind_since_it_was_listed_is_copied_as_what_it_is() {
1734        let from = tempfile::tempdir().expect("from");
1735        let to = tempfile::tempdir().expect("to");
1736        std::fs::write(from.path().join("was-dir"), "abc").expect("write");
1737        std::fs::write(from.path().join("was-link"), "de").expect("write");
1738        let source = Dir::open_ambient_dir(from.path(), ambient_authority()).expect("source");
1739        let target = Dir::open_ambient_dir(to.path(), ambient_authority()).expect("target");
1740        let mut bytes = 0;
1741        let mut copy = TreeCopy {
1742            cap: 1_000,
1743            bytes: &mut bytes,
1744            entries: 0,
1745            rel: PathBuf::new(),
1746        };
1747        copy.entry(&source, &target, OsStr::new("was-dir"), EntryKind::Dir, 0)
1748            .expect("dir now a file");
1749        copy.entry(&source, &target, OsStr::new("was-link"), EntryKind::Link, 0)
1750            .expect("link now a file");
1751        // The same name listed again is left as copied.
1752        copy.entry(&source, &target, OsStr::new("was-dir"), EntryKind::File, 0)
1753            .expect("duplicate");
1754        assert_eq!(bytes, 5);
1755        assert_eq!(
1756            std::fs::read_to_string(to.path().join("was-link")).expect("read"),
1757            "de"
1758        );
1759    }
1760
1761    #[cfg(unix)]
1762    #[test]
1763    fn an_entry_that_changes_kind_a_second_time_is_skipped_not_chased() {
1764        let outside = tempfile::tempdir().expect("outside");
1765        let from = tempfile::tempdir().expect("from");
1766        let to = tempfile::tempdir().expect("to");
1767        std::os::unix::fs::symlink(outside.path(), from.path().join("now-link")).expect("symlink");
1768        std::fs::write(from.path().join("now-file"), "abc").expect("write");
1769        let source = Dir::open_ambient_dir(from.path(), ambient_authority()).expect("source");
1770        let target = Dir::open_ambient_dir(to.path(), ambient_authority()).expect("target");
1771        let mut bytes = 0;
1772        let mut copy = TreeCopy {
1773            cap: 1_000,
1774            bytes: &mut bytes,
1775            entries: 0,
1776            rel: PathBuf::new(),
1777        };
1778        // Listed as a file, it is a link: copied as one, once.
1779        copy.file(&source, &target, OsStr::new("now-link"), false)
1780            .expect("re-classified once");
1781        assert!(
1782            std::fs::symlink_metadata(to.path().join("now-link"))
1783                .expect("kept")
1784                .is_symlink()
1785        );
1786        // Already re-classified, one that fails again is skipped, not re-classified again:
1787        // here a link that reads as a file, as if it had flipped back.
1788        copy.link(&source, &target, OsStr::new("now-file"), true)
1789            .expect("skipped");
1790        copy.file(&source, &target, OsStr::new("now-link"), true)
1791            .expect("skipped");
1792        assert!(!to.path().join("now-file").exists());
1793        assert_eq!(bytes, 0);
1794    }
1795
1796    #[cfg(unix)]
1797    #[test]
1798    fn an_entry_that_flipped_back_is_skipped_but_an_unreadable_one_still_fails() {
1799        use std::os::unix::fs::PermissionsExt;
1800        let from = tempfile::tempdir().expect("from");
1801        std::fs::write(from.path().join("plain"), "abc").expect("write");
1802        std::fs::create_dir(from.path().join("dir")).expect("mkdir");
1803        let locked = from.path().join("locked");
1804        std::fs::write(&locked, "x").expect("write");
1805        std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).expect("chmod");
1806        let source = Dir::open_ambient_dir(from.path(), ambient_authority()).expect("source");
1807        let to = tempfile::tempdir().expect("to");
1808        let target = Dir::open_ambient_dir(to.path(), ambient_authority()).expect("target");
1809        let mut bytes = 0;
1810        let mut copy = TreeCopy {
1811            cap: 1_000,
1812            bytes: &mut bytes,
1813            entries: 0,
1814            rel: PathBuf::new(),
1815        };
1816        // Listed as a directory, a file, and a link; each is still the kind that now fails
1817        // the open the way a flipped-back entry does.
1818        copy.entry(&source, &target, OsStr::new("plain"), EntryKind::Dir, 0)
1819            .expect("file listed as dir, re-classified");
1820        let not_a_dir = io::Error::from_raw_os_error(libc::ENOTDIR);
1821        copy.changed(
1822            &source,
1823            &target,
1824            OsStr::new("dir"),
1825            EntryKind::Dir,
1826            not_a_dir,
1827            false,
1828        )
1829        .expect("flipped back to a directory");
1830        let not_a_link = io::Error::from_raw_os_error(libc::EINVAL);
1831        copy.changed(
1832            &source,
1833            &target,
1834            OsStr::new("plain"),
1835            EntryKind::File,
1836            not_a_link,
1837            false,
1838        )
1839        .expect("flipped back to a file");
1840        let loop_error = io::Error::from_raw_os_error(libc::ELOOP);
1841        copy.changed(
1842            &source,
1843            &target,
1844            OsStr::new("plain"),
1845            EntryKind::Link,
1846            loop_error,
1847            true,
1848        )
1849        .expect("reclassified, wrong kind");
1850        // A privileged user reads it anyway.
1851        if unsafe { libc::geteuid() } != 0 {
1852            // Reclassified: only wrong-kind errors skip; an unreadable file fails.
1853            let result = copy.file(&source, &target, OsStr::new("locked"), true);
1854            std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o600))
1855                .expect("chmod");
1856            let Err(CopyDirError::Io(error)) = result else {
1857                panic!("expected an I/O error, got {result:?}");
1858            };
1859            assert!(error.to_string().starts_with("locked: "), "{error}");
1860        }
1861        std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o600)).expect("chmod");
1862        // Other errors on an entry still of the kind listed fail too.
1863        let denied = io::Error::from_raw_os_error(libc::EIO);
1864        let result = copy.changed(
1865            &source,
1866            &target,
1867            OsStr::new("dir"),
1868            EntryKind::Dir,
1869            denied,
1870            false,
1871        );
1872        assert!(matches!(result, Err(CopyDirError::Io(_))));
1873    }
1874
1875    #[cfg(unix)]
1876    #[test]
1877    fn an_unreadable_entry_fails_the_copy_naming_its_path_in_the_tree() {
1878        use std::os::unix::fs::PermissionsExt;
1879        let from = tempfile::tempdir().expect("from");
1880        std::fs::create_dir(from.path().join("sub")).expect("mkdir");
1881        let locked = from.path().join("sub/locked");
1882        std::fs::write(&locked, "x").expect("write");
1883        std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).expect("chmod");
1884        let (_to, result) = copied(from.path(), 1_000);
1885        std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o600)).expect("chmod");
1886        // A privileged user reads it anyway.
1887        if unsafe { libc::geteuid() } == 0 {
1888            return;
1889        }
1890        let Err(CopyDirError::Io(error)) = result else {
1891            panic!("expected an I/O error, got {result:?}");
1892        };
1893        let message = error.to_string();
1894        assert!(message.starts_with("sub/locked: "), "{message}");
1895        assert!(!message.contains(from.path().to_str().expect("utf8")));
1896    }
1897
1898    #[test]
1899    fn a_missing_component_opens_as_none() {
1900        let volume = tempfile::tempdir().expect("volume");
1901        std::fs::create_dir_all(volume.path().join("a")).expect("mkdir");
1902        assert!(
1903            open_host_subdir(volume.path(), Path::new("a"))
1904                .unwrap()
1905                .is_some()
1906        );
1907        assert!(
1908            open_host_subdir(volume.path(), Path::new("a/b/c"))
1909                .unwrap()
1910                .is_none()
1911        );
1912        assert!(
1913            open_host_subdir(volume.path(), Path::new("x"))
1914                .unwrap()
1915                .is_none()
1916        );
1917        assert_eq!(
1918            measure_host_subdir_skipping_vanished(volume.path(), Path::new("a/b")).unwrap(),
1919            0
1920        );
1921    }
1922
1923    #[cfg(unix)]
1924    #[test]
1925    fn a_link_component_is_an_error() {
1926        let volume = tempfile::tempdir().expect("volume");
1927        let outside = tempfile::tempdir().expect("outside");
1928        std::fs::create_dir_all(outside.path().join("deep")).expect("mkdir");
1929        std::fs::write(outside.path().join("deep/f"), "x").expect("write");
1930        std::fs::create_dir_all(volume.path().join("a")).expect("mkdir");
1931        std::os::unix::fs::symlink(outside.path(), volume.path().join("a/link")).expect("link");
1932        for sub in ["a/link", "a/link/deep"] {
1933            let error = open_host_subdir(volume.path(), Path::new(sub)).expect_err("refused");
1934            assert!(error.to_string().starts_with(sub), "{error}");
1935            assert!(measure_host_subdir_skipping_vanished(volume.path(), Path::new(sub)).is_err());
1936            let to = tempfile::tempdir().expect("to");
1937            let result = copy_host_subdir(volume.path(), Path::new(sub), to.path(), 1_000, &mut 0);
1938            assert!(matches!(result, Err(CopyDirError::Io(_))));
1939        }
1940    }
1941
1942    #[test]
1943    fn a_sub_path_is_copied_to_the_same_place_under_the_target() {
1944        let from = tempfile::tempdir().expect("from");
1945        std::fs::create_dir_all(from.path().join("users/ada/notes")).expect("mkdir");
1946        std::fs::write(from.path().join("users/ada/notes/n.txt"), "note").expect("write");
1947        std::fs::write(from.path().join("users/ada/a.txt"), "ada").expect("write");
1948        std::fs::write(from.path().join("users/b.txt"), "bob").expect("write");
1949        std::fs::write(from.path().join("top.txt"), "top").expect("write");
1950        let to = tempfile::tempdir().expect("to");
1951        let mut bytes = 0;
1952        copy_host_subdir(
1953            from.path(),
1954            Path::new("users/ada"),
1955            to.path(),
1956            1_000,
1957            &mut bytes,
1958        )
1959        .expect("copied");
1960        assert_eq!(bytes, 7);
1961        let read = |rel: &str| std::fs::read_to_string(to.path().join(rel)).expect(rel);
1962        assert_eq!(read("users/ada/notes/n.txt"), "note");
1963        assert_eq!(read("users/ada/a.txt"), "ada");
1964        assert!(!to.path().join("users/b.txt").exists());
1965        assert!(!to.path().join("top.txt").exists());
1966        assert_eq!(
1967            measure_host_subdir_skipping_vanished(from.path(), Path::new("users/ada")).unwrap(),
1968            7
1969        );
1970    }
1971
1972    #[test]
1973    fn a_copy_stops_in_a_tree_nested_too_deep() {
1974        let from = tempfile::tempdir().expect("from");
1975        let mut path = from.path().to_path_buf();
1976        for _ in 0..=MAX_MEASURED_DEPTH + 1 {
1977            path = path.join("d");
1978        }
1979        std::fs::create_dir_all(&path).expect("mkdir");
1980        let (_to, result) = copied(from.path(), 1_000);
1981        assert!(matches!(result, Err(CopyDirError::TooMany)));
1982    }
1983}