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        self,
477        spec: MountSpec,
478        sub_path: Option<&str>,
479    ) -> Result<Self, MountError> {
480        let access = spec.access;
481        let root_access = self.access;
482        self.open_mount(spec, sub_path, root_access, access)
483    }
484
485    /// Reopen an existing mount without creating its target or placeholders.
486    pub fn reopen_mount_subpath(
487        self,
488        spec: MountSpec,
489        sub_path: Option<&str>,
490    ) -> Result<Self, MountError> {
491        self.open_mount(spec, sub_path, Access::ReadOnly, Access::ReadOnly)
492    }
493
494    fn open_mount(
495        mut self,
496        spec: MountSpec,
497        sub_path: Option<&str>,
498        root_preparation: Access,
499        volume_preparation: Access,
500    ) -> Result<Self, MountError> {
501        let root = self.dir.as_ref().ok_or(MountError::RootDisabled)?;
502        if self.mounts.len() >= MAX_MOUNTS {
503            return Err(MountError::TooMany);
504        }
505        let rel = mount_rel(&spec.guest_path)?;
506        if crate::runtime::fs::protected_metadata(&rel) {
507            return Err(MountError::ProtectedPath(spec.guest_path));
508        }
509        // Case is folded as the blueprint parser folds it, so two spellings of one
510        // directory on a case-insensitive filesystem cannot both be mounted.
511        for mount in self.mounts.iter() {
512            let (outer, inner) = if starts_with_folded(&rel, &mount.rel) {
513                (mount.guest.to_string(), spec.guest_path.clone())
514            } else if starts_with_folded(&mount.rel, &rel) {
515                (spec.guest_path.clone(), mount.guest.to_string())
516            } else {
517                continue;
518            };
519            return Err(MountError::Nested { outer, inner });
520        }
521        let placeholders = prepare_mount_point(root, &rel, root_preparation, &spec.guest_path)?;
522        let (dir, ancestors) = select_directory(
523            open_volume(&spec.host).map_err(MountError::Io)?,
524            sub_path,
525            volume_preparation,
526        )
527        .map_err(MountError::Io)?;
528        let mount = Mount {
529            guest: Arc::from(spec.guest_path.as_str()),
530            rel,
531            volume: Arc::from(spec.volume.as_str()),
532            dir,
533            host: Arc::from(
534                sub_path.map_or_else(|| spec.host.clone(), |path| spec.host.join(path)),
535            ),
536            access: spec.access,
537            quota: spec.quota,
538            placeholders: Arc::from(placeholders),
539            ancestors: Arc::from(ancestors),
540        };
541        let mut mounts = self.mounts.to_vec();
542        mounts.push(mount);
543        self.mounts = Arc::from(mounts);
544        Ok(self)
545    }
546
547    /// Charge the volume mounted at `guest_path` to `quota`, the limit the
548    /// server shares between every VFS that mounts it. No mount there is a
549    /// no-op.
550    pub fn with_mount_quota(mut self, guest_path: &str, quota: Arc<DiskQuota>) -> Self {
551        let mounts: Vec<Mount> = self
552            .mounts
553            .iter()
554            .cloned()
555            .map(|mut mount| {
556                if &*mount.guest == guest_path {
557                    mount.quota = Some(Arc::clone(&quota));
558                }
559                mount
560            })
561            .collect();
562        self.mounts = Arc::from(mounts);
563        self
564    }
565
566    /// Route a root-relative path (as the lexical pass produced it) to the
567    /// volume that holds it: the handle to open it through, the path relative
568    /// to that handle, and the volume's placement. `None` under
569    /// [`VfsMode::None`].
570    ///
571    /// Matching is by whole components, so `/memoryx` is not under `/memory`.
572    /// A mount point itself resolves to its volume's root, `.`.
573    pub fn locate(&self, rel: &Path) -> Option<(Arc<Dir>, PathBuf, Placement)> {
574        let root = self.dir.as_ref()?;
575        let (protected_roots, protected_ancestors) = self.protected_directories();
576        for mount in self.mounts.iter() {
577            if let Ok(rest) = rel.strip_prefix(&mount.rel) {
578                let rest = if rest.as_os_str().is_empty() {
579                    PathBuf::from(".")
580                } else {
581                    rest.to_path_buf()
582                };
583                let placement = Placement {
584                    access: mount.access,
585                    quota: mount.quota.clone(),
586                    mount: Some(Arc::clone(&mount.guest)),
587                    nested: Arc::from([]),
588                    host: Arc::clone(&mount.host),
589                    volume: Some(Arc::clone(&mount.volume)),
590                    ancestors: Arc::clone(&mount.ancestors),
591                    protected_roots,
592                    protected_ancestors,
593                };
594                return Some((Arc::clone(&mount.dir), rest, placement));
595            }
596        }
597        let placement = Placement {
598            access: self.access,
599            quota: self.quota.clone(),
600            mount: None,
601            nested: Arc::clone(&self.mounts),
602            host: Arc::from(self.root.as_path()),
603            volume: self.volume.clone(),
604            ancestors: Arc::clone(&self.ancestors),
605            protected_roots,
606            protected_ancestors,
607        };
608        Some((Arc::clone(root), rel.to_path_buf(), placement))
609    }
610
611    fn protected_directories(&self) -> (Arc<[FileIdentity]>, Arc<[FileIdentity]>) {
612        let mut roots = Vec::new();
613        let mut ancestors = self
614            .ancestors
615            .split_last()
616            .map_or(&[][..], |(_, parents)| parents)
617            .to_vec();
618        if let Some(identity) = self.ancestors.last() {
619            roots.push(*identity);
620            if !self.mounts.is_empty() {
621                ancestors.push(*identity);
622            }
623        }
624        for mount in self.mounts.iter() {
625            if let Some(identity) = mount.ancestors.last() {
626                roots.push(*identity);
627            }
628            if let Some((_, parents)) = mount.ancestors.split_last() {
629                ancestors.extend_from_slice(parents);
630            }
631            // Git must also avoid publishing inside hidden placeholders.
632            for placeholder in mount.placeholders.iter() {
633                ancestors.push(placeholder.identity);
634            }
635            if let Some(placeholder) = mount.placeholders.last() {
636                roots.push(placeholder.identity);
637            }
638        }
639        (Arc::from(roots), Arc::from(ancestors))
640    }
641
642    /// Guest working directory shared by I/O, policy, and introspection.
643    pub fn cwd(&self) -> &str {
644        &self.cwd
645    }
646
647    pub fn with_cwd(self, cwd: &str) -> Result<Self, crate::runtime::fs::ContainError> {
648        use crate::runtime::fs::{ContainError, resolve_content};
649        let vfs = self.with_cwd_path(cwd)?;
650        let target = resolve_content(&vfs, "/", cwd)?;
651        match target.open_dir() {
652            Ok(_) => {}
653            Err(ContainError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
654                target.create_dir_all()?;
655                target.open_dir()?;
656            }
657            Err(error) => return Err(error),
658        }
659        Ok(vfs)
660    }
661
662    /// Set the logical working directory without creating it on disk.
663    pub fn with_cwd_path(mut self, cwd: &str) -> Result<Self, crate::runtime::fs::ContainError> {
664        use crate::runtime::fs::{ContainError, guest_normalize};
665        if !cwd.starts_with('/') || cwd.len() > 4096 || guest_normalize("/", cwd)? != cwd {
666            return Err(ContainError::Io(io::Error::new(
667                io::ErrorKind::InvalidInput,
668                "cwd must be an absolute normalized guest path",
669            )));
670        }
671        self.cwd = Arc::from(cwd);
672        Ok(self)
673    }
674
675    /// Select a directory through the volume handle, without ambient reopening.
676    pub fn with_subpath(self, sub_path: Option<&str>) -> io::Result<Self> {
677        let access = self.access;
678        self.select_subpath(sub_path, access)
679    }
680
681    /// Reopen the selected directory without provisioning a missing path.
682    pub fn reopen_subpath(self, sub_path: Option<&str>) -> io::Result<Self> {
683        self.select_subpath(sub_path, Access::ReadOnly)
684    }
685
686    fn select_subpath(mut self, sub_path: Option<&str>, preparation: Access) -> io::Result<Self> {
687        let root = self
688            .dir
689            .clone()
690            .ok_or_else(|| io::Error::other("filesystem disabled"))?;
691        let (dir, ancestors) = select_directory(root, sub_path, preparation)?;
692        self.dir = Some(dir);
693        if let Some(path) = sub_path {
694            self.root.push(path);
695        }
696        self.ancestors = Arc::from(ancestors);
697        Ok(self)
698    }
699
700    /// The bytes held by regular files under the root; see [`measure_dir`].
701    pub fn measure_usage(&self) -> io::Result<u64> {
702        match &self.dir {
703            Some(root) => measure_dir(root),
704            None => Ok(0),
705        }
706    }
707}
708
709/// The bytes held by regular files under the host directory `root`, as a size
710/// limit starting from it counts them. The error carries no host path; a tree with too
711/// many entries or nested too deep is [`io::ErrorKind::InvalidData`].
712pub fn measure_host_dir(root: &Path) -> io::Result<u64> {
713    measure_dir(&*open_volume(root)?)
714}
715
716/// As [`measure_host_dir`], but an entry removed mid-walk is not counted instead of
717/// failing the measure: for a tree being copied, never for a size limit's accounting.
718pub fn measure_host_dir_skipping_vanished(root: &Path) -> io::Result<u64> {
719    let mut total: u64 = 0;
720    let root = open_volume(root)?;
721    for_each_regular_file(&root, MAX_MEASURED_ENTRIES, true, |_, bytes| {
722        total = total.saturating_add(bytes);
723    })?;
724    Ok(total)
725}
726
727/// As [`measure_host_dir_skipping_vanished`], for the directory `sub` below the volume
728/// `volume`, which is no link all the way down. A `sub` that is not there is empty.
729pub fn measure_host_subdir_skipping_vanished(volume: &Path, sub: &Path) -> io::Result<u64> {
730    let Some(root) = open_host_subdir(volume, sub)? else {
731        return Ok(0);
732    };
733    let mut total: u64 = 0;
734    for_each_regular_file(&root, MAX_MEASURED_ENTRIES, true, |_, bytes| {
735        total = total.saturating_add(bytes);
736    })?;
737    Ok(total)
738}
739
740/// Opens the directory `sub` below the host directory `volume`, one component at a time
741/// without following links, so a link anywhere in `sub` is an error. `None` when a
742/// component is not there. `sub` must be relative with no `.` or `..`.
743pub fn open_host_subdir(volume: &Path, sub: &Path) -> io::Result<Option<Arc<Dir>>> {
744    let mut dir = open_volume(volume)?;
745    for component in sub.components() {
746        let Component::Normal(name) = component else {
747            return Err(io::Error::new(
748                io::ErrorKind::InvalidInput,
749                "a sub-path must be a normalized relative path",
750            ));
751        };
752        dir = match dir.open_dir_nofollow(name) {
753            Ok(child) => Arc::new(child),
754            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
755            Err(error) => {
756                return Err(io::Error::new(
757                    error.kind(),
758                    format!("{}: {error}", sub.display()),
759                ));
760            }
761        };
762    }
763    Ok(Some(dir))
764}
765
766/// The bytes held by regular files under `root`; see [`for_each_regular_file`].
767pub fn measure_dir(root: &Dir) -> io::Result<u64> {
768    let mut total: u64 = 0;
769    for_each_regular_file(root, MAX_MEASURED_ENTRIES, false, |_, bytes| {
770        total = total.saturating_add(bytes);
771    })?;
772    Ok(total)
773}
774
775/// Every regular file under `root` and its size, as a removal of the tree frees
776/// them; more than `max_entries` entries is an [`io::ErrorKind::InvalidData`] error.
777pub fn regular_files(root: &Dir, max_entries: usize) -> io::Result<Vec<(FileIdentity, u64)>> {
778    let mut files = Vec::new();
779    for_each_regular_file(root, max_entries, false, |file, bytes| {
780        files.push((file, bytes));
781    })?;
782    Ok(files)
783}
784
785/// Visit each regular file under `root`. Links are never followed, so a link cannot
786/// make a file count twice. The walk goes depth first, holding one open directory
787/// per level, so its descriptors and memory grow with depth, not with the tree.
788///
789/// An entry that cannot be read is an error, so a size limit treats the tree as full;
790/// with `skip_vanished` one that was removed meanwhile (`NotFound`) is left out instead.
791fn for_each_regular_file(
792    root: &Dir,
793    max_entries: usize,
794    skip_vanished: bool,
795    mut visit: impl FnMut(FileIdentity, u64),
796) -> io::Result<()> {
797    let mut entries: usize = 0;
798    let mut open: Vec<cap_std::fs::ReadDir> = vec![root.entries()?];
799    while let Some(dir) = open.last_mut() {
800        let Some(entry) = dir.next() else {
801            open.pop();
802            continue;
803        };
804        let entry = entry?;
805        entries += 1;
806        if entries > max_entries {
807            return Err(io::Error::new(
808                io::ErrorKind::InvalidData,
809                format!("more than {max_entries} entries"),
810            ));
811        }
812        let Some(kind) = vanished(entry.file_type(), skip_vanished)? else {
813            continue;
814        };
815        if kind.is_dir() {
816            if open.len() > MAX_MEASURED_DEPTH {
817                return Err(io::Error::new(
818                    io::ErrorKind::InvalidData,
819                    format!("directories nested more than {MAX_MEASURED_DEPTH} deep"),
820                ));
821            }
822            let Some(child) = vanished(
823                entry.open_dir().and_then(|dir| dir.entries()),
824                skip_vanished,
825            )?
826            else {
827                continue;
828            };
829            open.push(child);
830        } else if kind.is_file() {
831            // Full metadata, which Windows reads through a handle, carries the
832            // file's identity.
833            let Some(metadata) = vanished(
834                cap_fs_ext::DirEntryExt::full_metadata(&entry),
835                skip_vanished,
836            )?
837            else {
838                continue;
839            };
840            visit(FileIdentity::of(&metadata)?, metadata.len());
841        }
842    }
843    Ok(())
844}
845
846/// `None` when the entry was removed meanwhile and `skip` is set.
847fn vanished<T>(result: io::Result<T>, skip: bool) -> io::Result<Option<T>> {
848    match result {
849        Ok(value) => Ok(Some(value)),
850        Err(error) if skip && error.kind() == io::ErrorKind::NotFound => Ok(None),
851        Err(error) => Err(error),
852    }
853}
854
855/// Why [`copy_host_dir`] stopped.
856#[derive(Debug)]
857pub enum CopyDirError {
858    Io(io::Error),
859    /// The copy went past its byte cap.
860    OverCap,
861    /// More entries or deeper nesting than a walk goes through, as for [`measure_host_dir`].
862    TooMany,
863}
864
865impl From<io::Error> for CopyDirError {
866    fn from(error: io::Error) -> Self {
867        Self::Io(error)
868    }
869}
870
871/// Copies the host directory `from` to `to`, adding the bytes copied to `bytes` and stopping
872/// once they pass `cap`.
873///
874/// Nothing under `from` is followed: every entry is opened relative to its already open
875/// parent without following links, so a program swapping an entry for a link mid-copy
876/// cannot get a file from outside the tree copied. A link is recreated as a link; an entry
877/// removed meanwhile is skipped. Only `from` itself, which the caller vouches for, is
878/// resolved by path.
879pub fn copy_host_dir(
880    from: &Path,
881    to: &Path,
882    cap: u64,
883    bytes: &mut u64,
884) -> Result<(), CopyDirError> {
885    let source = open_volume(from)?;
886    std::fs::create_dir_all(to)?;
887    let target = Dir::open_ambient_dir(to, ambient_authority())?;
888    let mut copy = TreeCopy {
889        cap,
890        bytes,
891        entries: 0,
892        rel: PathBuf::new(),
893    };
894    copy.dir(&source, &target, 0)
895}
896
897/// As [`copy_host_dir`], for the directory `sub` below the volume `volume`, copied to
898/// `to_root/sub`. `sub` is walked without following links, so one that is a link, or
899/// passes through one, is refused. `to_root` is made either way; a `sub` that is not in
900/// `volume` copies nothing.
901pub fn copy_host_subdir(
902    volume: &Path,
903    sub: &Path,
904    to_root: &Path,
905    cap: u64,
906    bytes: &mut u64,
907) -> Result<(), CopyDirError> {
908    std::fs::create_dir_all(to_root)?;
909    let Some(source) = open_host_subdir(volume, sub)? else {
910        return Ok(());
911    };
912    let mut target = Dir::open_ambient_dir(to_root, ambient_authority())?;
913    for component in sub.components() {
914        target = match target.create_dir(component) {
915            Ok(()) => target.open_dir_nofollow(component)?,
916            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
917                target.open_dir_nofollow(component)?
918            }
919            Err(error) => return Err(error.into()),
920        };
921    }
922    let mut copy = TreeCopy {
923        cap,
924        bytes,
925        entries: 0,
926        rel: sub.to_path_buf(),
927    };
928    copy.dir(&source, &target, 0)
929}
930
931#[derive(Clone, Copy)]
932enum EntryKind {
933    Dir,
934    File,
935    Link,
936    Other,
937}
938
939impl EntryKind {
940    fn of(file_type: cap_std::fs::FileType) -> Self {
941        if file_type.is_dir() {
942            Self::Dir
943        } else if file_type.is_file() {
944            Self::File
945        } else if file_type.is_symlink() {
946            Self::Link
947        } else {
948            Self::Other
949        }
950    }
951}
952
953struct TreeCopy<'a> {
954    cap: u64,
955    bytes: &'a mut u64,
956    entries: usize,
957    /// The directory being copied, relative to the root, for errors.
958    rel: PathBuf,
959}
960
961impl TreeCopy<'_> {
962    /// An error naming the entry it came from, relative to the root.
963    fn at(&self, name: &OsStr, error: io::Error) -> CopyDirError {
964        let path = self.rel.join(name);
965        let shown = if path.as_os_str().is_empty() {
966            Path::new(".")
967        } else {
968            &path
969        };
970        io::Error::new(error.kind(), format!("{}: {error}", shown.display())).into()
971    }
972
973    fn dir(&mut self, source: &Dir, target: &Dir, depth: usize) -> Result<(), CopyDirError> {
974        let entries = source
975            .entries()
976            .map_err(|error| self.at(OsStr::new(""), error))?;
977        for entry in entries {
978            let entry = entry.map_err(|error| self.at(OsStr::new(""), error))?;
979            self.entries += 1;
980            if self.entries > MAX_MEASURED_ENTRIES {
981                return Err(CopyDirError::TooMany);
982            }
983            let Some(file_type) = vanished(entry.file_type(), true)? else {
984                continue;
985            };
986            let kind = EntryKind::of(file_type);
987            self.entry(source, target, &entry.file_name(), kind, depth)?;
988        }
989        Ok(())
990    }
991
992    /// Copies the entry `name` of `source`, which was listed as `kind`. An entry that is
993    /// another kind by the time it is opened is copied as what it is, whatever it was listed as.
994    fn entry(
995        &mut self,
996        source: &Dir,
997        target: &Dir,
998        name: &OsStr,
999        kind: EntryKind,
1000        depth: usize,
1001    ) -> Result<(), CopyDirError> {
1002        match kind {
1003            EntryKind::Dir => self.subdir(source, target, name, depth),
1004            EntryKind::File => self.file(source, target, name, false),
1005            EntryKind::Link => self.link(source, target, name, false),
1006            EntryKind::Other => Ok(()),
1007        }
1008    }
1009
1010    fn subdir(
1011        &mut self,
1012        source: &Dir,
1013        target: &Dir,
1014        name: &OsStr,
1015        depth: usize,
1016    ) -> Result<(), CopyDirError> {
1017        if depth >= MAX_MEASURED_DEPTH {
1018            return Err(CopyDirError::TooMany);
1019        }
1020        let child = match source.open_dir_nofollow(name) {
1021            Ok(child) => child,
1022            Err(error) => return self.changed(source, target, name, EntryKind::Dir, error, false),
1023        };
1024        match target.create_dir(name) {
1025            Ok(()) => {}
1026            // Listed twice.
1027            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => return Ok(()),
1028            Err(error) => return Err(self.at(name, error)),
1029        }
1030        let copied = target
1031            .open_dir(name)
1032            .map_err(|error| self.at(name, error))?;
1033        self.rel.push(name);
1034        let result = self.dir(&child, &copied, depth + 1);
1035        self.rel.pop();
1036        result
1037    }
1038
1039    /// `reclassified`: the entry already turned out another kind than it was listed as.
1040    fn file(
1041        &mut self,
1042        source: &Dir,
1043        target: &Dir,
1044        name: &OsStr,
1045        reclassified: bool,
1046    ) -> Result<(), CopyDirError> {
1047        let mut options = cap_std::fs::OpenOptions::new();
1048        options.read(true).follow(FollowSymlinks::No);
1049        // A device or pipe swapped in must not block the open, nor become our terminal.
1050        #[cfg(unix)]
1051        cap_std::fs::OpenOptionsExt::custom_flags(&mut options, libc::O_NONBLOCK | libc::O_NOCTTY);
1052        let mut input = match source.open_with(name, &options) {
1053            Ok(file) => file,
1054            Err(error) => {
1055                return self.changed(source, target, name, EntryKind::File, error, reclassified);
1056            }
1057        };
1058        let metadata = input.metadata().map_err(|error| self.at(name, error))?;
1059        if !metadata.is_file() {
1060            return Ok(());
1061        }
1062        let mut output = match target.open_with(
1063            name,
1064            cap_std::fs::OpenOptions::new().write(true).create_new(true),
1065        ) {
1066            Ok(file) => file,
1067            // Listed twice.
1068            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => return Ok(()),
1069            Err(error) => return Err(self.at(name, error)),
1070        };
1071        // One byte past what is left is enough to tell the cap was passed.
1072        let room = self.cap.saturating_sub(*self.bytes).saturating_add(1);
1073        let copied = io::copy(&mut input.by_ref().take(room), &mut output)
1074            .map_err(|error| self.at(name, error))?;
1075        *self.bytes = self.bytes.saturating_add(copied);
1076        if *self.bytes > self.cap {
1077            return Err(CopyDirError::OverCap);
1078        }
1079        output
1080            .set_permissions(metadata.permissions())
1081            .map_err(|error| self.at(name, error))?;
1082        Ok(())
1083    }
1084
1085    fn link(
1086        &mut self,
1087        source: &Dir,
1088        target: &Dir,
1089        name: &OsStr,
1090        reclassified: bool,
1091    ) -> Result<(), CopyDirError> {
1092        // The target is kept as written, where `read_link` would refuse one that leaves
1093        // the directory; it is never resolved here.
1094        let to = match source.read_link_contents(name) {
1095            Ok(to) => to,
1096            Err(error) => {
1097                return self.changed(source, target, name, EntryKind::Link, error, reclassified);
1098            }
1099        };
1100        #[cfg(not(windows))]
1101        match target.symlink_contents(to, name) {
1102            Ok(()) => {}
1103            // Listed twice.
1104            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
1105            Err(error) => return Err(self.at(name, error)),
1106        }
1107        #[cfg(windows)]
1108        let _ = to;
1109        Ok(())
1110    }
1111
1112    /// Settles an entry that could not be handled as `listed`: gone is skipped, and one that
1113    /// is now another kind is copied as that, by its type without following links. An entry
1114    /// still of the kind listed fails the copy as unreadable, unless the error says it is not
1115    /// that kind (it flipped back meanwhile), which skips it like a vanished one. An entry is
1116    /// re-classified once: one that changed kind again meanwhile is skipped the same way, so a
1117    /// program flipping it cannot keep the copy at it.
1118    fn changed(
1119        &mut self,
1120        source: &Dir,
1121        target: &Dir,
1122        name: &OsStr,
1123        listed: EntryKind,
1124        error: io::Error,
1125        reclassified: bool,
1126    ) -> Result<(), CopyDirError> {
1127        if error.kind() == io::ErrorKind::NotFound {
1128            return Ok(());
1129        }
1130        if reclassified {
1131            return if wrong_kind(&error) {
1132                Ok(())
1133            } else {
1134                Err(self.at(name, error))
1135            };
1136        }
1137        let now = match source.symlink_metadata(name) {
1138            Ok(meta) => EntryKind::of(meta.file_type()),
1139            Err(gone) if gone.kind() == io::ErrorKind::NotFound => return Ok(()),
1140            Err(_) => return Err(self.at(name, error)),
1141        };
1142        match (listed, now) {
1143            (EntryKind::Dir | EntryKind::Link, EntryKind::File) => {
1144                self.file(source, target, name, true)
1145            }
1146            (EntryKind::Dir | EntryKind::File, EntryKind::Link) => {
1147                self.link(source, target, name, true)
1148            }
1149            // Swapped for a directory since it was listed: skipped like a vanished entry.
1150            (EntryKind::File | EntryKind::Link, EntryKind::Dir) | (_, EntryKind::Other) => Ok(()),
1151            // Still the kind listed: skipped only if the error says it is not that kind, as
1152            // when it flipped back since the check.
1153            _ if wrong_kind(&error) => Ok(()),
1154            _ => Err(self.at(name, error)),
1155        }
1156    }
1157}
1158
1159/// Whether `error` is the one an open or `read_link` gives an entry of another kind than
1160/// asked for: not a directory, a followed link refused, or not a link.
1161fn wrong_kind(error: &io::Error) -> bool {
1162    #[cfg(unix)]
1163    {
1164        matches!(
1165            error.raw_os_error(),
1166            Some(libc::ENOTDIR | libc::ELOOP | libc::EINVAL)
1167        )
1168    }
1169    #[cfg(not(unix))]
1170    {
1171        let _ = error;
1172        false
1173    }
1174}
1175
1176/// How many entries [`for_each_regular_file`] walks before it gives up, so a directory
1177/// crafted to be huge can't stall every run that opens it.
1178const MAX_MEASURED_ENTRIES: usize = 1_000_000;
1179
1180/// How many directories deep below the root [`for_each_regular_file`] descends
1181/// before it gives up, which bounds the directories it holds open at once.
1182pub const MAX_MEASURED_DEPTH: usize = 64;
1183
1184/// The root-relative form of a mount's guest path, refusing anything that is
1185/// not absolute, normalized, and made of ASCII letters, digits, `.`, `_` and
1186/// `-`. ASCII alone is what the case-folded mount-point guard compares
1187/// reliably on hosts whose filesystems fold case. The blueprint parser applies
1188/// the same rule; this repeats it for direct embedders.
1189fn mount_rel(guest: &str) -> Result<PathBuf, MountError> {
1190    let bad = || MountError::BadPath(guest.to_string());
1191    let Some(rest) = guest.strip_prefix('/') else {
1192        return Err(bad());
1193    };
1194    if rest.is_empty() {
1195        return Err(MountError::AtRoot);
1196    }
1197    let mut rel = PathBuf::new();
1198    for part in rest.split('/') {
1199        let plain = part
1200            .chars()
1201            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'));
1202        // A trailing dot is dropped by Windows, which would make `memory.` and
1203        // `memory` one directory.
1204        if part.is_empty() || part.ends_with('.') || !plain {
1205            return Err(bad());
1206        }
1207        rel.push(part);
1208    }
1209    Ok(rel)
1210}
1211
1212/// Whether `path` is `prefix` or below it, comparing whole components with
1213/// ASCII case folded.
1214fn starts_with_folded(path: &Path, prefix: &Path) -> bool {
1215    let mut parts = path.components();
1216    prefix.components().all(|want| {
1217        parts.next().is_some_and(|have| {
1218            have.as_os_str()
1219                .as_encoded_bytes()
1220                .eq_ignore_ascii_case(want.as_os_str().as_encoded_bytes())
1221        })
1222    })
1223}
1224
1225/// Make sure the mount point is a plain directory in the root, creating it (and
1226/// its parents) only when the root may be written.
1227///
1228/// Returns each directory on the way, the mount point last; see
1229/// [`placeholders_on`].
1230fn prepare_mount_point(
1231    root: &Dir,
1232    rel: &Path,
1233    access: Access,
1234    guest: &str,
1235) -> Result<Vec<Placeholder>, MountError> {
1236    let unavailable = |reason| MountError::MountPointUnavailable {
1237        path: guest.to_string(),
1238        reason,
1239    };
1240    let mut prefix = PathBuf::new();
1241    for part in rel.components() {
1242        prefix.push(part);
1243        match root.symlink_metadata(&prefix) {
1244            Ok(meta) if meta.is_dir() => {
1245                match spelling_of(root, &prefix).map_err(MountError::RootIo)? {
1246                    Spelling::Exact => {}
1247                    Spelling::Variant => {
1248                        return Err(unavailable(
1249                            "exists in the root under a different letter case; rename it to \
1250                             match",
1251                        ));
1252                    }
1253                    Spelling::Unknown => {
1254                        return Err(unavailable(
1255                            "sits beside too many entries to check its spelling; move some \
1256                             of them into a subdirectory",
1257                        ));
1258                    }
1259                }
1260            }
1261            Ok(_) => return Err(unavailable("exists in the root and is not a directory")),
1262            Err(err) if err.kind() == io::ErrorKind::NotFound => {
1263                if access == Access::ReadOnly {
1264                    return Err(unavailable(
1265                        "does not exist, and a read-only root cannot create it",
1266                    ));
1267                }
1268                match root.create_dir(&prefix) {
1269                    Ok(()) => {}
1270                    // Another run created it between the check and here.
1271                    Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {}
1272                    Err(err) => return Err(MountError::RootIo(err)),
1273                }
1274            }
1275            Err(err) => return Err(MountError::RootIo(err)),
1276        }
1277    }
1278    // Recheck: a racing writer may have swapped a link in after creation.
1279    match root.symlink_metadata(rel) {
1280        Ok(meta) if meta.is_dir() => {}
1281        Ok(_) => return Err(unavailable("exists in the root and is not a directory")),
1282        Err(err) => return Err(MountError::RootIo(err)),
1283    }
1284    placeholders_on(root, rel).map_err(MountError::RootIo)
1285}
1286
1287/// The identity and exact name of each directory on the way to `rel`, `rel`
1288/// last, taken after the recheck so a directory swapped in before it isn't the
1289/// one recorded. An identity that can't be read fails the mount: without it an
1290/// alias of the mount point would go unrecognized.
1291fn placeholders_on(root: &Dir, rel: &Path) -> io::Result<Vec<Placeholder>> {
1292    let mut placeholders = Vec::new();
1293    let mut prefix = PathBuf::new();
1294    for part in rel.components() {
1295        prefix.push(part);
1296        let meta = root.symlink_metadata(&prefix)?;
1297        placeholders.push(Placeholder {
1298            identity: dir_identity(root, &prefix, &meta)?,
1299            parent: prefix.parent().map(Path::to_path_buf).unwrap_or_default(),
1300            name: part.as_os_str().to_os_string(),
1301        });
1302    }
1303    Ok(placeholders)
1304}
1305
1306/// How an existing directory on the way to a mount point is spelled on disk.
1307enum Spelling {
1308    Exact,
1309    /// Found only through a case-insensitive lookup.
1310    Variant,
1311    /// Too many entries beside it to tell.
1312    Unknown,
1313}
1314
1315/// Whether the directory entry at `path` is spelled exactly so, not merely found
1316/// through a case-insensitive lookup. Recursive listings descend into a mount by
1317/// matching the entry's name, so a case variant standing in as the mount point
1318/// would hide the volume behind the root's own directory.
1319///
1320/// Checked on every host, since a Linux filesystem can fold case too (ext4
1321/// casefold, or a directory shared in from a Mac). Where the other case finds
1322/// nothing, or a different directory, the filesystem distinguishes case here and
1323/// the exact lookup that found `path` settles it; only a case-folding directory
1324/// is scanned for the exact name.
1325fn spelling_of(root: &Dir, path: &Path) -> io::Result<Spelling> {
1326    let Some(name) = path.file_name() else {
1327        return Ok(Spelling::Exact);
1328    };
1329    let flipped: String = name
1330        .to_string_lossy()
1331        .chars()
1332        .map(|c| {
1333            if c.is_ascii_lowercase() {
1334                c.to_ascii_uppercase()
1335            } else {
1336                c.to_ascii_lowercase()
1337            }
1338        })
1339        .collect();
1340    if flipped.as_str() == name {
1341        return Ok(Spelling::Exact);
1342    }
1343    let parent = path.parent().unwrap_or(Path::new(""));
1344    let other = match root.symlink_metadata(parent.join(&flipped)) {
1345        Ok(meta) => meta,
1346        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Spelling::Exact),
1347        Err(err) => return Err(err),
1348    };
1349    let this = root.symlink_metadata(path)?;
1350    if let (Ok(this), Ok(other)) = (FileIdentity::of(&this), FileIdentity::of(&other))
1351        && this != other
1352    {
1353        return Ok(Spelling::Exact);
1354    }
1355    let entries = if parent.as_os_str().is_empty() {
1356        root.entries()?
1357    } else {
1358        root.read_dir(parent)?
1359    };
1360    for (index, entry) in entries.enumerate() {
1361        if index >= MAX_SPELLING_SCAN {
1362            return Ok(Spelling::Unknown);
1363        }
1364        if entry?.file_name() == name {
1365            return Ok(Spelling::Exact);
1366        }
1367    }
1368    Ok(Spelling::Variant)
1369}
1370
1371/// How many entries [`spelling_of`] reads beside a mount point before giving
1372/// up, so a huge directory can't slow every run that mounts below it.
1373const MAX_SPELLING_SCAN: usize = 100_000;
1374
1375fn open_volume(host: &Path) -> io::Result<Arc<Dir>> {
1376    let meta = std::fs::metadata(host)?;
1377    if !meta.is_dir() {
1378        return Err(io::Error::new(
1379            io::ErrorKind::NotADirectory,
1380            "volume is not a directory",
1381        ));
1382    }
1383    Dir::open_ambient_dir(host, ambient_authority()).map(Arc::new)
1384}
1385
1386fn select_directory(
1387    mut dir: Arc<Dir>,
1388    sub_path: Option<&str>,
1389    access: Access,
1390) -> io::Result<(Arc<Dir>, Vec<FileIdentity>)> {
1391    let mut ancestors = vec![dir_identity(&dir, Path::new("."), &dir.dir_metadata()?)?];
1392    let Some(path) = sub_path else {
1393        return Ok((dir, ancestors));
1394    };
1395    if path.is_empty()
1396        || path.len() > 4096
1397        || path.split('/').any(|part| {
1398            part.is_empty() || part == "." || part == ".." || part.contains(['\\', '\0'])
1399        })
1400    {
1401        return Err(io::Error::new(
1402            io::ErrorKind::InvalidInput,
1403            "subPath must be a normalized relative path",
1404        ));
1405    }
1406    for component in path.split('/') {
1407        let child = match dir.open_dir_nofollow(component) {
1408            Ok(child) => child,
1409            Err(error)
1410                if error.kind() == io::ErrorKind::NotFound && access == Access::ReadWrite =>
1411            {
1412                match dir.create_dir(component) {
1413                    Ok(()) => {}
1414                    Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
1415                    Err(error) => return Err(error),
1416                }
1417                dir.open_dir_nofollow(component)?
1418            }
1419            Err(error) => return Err(error),
1420        };
1421        ancestors.push(dir_identity(
1422            &child,
1423            Path::new("."),
1424            &child.dir_metadata()?,
1425        )?);
1426        dir = Arc::new(child);
1427    }
1428    Ok((dir, ancestors))
1429}
1430
1431fn open_root(root: &Path) -> io::Result<Arc<Dir>> {
1432    Dir::open_ambient_dir(root, ambient_authority())
1433        .map(Arc::new)
1434        .map_err(|e| io::Error::new(e.kind(), format!("VFS root {}: {e}", root.display())))
1435}
1436
1437#[cfg(test)]
1438mod tests {
1439    use super::*;
1440
1441    #[test]
1442    fn subpath_creation_and_readonly_cwd() {
1443        let volume = tempfile::tempdir().unwrap();
1444        let vfs = Vfs::external(volume.path().into())
1445            .unwrap()
1446            .with_subpath(Some("users/ada"))
1447            .unwrap();
1448        assert!(volume.path().join("users/ada").is_dir());
1449        let vfs = vfs.with_cwd("/notes/drafts").unwrap();
1450        assert_eq!(vfs.cwd(), "/notes/drafts");
1451        assert!(volume.path().join("users/ada/notes/drafts").is_dir());
1452        let readonly = vfs.with_access(Access::ReadOnly);
1453        assert!(readonly.clone().with_cwd("/notes").is_ok());
1454        assert!(readonly.with_cwd("/missing").is_err());
1455        assert!(
1456            Vfs::external(volume.path().into())
1457                .unwrap()
1458                .with_access(Access::ReadOnly)
1459                .with_subpath(Some("absent"))
1460                .is_err()
1461        );
1462    }
1463
1464    #[cfg(unix)]
1465    #[test]
1466    fn subpath_selection_rejects_symlinks() {
1467        let volume = tempfile::tempdir().unwrap();
1468        std::fs::create_dir_all(volume.path().join("users/ada")).unwrap();
1469        std::os::unix::fs::symlink("ada", volume.path().join("users/alias")).unwrap();
1470        let vfs = Vfs::external(volume.path().into()).unwrap();
1471        assert!(vfs.with_subpath(Some("users/alias")).is_err());
1472    }
1473
1474    #[test]
1475    fn tempdir_is_deleted_on_drop() {
1476        let vfs = Vfs::tempdir().expect("tempdir");
1477        let path = vfs.root().to_path_buf();
1478        assert!(path.is_dir());
1479        drop(vfs);
1480        assert!(!path.exists(), "tempdir should be deleted on drop");
1481    }
1482
1483    #[test]
1484    fn external_does_not_delete_directory() {
1485        let outer = tempfile::tempdir().expect("outer tempdir");
1486        let path = outer.path().to_path_buf();
1487        let vfs = Vfs::external(path.clone()).expect("external");
1488        drop(vfs);
1489        assert!(path.is_dir(), "external path must survive Vfs drop");
1490    }
1491
1492    #[test]
1493    fn external_rejects_missing_path() {
1494        let outer = tempfile::tempdir().expect("outer tempdir");
1495        let missing = outer.path().join("does-not-exist");
1496        let err = Vfs::external(missing).expect_err("must reject missing path");
1497        assert!(
1498            err.to_string().contains("VFS root"),
1499            "error should mention VFS root: {err}"
1500        );
1501    }
1502
1503    #[test]
1504    fn real_directory_yields_a_usable_handle() {
1505        let outer = tempfile::tempdir().expect("outer tempdir");
1506        std::fs::write(outer.path().join("a.txt"), b"hi").expect("write");
1507        let vfs = Vfs::external(outer.path().to_path_buf()).expect("external");
1508        let dir = vfs.dir().expect("an external root backs a directory");
1509        assert_eq!(dir.read("a.txt").expect("read through handle"), b"hi");
1510    }
1511
1512    #[test]
1513    fn none_backs_no_handle() {
1514        let vfs = Vfs::none();
1515        assert!(vfs.dir().is_none());
1516        assert_eq!(vfs.mode(), VfsMode::None);
1517    }
1518
1519    #[test]
1520    fn tempdir_modes_carry_a_handle() {
1521        let vfs = Vfs::tempdir().expect("tempdir");
1522        assert!(
1523            vfs.dir().is_some(),
1524            "ephemeral roots resolve through a handle too"
1525        );
1526    }
1527
1528    #[test]
1529    fn handle_survives_a_rename_of_the_root() {
1530        let outer = tempfile::tempdir().expect("outer tempdir");
1531        let from = outer.path().join("before");
1532        let to = outer.path().join("after");
1533        std::fs::create_dir(&from).expect("mkdir");
1534        std::fs::write(from.join("a.txt"), b"hi").expect("write");
1535
1536        let vfs = Vfs::external(from.clone()).expect("external");
1537        std::fs::rename(&from, &to).expect("rename");
1538
1539        // The handle tracks the inode, not the name — which is also why it cannot be
1540        // used as a liveness check for the directory it was opened on.
1541        let dir = vfs.dir().expect("handle");
1542        assert_eq!(dir.read("a.txt").expect("read after rename"), b"hi");
1543    }
1544
1545    #[test]
1546    fn external_rejects_file() {
1547        let outer = tempfile::tempdir().expect("outer tempdir");
1548        let file = outer.path().join("a.txt");
1549        std::fs::write(&file, b"hi").expect("write");
1550        let err = Vfs::external(file).expect_err("must reject file");
1551        assert!(
1552            err.to_string().contains("not a directory"),
1553            "error should mention not a directory: {err}"
1554        );
1555    }
1556
1557    #[test]
1558    fn size_limit_starts_from_what_the_directory_holds() {
1559        let outer = tempfile::tempdir().expect("outer tempdir");
1560        std::fs::write(outer.path().join("a.txt"), vec![0u8; 300]).expect("write");
1561        std::fs::create_dir(outer.path().join("sub")).expect("mkdir");
1562        std::fs::write(outer.path().join("sub/b.txt"), vec![0u8; 200]).expect("write");
1563        #[cfg(unix)]
1564        std::os::unix::fs::symlink("a.txt", outer.path().join("link")).expect("symlink");
1565
1566        let vfs = Vfs::external_with_mode(outer.path().to_path_buf(), VfsMode::PerSession)
1567            .expect("external")
1568            .with_size_limit(1_000);
1569        let quota = vfs.quota().expect("quota attached");
1570        assert_eq!(quota.used(), 500, "links count as nothing");
1571        assert_eq!(quota.limit(), 1_000);
1572        assert!(
1573            vfs.clone().quota().is_some_and(|q| Arc::ptr_eq(q, quota)),
1574            "clones share it"
1575        );
1576    }
1577
1578    #[test]
1579    fn measuring_many_directories_holds_one_open_per_level() {
1580        let outer = tempfile::tempdir().expect("outer tempdir");
1581        let mut path = outer.path().to_path_buf();
1582        for depth in 0..3_000 {
1583            if depth % 60 == 0 {
1584                path = outer.path().join(format!("branch{depth}"));
1585            } else {
1586                path = path.join("d");
1587            }
1588            std::fs::create_dir_all(&path).expect("mkdir");
1589        }
1590        std::fs::write(path.join("leaf.txt"), b"12345").expect("write");
1591        let root = Vfs::external(outer.path().to_path_buf()).expect("external");
1592        assert_eq!(root.measure_usage().expect("measure"), 5);
1593    }
1594
1595    #[test]
1596    fn listing_files_stops_past_its_entry_cap() {
1597        let outer = tempfile::tempdir().expect("outer tempdir");
1598        std::fs::write(outer.path().join("a"), b"1").expect("write");
1599        std::fs::create_dir(outer.path().join("d")).expect("mkdir");
1600        let root = Vfs::external(outer.path().to_path_buf()).expect("external");
1601        let dir = root.dir().expect("dir");
1602        assert_eq!(regular_files(dir, 2).expect("two entries fit").len(), 1);
1603        std::fs::write(outer.path().join("d/b"), b"2").expect("write");
1604        let error = regular_files(dir, 2).expect_err("a third entry passes the cap");
1605        assert_eq!(error.kind(), io::ErrorKind::InvalidData);
1606    }
1607
1608    #[test]
1609    fn a_tree_nested_past_the_measured_depth_opens_as_full() {
1610        let outer = tempfile::tempdir().expect("outer tempdir");
1611        let mut path = outer.path().to_path_buf();
1612        for _ in 0..MAX_MEASURED_DEPTH {
1613            path = path.join("d");
1614        }
1615        std::fs::create_dir_all(&path).expect("mkdir");
1616        let fits = Vfs::external(outer.path().to_path_buf()).expect("external");
1617        assert_eq!(fits.measure_usage().expect("measure"), 0);
1618
1619        std::fs::create_dir(path.join("d")).expect("mkdir");
1620        let vfs = Vfs::external(outer.path().to_path_buf())
1621            .expect("external")
1622            .with_size_limit(1_000);
1623        assert!(vfs.quota().is_some_and(|quota| quota.is_unmeasured()));
1624    }
1625
1626    #[cfg(unix)]
1627    #[test]
1628    fn a_directory_that_cannot_be_read_opens_as_full() {
1629        use std::os::unix::fs::PermissionsExt;
1630        let outer = tempfile::tempdir().expect("outer tempdir");
1631        let locked = outer.path().join("locked");
1632        std::fs::create_dir(&locked).expect("mkdir");
1633        std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).expect("chmod");
1634        let vfs = Vfs::external(outer.path().to_path_buf())
1635            .expect("external")
1636            .with_size_limit(1_000);
1637        let unmeasured = vfs.quota().is_some_and(|quota| quota.is_unmeasured());
1638        std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755))
1639            .expect("chmod back");
1640        assert!(unmeasured, "a failed walk leaves the VFS treated as full");
1641    }
1642
1643    #[test]
1644    fn only_a_walk_that_skips_vanished_entries_survives_one() {
1645        let gone = || io::Result::<u8>::Err(io::ErrorKind::NotFound.into());
1646        // A size limit's measure fails, so the directory opens as full.
1647        assert!(vanished(gone(), false).is_err());
1648        assert_eq!(vanished(gone(), true).expect("skipped"), None);
1649        let denied = io::Result::<u8>::Err(io::ErrorKind::PermissionDenied.into());
1650        assert!(vanished(denied, true).is_err());
1651        assert_eq!(vanished(Ok(1u8), false).expect("ok"), Some(1));
1652        let dir = tempfile::tempdir().expect("dir");
1653        std::fs::write(dir.path().join("a"), "abc").expect("write");
1654        assert_eq!(measure_host_dir(dir.path()).expect("strict"), 3);
1655        assert_eq!(
1656            measure_host_dir_skipping_vanished(dir.path()).expect("lenient"),
1657            3
1658        );
1659    }
1660
1661    fn copied(from: &Path, cap: u64) -> (tempfile::TempDir, Result<u64, CopyDirError>) {
1662        let to = tempfile::tempdir().expect("to");
1663        let mut bytes = 0;
1664        let result = copy_host_dir(from, &to.path().join("out"), cap, &mut bytes).map(|()| bytes);
1665        (to, result)
1666    }
1667
1668    #[test]
1669    fn a_copy_has_the_files_and_stops_at_the_cap() {
1670        let from = tempfile::tempdir().expect("from");
1671        std::fs::create_dir(from.path().join("sub")).expect("mkdir");
1672        std::fs::write(from.path().join("sub/a.txt"), "hello").expect("write");
1673        let (to, result) = copied(from.path(), 100);
1674        assert_eq!(result.expect("copied"), 5);
1675        assert_eq!(
1676            std::fs::read_to_string(to.path().join("out/sub/a.txt")).expect("read"),
1677            "hello"
1678        );
1679        let (_to, result) = copied(from.path(), 4);
1680        assert!(matches!(result, Err(CopyDirError::OverCap)));
1681    }
1682
1683    #[cfg(unix)]
1684    #[test]
1685    fn a_copy_recreates_links_and_never_follows_them() {
1686        let outside = tempfile::tempdir().expect("outside");
1687        std::fs::write(outside.path().join("secret"), "host file").expect("write");
1688        std::fs::create_dir(outside.path().join("dir")).expect("mkdir");
1689        std::fs::write(outside.path().join("dir/inner"), "host dir file").expect("write");
1690        let from = tempfile::tempdir().expect("from");
1691        std::os::unix::fs::symlink(outside.path().join("secret"), from.path().join("file-link"))
1692            .expect("symlink");
1693        std::os::unix::fs::symlink(outside.path().join("dir"), from.path().join("dir-link"))
1694            .expect("symlink");
1695        let (to, result) = copied(from.path(), 1_000);
1696        assert_eq!(result.expect("copied"), 0, "no linked content is copied");
1697        for name in ["file-link", "dir-link"] {
1698            let copy = to.path().join("out").join(name);
1699            assert!(std::fs::symlink_metadata(&copy).expect("kept").is_symlink());
1700        }
1701        assert!(!to.path().join("out/dir-link-copy").exists());
1702    }
1703
1704    #[cfg(unix)]
1705    #[test]
1706    fn an_entry_that_is_a_link_when_opened_is_not_followed() {
1707        let outside = tempfile::tempdir().expect("outside");
1708        std::fs::write(outside.path().join("secret"), "host file").expect("write");
1709        std::fs::create_dir(outside.path().join("dir")).expect("mkdir");
1710        let from = tempfile::tempdir().expect("from");
1711        let to = tempfile::tempdir().expect("to");
1712        std::os::unix::fs::symlink(outside.path().join("secret"), from.path().join("swapped"))
1713            .expect("symlink");
1714        std::os::unix::fs::symlink(outside.path().join("dir"), from.path().join("swapped-dir"))
1715            .expect("symlink");
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        // Listed as a file and a directory, as they were before being swapped.
1726        copy.entry(&source, &target, OsStr::new("swapped"), EntryKind::File, 0)
1727            .expect("file");
1728        copy.entry(
1729            &source,
1730            &target,
1731            OsStr::new("swapped-dir"),
1732            EntryKind::Dir,
1733            0,
1734        )
1735        .expect("dir");
1736        assert_eq!(bytes, 0);
1737        for name in ["swapped", "swapped-dir"] {
1738            let copy = to.path().join(name);
1739            assert!(std::fs::symlink_metadata(&copy).expect("kept").is_symlink());
1740        }
1741    }
1742
1743    #[test]
1744    fn an_entry_removed_since_it_was_listed_is_skipped() {
1745        let from = tempfile::tempdir().expect("from");
1746        let to = tempfile::tempdir().expect("to");
1747        let source = Dir::open_ambient_dir(from.path(), ambient_authority()).expect("source");
1748        let target = Dir::open_ambient_dir(to.path(), ambient_authority()).expect("target");
1749        let mut bytes = 0;
1750        let mut copy = TreeCopy {
1751            cap: 1_000,
1752            bytes: &mut bytes,
1753            entries: 0,
1754            rel: PathBuf::new(),
1755        };
1756        for kind in [EntryKind::Dir, EntryKind::File, EntryKind::Link] {
1757            copy.entry(&source, &target, OsStr::new("gone"), kind, 0)
1758                .expect("skipped");
1759        }
1760        assert_eq!(std::fs::read_dir(to.path()).expect("list").count(), 0);
1761    }
1762
1763    #[test]
1764    fn an_entry_that_changed_kind_since_it_was_listed_is_copied_as_what_it_is() {
1765        let from = tempfile::tempdir().expect("from");
1766        let to = tempfile::tempdir().expect("to");
1767        std::fs::write(from.path().join("was-dir"), "abc").expect("write");
1768        std::fs::write(from.path().join("was-link"), "de").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        copy.entry(&source, &target, OsStr::new("was-dir"), EntryKind::Dir, 0)
1779            .expect("dir now a file");
1780        copy.entry(&source, &target, OsStr::new("was-link"), EntryKind::Link, 0)
1781            .expect("link now a file");
1782        // The same name listed again is left as copied.
1783        copy.entry(&source, &target, OsStr::new("was-dir"), EntryKind::File, 0)
1784            .expect("duplicate");
1785        assert_eq!(bytes, 5);
1786        assert_eq!(
1787            std::fs::read_to_string(to.path().join("was-link")).expect("read"),
1788            "de"
1789        );
1790    }
1791
1792    #[cfg(unix)]
1793    #[test]
1794    fn an_entry_that_changes_kind_a_second_time_is_skipped_not_chased() {
1795        let outside = tempfile::tempdir().expect("outside");
1796        let from = tempfile::tempdir().expect("from");
1797        let to = tempfile::tempdir().expect("to");
1798        std::os::unix::fs::symlink(outside.path(), from.path().join("now-link")).expect("symlink");
1799        std::fs::write(from.path().join("now-file"), "abc").expect("write");
1800        let source = Dir::open_ambient_dir(from.path(), ambient_authority()).expect("source");
1801        let target = Dir::open_ambient_dir(to.path(), ambient_authority()).expect("target");
1802        let mut bytes = 0;
1803        let mut copy = TreeCopy {
1804            cap: 1_000,
1805            bytes: &mut bytes,
1806            entries: 0,
1807            rel: PathBuf::new(),
1808        };
1809        // Listed as a file, it is a link: copied as one, once.
1810        copy.file(&source, &target, OsStr::new("now-link"), false)
1811            .expect("re-classified once");
1812        assert!(
1813            std::fs::symlink_metadata(to.path().join("now-link"))
1814                .expect("kept")
1815                .is_symlink()
1816        );
1817        // Already re-classified, one that fails again is skipped, not re-classified again:
1818        // here a link that reads as a file, as if it had flipped back.
1819        copy.link(&source, &target, OsStr::new("now-file"), true)
1820            .expect("skipped");
1821        copy.file(&source, &target, OsStr::new("now-link"), true)
1822            .expect("skipped");
1823        assert!(!to.path().join("now-file").exists());
1824        assert_eq!(bytes, 0);
1825    }
1826
1827    #[cfg(unix)]
1828    #[test]
1829    fn an_entry_that_flipped_back_is_skipped_but_an_unreadable_one_still_fails() {
1830        use std::os::unix::fs::PermissionsExt;
1831        let from = tempfile::tempdir().expect("from");
1832        std::fs::write(from.path().join("plain"), "abc").expect("write");
1833        std::fs::create_dir(from.path().join("dir")).expect("mkdir");
1834        let locked = from.path().join("locked");
1835        std::fs::write(&locked, "x").expect("write");
1836        std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).expect("chmod");
1837        let source = Dir::open_ambient_dir(from.path(), ambient_authority()).expect("source");
1838        let to = tempfile::tempdir().expect("to");
1839        let target = Dir::open_ambient_dir(to.path(), ambient_authority()).expect("target");
1840        let mut bytes = 0;
1841        let mut copy = TreeCopy {
1842            cap: 1_000,
1843            bytes: &mut bytes,
1844            entries: 0,
1845            rel: PathBuf::new(),
1846        };
1847        // Listed as a directory, a file, and a link; each is still the kind that now fails
1848        // the open the way a flipped-back entry does.
1849        copy.entry(&source, &target, OsStr::new("plain"), EntryKind::Dir, 0)
1850            .expect("file listed as dir, re-classified");
1851        let not_a_dir = io::Error::from_raw_os_error(libc::ENOTDIR);
1852        copy.changed(
1853            &source,
1854            &target,
1855            OsStr::new("dir"),
1856            EntryKind::Dir,
1857            not_a_dir,
1858            false,
1859        )
1860        .expect("flipped back to a directory");
1861        let not_a_link = io::Error::from_raw_os_error(libc::EINVAL);
1862        copy.changed(
1863            &source,
1864            &target,
1865            OsStr::new("plain"),
1866            EntryKind::File,
1867            not_a_link,
1868            false,
1869        )
1870        .expect("flipped back to a file");
1871        let loop_error = io::Error::from_raw_os_error(libc::ELOOP);
1872        copy.changed(
1873            &source,
1874            &target,
1875            OsStr::new("plain"),
1876            EntryKind::Link,
1877            loop_error,
1878            true,
1879        )
1880        .expect("reclassified, wrong kind");
1881        // A privileged user reads it anyway.
1882        if unsafe { libc::geteuid() } != 0 {
1883            // Reclassified: only wrong-kind errors skip; an unreadable file fails.
1884            let result = copy.file(&source, &target, OsStr::new("locked"), true);
1885            std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o600))
1886                .expect("chmod");
1887            let Err(CopyDirError::Io(error)) = result else {
1888                panic!("expected an I/O error, got {result:?}");
1889            };
1890            assert!(error.to_string().starts_with("locked: "), "{error}");
1891        }
1892        std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o600)).expect("chmod");
1893        // Other errors on an entry still of the kind listed fail too.
1894        let denied = io::Error::from_raw_os_error(libc::EIO);
1895        let result = copy.changed(
1896            &source,
1897            &target,
1898            OsStr::new("dir"),
1899            EntryKind::Dir,
1900            denied,
1901            false,
1902        );
1903        assert!(matches!(result, Err(CopyDirError::Io(_))));
1904    }
1905
1906    #[cfg(unix)]
1907    #[test]
1908    fn an_unreadable_entry_fails_the_copy_naming_its_path_in_the_tree() {
1909        use std::os::unix::fs::PermissionsExt;
1910        let from = tempfile::tempdir().expect("from");
1911        std::fs::create_dir(from.path().join("sub")).expect("mkdir");
1912        let locked = from.path().join("sub/locked");
1913        std::fs::write(&locked, "x").expect("write");
1914        std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).expect("chmod");
1915        let (_to, result) = copied(from.path(), 1_000);
1916        std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o600)).expect("chmod");
1917        // A privileged user reads it anyway.
1918        if unsafe { libc::geteuid() } == 0 {
1919            return;
1920        }
1921        let Err(CopyDirError::Io(error)) = result else {
1922            panic!("expected an I/O error, got {result:?}");
1923        };
1924        let message = error.to_string();
1925        assert!(message.starts_with("sub/locked: "), "{message}");
1926        assert!(!message.contains(from.path().to_str().expect("utf8")));
1927    }
1928
1929    #[test]
1930    fn a_missing_component_opens_as_none() {
1931        let volume = tempfile::tempdir().expect("volume");
1932        std::fs::create_dir_all(volume.path().join("a")).expect("mkdir");
1933        assert!(
1934            open_host_subdir(volume.path(), Path::new("a"))
1935                .unwrap()
1936                .is_some()
1937        );
1938        assert!(
1939            open_host_subdir(volume.path(), Path::new("a/b/c"))
1940                .unwrap()
1941                .is_none()
1942        );
1943        assert!(
1944            open_host_subdir(volume.path(), Path::new("x"))
1945                .unwrap()
1946                .is_none()
1947        );
1948        assert_eq!(
1949            measure_host_subdir_skipping_vanished(volume.path(), Path::new("a/b")).unwrap(),
1950            0
1951        );
1952    }
1953
1954    #[cfg(unix)]
1955    #[test]
1956    fn a_link_component_is_an_error() {
1957        let volume = tempfile::tempdir().expect("volume");
1958        let outside = tempfile::tempdir().expect("outside");
1959        std::fs::create_dir_all(outside.path().join("deep")).expect("mkdir");
1960        std::fs::write(outside.path().join("deep/f"), "x").expect("write");
1961        std::fs::create_dir_all(volume.path().join("a")).expect("mkdir");
1962        std::os::unix::fs::symlink(outside.path(), volume.path().join("a/link")).expect("link");
1963        for sub in ["a/link", "a/link/deep"] {
1964            let error = open_host_subdir(volume.path(), Path::new(sub)).expect_err("refused");
1965            assert!(error.to_string().starts_with(sub), "{error}");
1966            assert!(measure_host_subdir_skipping_vanished(volume.path(), Path::new(sub)).is_err());
1967            let to = tempfile::tempdir().expect("to");
1968            let result = copy_host_subdir(volume.path(), Path::new(sub), to.path(), 1_000, &mut 0);
1969            assert!(matches!(result, Err(CopyDirError::Io(_))));
1970        }
1971    }
1972
1973    #[test]
1974    fn a_sub_path_is_copied_to_the_same_place_under_the_target() {
1975        let from = tempfile::tempdir().expect("from");
1976        std::fs::create_dir_all(from.path().join("users/ada/notes")).expect("mkdir");
1977        std::fs::write(from.path().join("users/ada/notes/n.txt"), "note").expect("write");
1978        std::fs::write(from.path().join("users/ada/a.txt"), "ada").expect("write");
1979        std::fs::write(from.path().join("users/b.txt"), "bob").expect("write");
1980        std::fs::write(from.path().join("top.txt"), "top").expect("write");
1981        let to = tempfile::tempdir().expect("to");
1982        let mut bytes = 0;
1983        copy_host_subdir(
1984            from.path(),
1985            Path::new("users/ada"),
1986            to.path(),
1987            1_000,
1988            &mut bytes,
1989        )
1990        .expect("copied");
1991        assert_eq!(bytes, 7);
1992        let read = |rel: &str| std::fs::read_to_string(to.path().join(rel)).expect(rel);
1993        assert_eq!(read("users/ada/notes/n.txt"), "note");
1994        assert_eq!(read("users/ada/a.txt"), "ada");
1995        assert!(!to.path().join("users/b.txt").exists());
1996        assert!(!to.path().join("top.txt").exists());
1997        assert_eq!(
1998            measure_host_subdir_skipping_vanished(from.path(), Path::new("users/ada")).unwrap(),
1999            7
2000        );
2001    }
2002
2003    #[test]
2004    fn a_copy_stops_in_a_tree_nested_too_deep() {
2005        let from = tempfile::tempdir().expect("from");
2006        let mut path = from.path().to_path_buf();
2007        for _ in 0..=MAX_MEASURED_DEPTH + 1 {
2008            path = path.join("d");
2009        }
2010        std::fs::create_dir_all(&path).expect("mkdir");
2011        let (_to, result) = copied(from.path(), 1_000);
2012        assert!(matches!(result, Err(CopyDirError::TooMany)));
2013    }
2014}