Skip to main content

submilli_engine/runtime/
fs.rs

1//! VFS path resolver for the `submilli:fs` host module.
2//!
3//! Guest paths reach the host filesystem through exactly two entry points here:
4//! [`resolve_content`] for operations that reach a path's *contents*, and
5//! [`resolve_link`] for operations that act on the *link itself*. Both hand back a
6//! newtype carrying the VFS capability handle, never a host path — see
7//! [`ContentPath`] for why that distinction is the boundary rather than a
8//! convention.
9
10use std::ffi::{OsStr, OsString};
11use std::fmt;
12use std::io;
13use std::path::{Component, Path, PathBuf};
14use std::sync::Arc;
15
16use cap_fs_ext::{DirExt, OpenOptionsSyncExt};
17use cap_std::fs::{Dir, File, Metadata, OpenOptions, ReadDir};
18
19use crate::runtime::Vfs;
20use crate::runtime::vfs::{Access, Mount, Placement};
21
22pub(crate) mod removal;
23
24#[derive(Debug, PartialEq, Eq, Clone)]
25pub enum ResolveError {
26    Escape,
27    EmbeddedNul,
28    UnsupportedPrefix,
29}
30
31impl fmt::Display for ResolveError {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        match self {
34            Self::Escape => f.write_str("path escapes the VFS root"),
35            Self::EmbeddedNul => f.write_str("path contains a NUL byte"),
36            Self::UnsupportedPrefix => f.write_str("Windows-style path prefixes are not supported"),
37        }
38    }
39}
40
41impl std::error::Error for ResolveError {}
42
43fn push_component(stack: &mut Vec<OsString>, comp: Component<'_>) -> Result<(), ResolveError> {
44    match comp {
45        Component::Prefix(_) => Err(ResolveError::UnsupportedPrefix),
46        // RootDir is a no-op: output is always anchored at the VFS root handle.
47        Component::RootDir | Component::CurDir => Ok(()),
48        Component::ParentDir => {
49            if stack.pop().is_none() {
50                Err(ResolveError::Escape)
51            } else {
52                Ok(())
53            }
54        }
55        Component::Normal(name) => {
56            stack.push(name.to_os_string());
57            Ok(())
58        }
59    }
60}
61
62/// Failure resolving or operating on a guest path under containment.
63///
64/// Deliberately distinct from [`ResolveError`], which covers only the lexical pass.
65/// Once a path is being opened, an escape is something the kernel (or `cap-primitives`)
66/// reports, and it must not arrive at the stdlib boundary as a bare [`io::Error`] that
67/// a call site might format as "permission denied".
68#[derive(Debug)]
69pub enum ContainError {
70    /// The path leaves the VFS root — lexically, or through a symlink `cap-std` refused.
71    Escape,
72    EmbeddedNul,
73    UnsupportedPrefix,
74    /// The VFS backs no directory (`vfs: none`).
75    Disabled,
76    /// The path lives in a volume mounted read-only; carries its mount point.
77    ReadOnly(Arc<str>),
78    /// The change would remove, replace or write through a mount point;
79    /// carries the mount point.
80    MountPoint(Arc<str>),
81    /// A genuine filesystem failure that is not an escape.
82    Io(io::Error),
83}
84
85impl fmt::Display for ContainError {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        match self {
88            Self::Escape => f.write_str("path escapes the VFS root"),
89            Self::EmbeddedNul => f.write_str("path contains a NUL byte"),
90            Self::UnsupportedPrefix => f.write_str("Windows-style path prefixes are not supported"),
91            Self::Disabled => f.write_str("filesystem is disabled (vfs mode: none)"),
92            Self::ReadOnly(mount) => write!(f, "the volume mounted at {mount} is read-only"),
93            Self::MountPoint(mount) => write!(
94                f,
95                "{mount} is a mount point; mount points and the directories above them \
96                 cannot be removed, moved, replaced or written through"
97            ),
98            Self::Io(e) => write!(f, "{e}"),
99        }
100    }
101}
102
103impl std::error::Error for ContainError {}
104
105impl From<ResolveError> for ContainError {
106    fn from(err: ResolveError) -> Self {
107        match err {
108            ResolveError::Escape => Self::Escape,
109            ResolveError::EmbeddedNul => Self::EmbeddedNul,
110            ResolveError::UnsupportedPrefix => Self::UnsupportedPrefix,
111        }
112    }
113}
114
115impl From<io::Error> for ContainError {
116    fn from(err: io::Error) -> Self {
117        if is_escape(&err) {
118            Self::Escape
119        } else {
120            Self::Io(err)
121        }
122    }
123}
124
125/// Whether an [`io::Error`] from a `cap-std` call is a containment refusal.
126///
127/// `cap-primitives` *constructs* its escape error rather than surfacing an OS one, so
128/// an escape is a `PermissionDenied` carrying no `raw_os_error`. A genuine `EACCES`
129/// from the kernel carries `Some(13)`, which is what keeps the two apart — and why
130/// this is a classification rather than a guess.
131pub fn is_escape(err: &io::Error) -> bool {
132    err.kind() == io::ErrorKind::PermissionDenied && err.raw_os_error().is_none()
133}
134
135/// A guest path resolved for an operation that reaches the path's *contents*.
136///
137/// Carries the VFS root handle plus the root-relative path. Every component
138/// including the last is traversed under containment, so a symlink anywhere along
139/// the way that leaves the root is refused when the path is opened.
140///
141/// There is deliberately no accessor yielding a `Path`, and no `Deref` or `AsRef`:
142/// a relative path handed to `std::fs` resolves against the *server process* working
143/// directory, so `std::fs::read("etc/passwd")` from a process whose cwd is `/` would
144/// be a host read with no diagnostic. Every consumer outside this module is therefore
145/// a compile error rather than a silent reinterpretation.
146#[derive(Clone)]
147pub struct ContentPath {
148    dir: Arc<Dir>,
149    rel: PathBuf,
150    placement: Placement,
151}
152
153impl fmt::Debug for ContentPath {
154    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155        f.debug_struct("ContentPath")
156            .field("rel", &self.rel)
157            .finish_non_exhaustive()
158    }
159}
160
161impl ContentPath {
162    pub(crate) fn new(dir: Arc<Dir>, rel: PathBuf, placement: Placement) -> Self {
163        Self {
164            dir,
165            rel,
166            placement,
167        }
168    }
169
170    /// The volume this path lives in.
171    pub fn placement(&self) -> &Placement {
172        &self.placement
173    }
174
175    /// The mounts strictly below this directory, each as its path relative to
176    /// this one and the handle of the volume behind it: what a recursive walk
177    /// descends into in place of the empty mount point in the root.
178    pub fn mounts_below(&self) -> Vec<(PathBuf, Arc<Dir>)> {
179        let base = if self.rel == Path::new(".") {
180            Path::new("")
181        } else {
182            self.rel.as_path()
183        };
184        self.placement
185            .nested()
186            .iter()
187            .filter_map(|mount| {
188                let rest = mount.rel().strip_prefix(base).ok()?;
189                (!rest.as_os_str().is_empty())
190                    .then(|| (rest.to_path_buf(), Arc::clone(mount.dir())))
191            })
192            .collect()
193    }
194
195    /// Refuse when the volume is mounted read-only. Every mutation below checks
196    /// this too; host functions call it first so the refusal comes before any
197    /// other work and carries the caller.
198    pub fn writable(&self) -> Result<(), ContainError> {
199        match self.placement.access() {
200            Access::ReadWrite => Ok(()),
201            Access::ReadOnly => Err(ContainError::ReadOnly(Arc::from(
202                self.placement.mount_point(),
203            ))),
204        }
205    }
206
207    fn check_mutation(&self, recursive: bool) -> Result<(), ContainError> {
208        self.writable()?;
209        check_mount_points(&self.dir, &self.rel, &self.placement, recursive, true)?;
210        check_metadata_mutation(&self.dir, &self.rel, recursive, true)
211    }
212
213    fn check_link_mutation(&self, recursive: bool) -> Result<(), ContainError> {
214        self.writable()?;
215        check_mount_points(&self.dir, &self.rel, &self.placement, recursive, false)?;
216        check_metadata_mutation(&self.dir, &self.rel, recursive, false)
217    }
218
219    pub fn open(&self) -> Result<File, ContainError> {
220        Ok(self.dir.open(&self.rel)?)
221    }
222
223    /// Refuse special files without waiting for a FIFO writer. The descriptor check
224    /// also covers a regular path replaced between metadata inspection and open.
225    pub(crate) fn open_regular(&self) -> Result<(File, Metadata), ContainError> {
226        require_regular_file(&self.metadata()?)?;
227        let mut options = OpenOptions::new();
228        options.read(true).nonblock(true);
229        let file = self.dir.open_with(&self.rel, &options)?;
230        let metadata = file.metadata()?;
231        require_regular_file(&metadata)?;
232        Ok((file, metadata))
233    }
234
235    pub fn create(&self) -> Result<File, ContainError> {
236        self.check_mutation(false)?;
237        Ok(self.dir.create(&self.rel)?)
238    }
239
240    /// Create, refusing if the path already exists. Race-free replacement for an
241    /// `exists()` check followed by `create()`.
242    pub fn create_new(&self) -> Result<File, ContainError> {
243        self.check_mutation(false)?;
244        let mut opts = OpenOptions::new();
245        opts.write(true).create_new(true);
246        Ok(self.dir.open_with(&self.rel, &opts)?)
247    }
248
249    pub fn append(&self) -> Result<File, ContainError> {
250        self.check_mutation(false)?;
251        let mut opts = OpenOptions::new();
252        opts.create(true).append(true);
253        Ok(self.dir.open_with(&self.rel, &opts)?)
254    }
255
256    pub fn read(&self) -> Result<Vec<u8>, ContainError> {
257        Ok(self.dir.read(&self.rel)?)
258    }
259
260    /// Metadata with symlinks followed, so an escaping link at any component refuses.
261    pub fn metadata(&self) -> Result<Metadata, ContainError> {
262        Ok(self.dir.metadata(&self.rel)?)
263    }
264
265    /// The regular file at this path and its size, without following a final
266    /// link; `None` for anything else, including nothing at all. What a write
267    /// that replaces this entry frees.
268    pub fn regular_file(&self) -> Option<(FileIdentity, u64)> {
269        regular_file(self.dir.symlink_metadata(&self.rel).ok()?)
270    }
271
272    /// Bytes held by the regular file at this path; 0 for anything else.
273    pub fn file_len(&self) -> u64 {
274        self.regular_file().map_or(0, |(_, bytes)| bytes)
275    }
276
277    /// Whether this path still names the file `identity` was taken from, the link
278    /// itself never followed. An escape along the way is reported, not answered
279    /// `false`.
280    pub fn refers_to(&self, identity: FileIdentity) -> Result<bool, ContainError> {
281        match self.dir.symlink_metadata(&self.rel) {
282            Ok(metadata) => Ok(regular_file(metadata).is_some_and(|(file, _)| file == identity)),
283            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false),
284            Err(e) => Err(e.into()),
285        }
286    }
287
288    /// Existence, with an escape reported as an [`ContainError::Escape`] for the caller
289    /// to translate. `Dir::exists` swallows the refusal and answers `false`, which would
290    /// conflate "outside the sandbox" with "not there".
291    pub fn try_exists(&self) -> Result<bool, ContainError> {
292        match self.dir.metadata(&self.rel) {
293            Ok(_) => Ok(true),
294            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false),
295            Err(e) => Err(e.into()),
296        }
297    }
298
299    pub fn create_dir(&self) -> Result<(), ContainError> {
300        self.check_mutation(false)?;
301        Ok(self.dir.create_dir(&self.rel)?)
302    }
303
304    /// Whether a directory is already here, links followed. `mkdir -p` of one
305    /// changes nothing, so it succeeds even in a read-only volume.
306    pub fn is_existing_dir(&self) -> bool {
307        self.dir.metadata(&self.rel).is_ok_and(|meta| meta.is_dir())
308    }
309
310    /// Like `mkdir -p`: a directory that already exists is success without any
311    /// change, even in a read-only volume; see [`is_existing_dir`](Self::is_existing_dir).
312    pub fn create_dir_all(&self) -> Result<(), ContainError> {
313        if self.is_existing_dir() {
314            return Ok(());
315        }
316        self.check_mutation(false)?;
317        Ok(self.dir.create_dir_all(&self.rel)?)
318    }
319
320    pub fn open_dir(&self) -> Result<Dir, ContainError> {
321        Ok(self.dir.open_dir(&self.rel)?)
322    }
323
324    pub fn entries(&self) -> Result<ReadDir, ContainError> {
325        Ok(self.dir.read_dir(&self.rel)?)
326    }
327
328    pub fn remove_file(&self) -> Result<(), ContainError> {
329        self.check_mutation(false)?;
330        Ok(self.dir.remove_file(&self.rel)?)
331    }
332
333    /// Rename onto `dest`. Neither side's final component is followed, so this cannot
334    /// be redirected by a link swapped in after resolution.
335    pub fn rename_to(&self, dest: &Self) -> Result<(), ContainError> {
336        self.check_rename_end()?;
337        dest.check_rename_end()?;
338        Ok(self.dir.rename(&self.rel, &dest.dir, &dest.rel)?)
339    }
340
341    /// The checks for one end of a rename. A rename replaces a link rather than
342    /// writing through it, so the mount-point guard looks at the final component
343    /// where it sits; the Git metadata guard keeps following it, as it always has.
344    pub(crate) fn check_rename_end(&self) -> Result<(), ContainError> {
345        self.writable()?;
346        check_mount_points(&self.dir, &self.rel, &self.placement, true, false)?;
347        check_metadata_mutation(&self.dir, &self.rel, true, true)
348    }
349
350    /// Whether this is the root of its volume: the VFS root or a mount point.
351    /// Writes and removes name it as a mistake rather than acting on it — `remove`
352    /// in particular would otherwise drain the root and then fail on the
353    /// self-unlink, destroying a volume and reporting a failure.
354    pub fn is_root(&self) -> bool {
355        self.rel == Path::new(".")
356    }
357
358    /// A collision-safe sibling for an atomic rename-into-place write, resolved
359    /// through the same handle so the commit cannot be redirected.
360    pub fn temp_sibling(&self) -> Self {
361        let mut name = self
362            .rel
363            .file_name()
364            .map_or_else(|| OsString::from("file"), OsStr::to_os_string);
365        name.push(temp_suffix());
366        let mut rel = self.rel.parent().map(Path::to_path_buf).unwrap_or_default();
367        rel.push(name);
368        Self {
369            dir: Arc::clone(&self.dir),
370            rel,
371            placement: self.placement.clone(),
372        }
373    }
374}
375
376/// A file's device and inode, which outlive any one name: how a writer checks that
377/// a path still names the file it created, and how the size limit keys the files
378/// programs hold open.
379#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
380pub struct FileIdentity {
381    dev: u64,
382    ino: u64,
383}
384
385impl FileIdentity {
386    /// Windows knows a file's identity only from metadata read through an open
387    /// handle, as a `Dir` stat or `File::metadata` reads it; other metadata is refused.
388    pub fn of(metadata: &Metadata) -> io::Result<Self> {
389        #[cfg(not(windows))]
390        {
391            Ok(Self {
392                dev: cap_fs_ext::MetadataExt::dev(metadata),
393                ino: cap_fs_ext::MetadataExt::ino(metadata),
394            })
395        }
396        #[cfg(windows)]
397        {
398            use cap_primitives::fs::_WindowsByHandle;
399            match (metadata.volume_serial_number(), metadata.file_index()) {
400                (Some(dev), Some(ino)) => Ok(Self {
401                    dev: dev.into(),
402                    ino,
403                }),
404                _ => Err(io::Error::other(
405                    "a file's identity needs metadata read through an open handle",
406                )),
407            }
408        }
409    }
410}
411
412/// How many names `metadata`'s file has, when the platform tells: Windows only
413/// through metadata read from an open handle.
414pub fn link_count(metadata: &Metadata) -> Option<u64> {
415    #[cfg(not(windows))]
416    {
417        Some(cap_fs_ext::MetadataExt::nlink(metadata))
418    }
419    #[cfg(windows)]
420    {
421        use cap_primitives::fs::_WindowsByHandle;
422        metadata.number_of_links().map(u64::from)
423    }
424}
425
426/// A regular file whose identity can't be read counts as no file: a write that
427/// replaces it then frees nothing, over-counting rather than freeing bytes twice.
428fn regular_file(metadata: Metadata) -> Option<(FileIdentity, u64)> {
429    if !metadata.is_file() {
430        return None;
431    }
432    let identity = FileIdentity::of(&metadata).ok()?;
433    Some((identity, metadata.len()))
434}
435
436fn require_regular_file(metadata: &Metadata) -> Result<(), ContainError> {
437    if metadata.is_file() {
438        return Ok(());
439    }
440    Err(io::Error::new(io::ErrorKind::InvalidInput, "not a regular file").into())
441}
442
443/// A guest path resolved for an operation that acts on the *link itself*.
444///
445/// Carries the *parent* directory handle plus the final component name. Parent
446/// components are traversed under containment; the final one is never followed, so a
447/// stale escaping link inside the root stays inspectable and removable while an
448/// ordinary internal link such as `node_modules/.bin/*` still stats.
449///
450/// The shape is the enforcement: a content operation cannot be expressed through this
451/// value, because the final component is unreachable to a following open.
452#[derive(Clone)]
453pub struct LinkPath {
454    parent: Arc<Dir>,
455    name: OsString,
456    guard: ContentPath,
457}
458
459impl fmt::Debug for LinkPath {
460    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
461        f.debug_struct("LinkPath")
462            .field("name", &self.name)
463            .finish_non_exhaustive()
464    }
465}
466
467/// Windows has two kinds of symlink, and a directory target reached through a file link
468/// does not traverse. Unix has one kind and ignores this.
469#[derive(Clone, Copy, Debug, PartialEq, Eq)]
470pub enum LinkKind {
471    File,
472    Directory,
473}
474
475impl LinkPath {
476    pub(crate) fn child(&self, parent: Arc<Dir>, name: OsString) -> Self {
477        let guard = ContentPath::new(
478            Arc::clone(&self.guard.dir),
479            self.guard.rel.join(&name),
480            self.guard.placement.clone(),
481        );
482        Self {
483            parent,
484            name,
485            guard,
486        }
487    }
488
489    /// Whether this names the root of its volume — the VFS root or a mount
490    /// point — rather than an entry inside it.
491    pub fn is_root(&self) -> bool {
492        self.name == OsStr::new(".")
493    }
494
495    /// The volume this path lives in.
496    pub fn placement(&self) -> &Placement {
497        &self.guard.placement
498    }
499
500    /// See [`ContentPath::writable`].
501    pub fn writable(&self) -> Result<(), ContainError> {
502        self.guard.writable()
503    }
504
505    /// A mount point strictly below this path, as written or where it lands
506    /// through the root's links: copying or moving it as one tree would take a
507    /// hidden placeholder instead of the volume.
508    pub fn contains_mount_point(&self) -> Result<Option<Arc<str>>, ContainError> {
509        let placement = &self.guard.placement;
510        if placement.nested().is_empty() {
511            return Ok(None);
512        }
513        if let Some(mount) = mount_below(&self.guard.rel, placement) {
514            return Ok(Some(mount));
515        }
516        let real = real_location(&self.guard.dir, &self.guard.rel, false, placement)?;
517        Ok(mount_below(&real, placement))
518    }
519
520    /// Compare physical entries, including aliases through other mounts.
521    pub fn same_entry(&self, other: &Self) -> Result<bool, ContainError> {
522        let source = self.symlink_metadata()?;
523        let target = match other.symlink_metadata() {
524            Ok(meta) => meta,
525            Err(ContainError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
526                return Ok(false);
527            }
528            Err(error) => return Err(error),
529        };
530        Ok(FileIdentity::of(&source)? == FileIdentity::of(&target)?)
531    }
532
533    /// Refuse a copy onto itself or into its own tree before creating targets.
534    pub fn check_copy_destination(&self, to: &ContentPath) -> Result<(), ContainError> {
535        let source = self.symlink_metadata()?;
536        let identity = FileIdentity::of(&source)?;
537        let invalid = || {
538            ContainError::Io(io::Error::new(
539                io::ErrorKind::InvalidInput,
540                "cannot copy an entry onto itself or a directory into its own tree",
541            ))
542        };
543        if source.is_dir() && to.placement.ancestors().contains(&identity) {
544            return Err(invalid());
545        }
546        let mut prefix = PathBuf::from(".");
547        let parts = std::iter::once(None).chain(to.rel.components().map(Some));
548        for part in parts {
549            if let Some(part) = part {
550                prefix.push(part.as_os_str());
551            }
552            match to.dir.metadata(&prefix) {
553                Ok(meta) => {
554                    if FileIdentity::of(&meta)? == identity
555                        && (source.is_dir()
556                            || prefix == to.rel
557                            || prefix.strip_prefix(".").is_ok_and(|p| p == to.rel))
558                    {
559                        return Err(invalid());
560                    }
561                }
562                Err(error)
563                    if matches!(
564                        error.kind(),
565                        io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
566                    ) =>
567                {
568                    break;
569                }
570                Err(error) => return Err(error.into()),
571            }
572        }
573        Ok(())
574    }
575
576    /// A collision-safe sibling to stage a move between volumes in, resolved
577    /// through the same parent handle so the final rename cannot be redirected.
578    pub fn temp_sibling(&self) -> Self {
579        let mut name = self.name.clone();
580        name.push(temp_suffix());
581        let rel = self
582            .guard
583            .rel
584            .parent()
585            .map_or_else(|| PathBuf::from(&name), |parent| parent.join(&name));
586        Self {
587            parent: Arc::clone(&self.parent),
588            name,
589            guard: ContentPath::new(
590                Arc::clone(&self.guard.dir),
591                rel,
592                self.guard.placement.clone(),
593            ),
594        }
595    }
596
597    pub fn symlink_metadata(&self) -> Result<Metadata, ContainError> {
598        Ok(self.parent.symlink_metadata(&self.name)?)
599    }
600
601    pub fn read_link_contents(&self) -> Result<PathBuf, ContainError> {
602        Ok(self.parent.read_link_contents(&self.name)?)
603    }
604
605    /// Which flavour of link this is.
606    ///
607    /// Windows records the distinction in the link itself and Unix does not, so on Unix
608    /// the answer is a constant and nothing is stat'd for it.
609    pub fn link_kind(&self) -> Result<LinkKind, ContainError> {
610        #[cfg(not(windows))]
611        {
612            Ok(LinkKind::File)
613        }
614        #[cfg(windows)]
615        {
616            use cap_std::fs::MetadataExt;
617
618            const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x10;
619            let attrs = self.symlink_metadata()?.file_attributes();
620            Ok(if attrs & FILE_ATTRIBUTE_DIRECTORY == 0 {
621                LinkKind::File
622            } else {
623                LinkKind::Directory
624            })
625        }
626    }
627
628    /// Create a symlink here whose target is stored verbatim, in the given flavour.
629    ///
630    /// Verbatim matters: the target is never resolved, so reproducing a link that escapes
631    /// the root yields one that still refuses rather than a working door out of it.
632    ///
633    /// Neither the inherent `Dir::symlink` nor `DirExt::symlink` can be used. The first
634    /// rejects an absolute target outright. The second, on Windows, stats the *target* to
635    /// choose between a file and a directory link — and that stat runs under containment,
636    /// from whichever directory handle the new link is being written through. A target of
637    /// `../shared` is then refused for reaching above that handle even though the link is
638    /// a perfectly ordinary internal one, and a directory link is silently rebuilt as a
639    /// file link, which on Windows does not traverse. The flavour comes from the source
640    /// link instead ([`link_kind`](Self::link_kind)), which is a property of the link and
641    /// needs no access to what it points at.
642    pub fn symlink(&self, target: &Path, kind: LinkKind) -> Result<(), ContainError> {
643        self.guard.check_link_mutation(false)?;
644        #[cfg(not(windows))]
645        {
646            let _ = kind;
647            Ok(self.parent.symlink_contents(target, &self.name)?)
648        }
649        #[cfg(windows)]
650        {
651            match kind {
652                LinkKind::Directory => Ok(DirExt::symlink_dir(&*self.parent, target, &self.name)?),
653                LinkKind::File => Ok(DirExt::symlink_file(&*self.parent, target, &self.name)?),
654            }
655        }
656    }
657
658    pub fn remove_file(&self) -> Result<(), ContainError> {
659        self.guard.check_link_mutation(false)?;
660        // Windows directory symlinks require directory removal; the extension
661        // inspects the link without following it and retains its handle while deleting.
662        Ok(self.parent.remove_file_or_symlink(&self.name)?)
663    }
664
665    pub fn remove_dir(&self) -> Result<(), ContainError> {
666        self.guard.check_link_mutation(true)?;
667        Ok(self.parent.remove_dir(&self.name)?)
668    }
669
670    pub fn remove_dir_all(&self) -> Result<(), ContainError> {
671        self.check_removable()?;
672        Ok(self.parent.remove_dir_all(&self.name)?)
673    }
674
675    /// The checks a removal of this entry with everything below it, or either
676    /// end of a rename, has to pass, run without changing anything; the
677    /// counterpart of [`ContentPath::check_rename_end`] for a link path.
678    pub fn check_removable(&self) -> Result<(), ContainError> {
679        self.guard.check_link_mutation(true)
680    }
681
682    pub fn create_dir_all(&self) -> Result<(), ContainError> {
683        if self
684            .parent
685            .metadata(&self.name)
686            .is_ok_and(|meta| meta.is_dir())
687        {
688            return Ok(());
689        }
690        self.guard.check_link_mutation(false)?;
691        match self.parent.create_dir(&self.name) {
692            Ok(()) => Ok(()),
693            Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Ok(()),
694            Err(e) => Err(e.into()),
695        }
696    }
697
698    /// Open as a directory. Follows the final component, so call only after
699    /// [`symlink_metadata`](Self::symlink_metadata) has established it is not a link.
700    pub fn open_dir(&self) -> Result<Dir, ContainError> {
701        Ok(self.parent.open_dir(&self.name)?)
702    }
703
704    /// The regular file this names and its size, the link itself never followed;
705    /// `None` for anything else, including nothing at all.
706    pub fn regular_file(&self) -> Option<(FileIdentity, u64)> {
707        regular_file(self.symlink_metadata().ok()?)
708    }
709
710    /// The regular file a copy onto this path writes, a final link followed as
711    /// [`copy_to`](Self::copy_to) follows it, and its size.
712    pub fn copied_onto_file(&self) -> Option<(FileIdentity, u64)> {
713        regular_file(self.parent.metadata(&self.name).ok()?)
714    }
715
716    pub fn entries(&self) -> Result<ReadDir, ContainError> {
717        Ok(self.parent.read_dir(&self.name)?)
718    }
719
720    /// Copy file contents onto `dest`. Follows the source's final component, so call
721    /// only after establishing it is not a symlink — dereferencing one here is what
722    /// would turn a copy into an exfiltration.
723    pub fn copy_to(&self, dest: &Self) -> Result<(), ContainError> {
724        self.copy_to_counted(dest).map(|_| ())
725    }
726
727    pub(crate) fn copy_to_counted(&self, dest: &Self) -> Result<u64, ContainError> {
728        dest.guard.check_mutation(true)?;
729        Ok(self.parent.copy(&self.name, &dest.parent, &dest.name)?)
730    }
731
732    /// Rename onto `dest`. Neither final component is followed, so this relocates a
733    /// link rather than its target.
734    pub fn rename_to(&self, dest: &Self) -> Result<(), ContainError> {
735        self.check_removable()?;
736        dest.check_removable()?;
737        Ok(self.parent.rename(&self.name, &dest.parent, &dest.name)?)
738    }
739}
740
741/// Two concurrent executes share a process, so the pid is constant and sub-second
742/// resolution alone is not separation. The counter is what actually keeps two writers to
743/// the same guest path apart; callers pair it with an exclusive create so a collision
744/// fails loudly instead of truncating someone else's in-flight temp.
745fn temp_suffix() -> String {
746    static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
747    let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
748    let pid = std::process::id();
749    let nanos = std::time::SystemTime::now()
750        .duration_since(std::time::UNIX_EPOCH)
751        .map_or(0, |d| d.as_nanos());
752    format!(".{nanos}.{pid}.{seq}.tmp")
753}
754
755/// Resolve a guest path for an operation that reaches its contents.
756///
757/// Purely lexical — no filesystem access happens here, so an escape through a symlink
758/// surfaces when the returned [`ContentPath`] is opened, classified by [`is_escape`].
759pub fn resolve_content(
760    vfs: &Vfs,
761    cwd: &str,
762    guest_path: &str,
763) -> Result<ContentPath, ContainError> {
764    // `Disabled` takes precedence over a malformed path, as it always has.
765    vfs.dir().ok_or(ContainError::Disabled)?;
766    let rel = relative(cwd, guest_path)?;
767    let (dir, rel, placement) = vfs.locate(&rel).ok_or(ContainError::Disabled)?;
768    Ok(ContentPath::new(dir, rel, placement))
769}
770
771/// Resolve a guest path for an operation that acts on the link itself.
772///
773/// Opens the parent directory, so a parent component that escapes is refused here.
774/// The VFS root itself resolves to the root handle with a `.` name: it is not a link,
775/// so nothing is followed, and removing it fails at the OS as it should.
776///
777/// A mount point resolves the same way, to its volume's handle with a `.` name, so it
778/// can be neither removed nor renamed like an entry of its parent.
779pub fn resolve_link(vfs: &Vfs, cwd: &str, guest_path: &str) -> Result<LinkPath, ContainError> {
780    // `Disabled` takes precedence over a malformed path, as it always has.
781    vfs.dir().ok_or(ContainError::Disabled)?;
782    let rel = relative(cwd, guest_path)?;
783    let (root, rel, placement) = vfs.locate(&rel).ok_or(ContainError::Disabled)?;
784    let Some(name) = rel.file_name() else {
785        let name = OsString::from(".");
786        let guard = ContentPath::new(Arc::clone(&root), PathBuf::from(&name), placement);
787        return Ok(LinkPath {
788            parent: root,
789            name,
790            guard,
791        });
792    };
793    let name = name.to_os_string();
794    let parent_rel = rel.parent().unwrap_or(Path::new(""));
795    let parent = if parent_rel.as_os_str().is_empty() {
796        Arc::clone(&root)
797    } else {
798        Arc::new(root.open_dir(parent_rel)?)
799    };
800    Ok(LinkPath {
801        parent,
802        name,
803        guard: ContentPath::new(root, rel, placement),
804    })
805}
806
807/// The guest-visible absolute form of a path, with `.` and `..` collapsed.
808///
809/// Guest space, not host space — this is what a diagnostic or a directory entry shows
810/// the program, and it is the only path-shaped string that leaves this module.
811pub fn guest_normalize(cwd: &str, guest_path: &str) -> Result<String, ResolveError> {
812    let rel = relative(cwd, guest_path)?;
813    if rel == Path::new(".") {
814        return Ok(String::from("/"));
815    }
816    // Guest paths use forward slashes even when the host uses another separator.
817    let components = rel
818        .components()
819        .map(|component| component.as_os_str().to_string_lossy())
820        .collect::<Vec<_>>();
821    Ok(format!("/{}", components.join("/")))
822}
823
824/// The lexical half of resolution: a root-relative path with `.` and `..` collapsed.
825///
826/// The bare root maps to `"."` rather than `""`, which `cap-std` rejects with `ENOENT`.
827fn relative(cwd: &str, guest_path: &str) -> Result<PathBuf, ResolveError> {
828    if guest_path.as_bytes().contains(&0) || cwd.as_bytes().contains(&0) {
829        return Err(ResolveError::EmbeddedNul);
830    }
831    let path = Path::new(guest_path);
832    let is_absolute = matches!(path.components().next(), Some(Component::RootDir));
833
834    let mut stack: Vec<OsString> = Vec::new();
835    if !is_absolute {
836        for comp in Path::new(cwd).components() {
837            push_component(&mut stack, comp)?;
838        }
839    }
840    for comp in path.components() {
841        push_component(&mut stack, comp)?;
842    }
843
844    if stack.is_empty() {
845        return Ok(PathBuf::from("."));
846    }
847    let mut out = PathBuf::new();
848    for name in &stack {
849        out.push(name);
850    }
851    Ok(out)
852}
853
854pub(crate) fn protected_metadata(path: &Path) -> bool {
855    path.components().any(|part| {
856        let name = part.as_os_str().to_string_lossy();
857        let options = gix::validate::path::component::Options {
858            protect_windows: false,
859            ..Default::default()
860        };
861        let reserved_alias = matches!(
862            gix::validate::path::component(name.as_bytes().into(), None, options),
863            Err(gix::validate::path::component::Error::DotGitDir)
864        );
865        reserved_alias
866            || name
867                .trim_end_matches([' ', '.'])
868                .eq_ignore_ascii_case(".git")
869            || name.to_ascii_lowercase().starts_with(".git-submilli-")
870    })
871}
872
873/// Refuse a repository at the root-relative `path` that would overlap a mount:
874/// one the path is inside of (an alias of a mount point leads into its hidden
875/// placeholder), or one at or below it, judged by spelling and by where the path
876/// lands. Git publishes by renaming every entry of the repository directory, which
877/// would carry a mount point's placeholder away.
878pub(crate) fn check_repository_clear_of_mounts(
879    root: &Dir,
880    path: &Path,
881    placement: &Placement,
882) -> Result<(), ContainError> {
883    check_nested_mount_points(root, path, placement, true, true)?;
884    match root.metadata(path) {
885        Ok(meta) if meta.is_dir() => {
886            if placement.protects_descendants(dir_identity(root, path, &meta)?) {
887                return Err(ContainError::MountPoint(Arc::from(placement.mount_point())));
888            }
889        }
890        Ok(_) => {}
891        Err(error) if error.kind() == io::ErrorKind::NotFound => {}
892        Err(error) => return Err(error.into()),
893    }
894    Ok(())
895}
896
897/// Refuse a change to the root volume that would reach a mount point.
898///
899/// A recursive change (remove, rename, copy onto) to a mount point or one of its
900/// ancestors would take the mount point with it, and any change inside a mount
901/// point's directory in the root would land in the hidden placeholder rather
902/// than the volume. Both are checked on the path as written and on where it
903/// lands once the root's links are followed, so a link to an ancestor cannot
904/// spell its way past the guard.
905fn check_mount_points(
906    root: &Dir,
907    path: &Path,
908    placement: &Placement,
909    recursive: bool,
910    follow_final: bool,
911) -> Result<(), ContainError> {
912    check_nested_mount_points(root, path, placement, recursive, follow_final)?;
913    let metadata = if follow_final {
914        root.metadata(path)
915    } else {
916        root.symlink_metadata(path)
917    };
918    match metadata {
919        Ok(meta) if meta.is_dir() => {
920            if placement.protects(dir_identity(root, path, &meta)?, recursive) {
921                return Err(ContainError::MountPoint(Arc::from(placement.mount_point())));
922            }
923        }
924        Ok(_) => {}
925        Err(error)
926            if matches!(
927                error.kind(),
928                io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
929            ) => {}
930        Err(error) => return Err(error.into()),
931    }
932    Ok(())
933}
934
935fn check_nested_mount_points(
936    root: &Dir,
937    path: &Path,
938    placement: &Placement,
939    recursive: bool,
940    follow_final: bool,
941) -> Result<(), ContainError> {
942    if placement.nested().is_empty() {
943        return Ok(());
944    }
945    if let Some(mount) = mount_at_or_below(path, placement, recursive) {
946        return Err(ContainError::MountPoint(mount));
947    }
948    let real = real_location(root, path, follow_final, placement)?;
949    if let Some(mount) = mount_at_or_below(&real, placement, recursive) {
950        return Err(ContainError::MountPoint(mount));
951    }
952    Ok(())
953}
954
955/// Where `path` lands in the root once links are followed, resolved one
956/// component at a time. A link's target is substituted lexically, so a dangling
957/// link resolves to where a write through it would create the file; the walk
958/// stops following links below the first component that does not exist. When
959/// `follow_final` is false a link as the last component stays as written, since
960/// a link operation acts on the link where it sits. An absolute or upward-escaping
961/// target is reported as an escape, which is what opening it would report.
962///
963/// A directory on the way to a mount point is named as the mount spells it,
964/// whatever spelling reached it: a case-insensitive filesystem opens it under
965/// others too, some not ASCII, and only its identity says which directory it is.
966fn real_location(
967    root: &Dir,
968    path: &Path,
969    follow_final: bool,
970    placement: &Placement,
971) -> Result<PathBuf, ContainError> {
972    let mut pending: Vec<OsString> = Vec::new();
973    push_reversed(&mut pending, path);
974    let mut resolved = PathBuf::new();
975    let mut missing = false;
976    let mut hops = 0usize;
977    while let Some(name) = pending.pop() {
978        if name == OsStr::new(".") {
979            continue;
980        }
981        if name == OsStr::new("..") {
982            if !resolved.pop() {
983                return Err(ContainError::Escape);
984            }
985            continue;
986        }
987        let candidate = resolved.join(&name);
988        if missing {
989            resolved = candidate;
990            continue;
991        }
992        let follow_this = follow_final || !pending.is_empty();
993        match root.symlink_metadata(&candidate) {
994            Ok(meta) if meta.file_type().is_symlink() && follow_this => {
995                hops += 1;
996                if hops > MAX_LINK_HOPS {
997                    return Err(ContainError::Io(io::Error::other(
998                        "too many levels of symbolic links",
999                    )));
1000                }
1001                let target = root.read_link_contents(&candidate)?;
1002                if target.has_root() {
1003                    return Err(ContainError::Escape);
1004                }
1005                push_reversed(&mut pending, &target);
1006            }
1007            Ok(meta) => match placeholder_name(placement, root, &candidate, &resolved, &meta) {
1008                Ok(Some(exact)) => resolved.push(exact),
1009                Ok(None) => resolved = candidate,
1010                // Removed since it was stat'ed: as missing as if never found.
1011                Err(error) if error.kind() == io::ErrorKind::NotFound => {
1012                    missing = true;
1013                    resolved = candidate;
1014                }
1015                Err(error) => return Err(error.into()),
1016            },
1017            Err(error) if error.kind() == io::ErrorKind::NotFound => {
1018                missing = true;
1019                resolved = candidate;
1020            }
1021            Err(error) => return Err(error.into()),
1022        }
1023    }
1024    Ok(resolved)
1025}
1026
1027/// The exact name of the mount-point directory (or one on the way to it) that
1028/// `meta` describes, found in `parent`, if it is one. The match needs the parent
1029/// as well as the identity, so a filesystem whose inode numbers are not unique
1030/// can at worst misname an entry beside a mount point, never one elsewhere. The
1031/// identity is read only for a directory beside a placeholder, and one that can't
1032/// be read is an error rather than "not a placeholder", as it is when the mount is
1033/// prepared.
1034fn placeholder_name<'a>(
1035    placement: &'a Placement,
1036    root: &Dir,
1037    path: &Path,
1038    parent: &Path,
1039    meta: &Metadata,
1040) -> io::Result<Option<&'a OsStr>> {
1041    if !meta.is_dir() {
1042        return Ok(None);
1043    }
1044    let mut beside = placement
1045        .nested()
1046        .iter()
1047        .flat_map(Mount::placeholders)
1048        .filter(|placeholder| placeholder.parent == parent)
1049        .peekable();
1050    if beside.peek().is_none() {
1051        return Ok(None);
1052    }
1053    let identity = dir_identity(root, path, meta)?;
1054    Ok(beside
1055        .find(|placeholder| placeholder.identity == identity)
1056        .map(|placeholder| placeholder.name.as_os_str()))
1057}
1058
1059/// The identity of the directory at `path`, whose metadata is `meta`. Windows
1060/// reports one only for metadata read through an open handle, so that is the
1061/// fallback.
1062pub(crate) fn dir_identity(root: &Dir, path: &Path, meta: &Metadata) -> io::Result<FileIdentity> {
1063    match FileIdentity::of(meta) {
1064        Ok(identity) => Ok(identity),
1065        Err(_) => FileIdentity::of(&root.open_dir(path)?.dir_metadata()?),
1066    }
1067}
1068
1069/// Queue `path`'s components so that popping yields them in order.
1070fn push_reversed(pending: &mut Vec<OsString>, path: &Path) {
1071    pending.extend(
1072        path.components()
1073            .rev()
1074            .map(|part| part.as_os_str().to_os_string()),
1075    );
1076}
1077
1078/// How many links [`real_location`] follows before giving up, as the kernel does.
1079const MAX_LINK_HOPS: usize = 40;
1080
1081/// The mount `path` is inside of, or — when `recursive` — one at or below it.
1082fn mount_at_or_below(path: &Path, placement: &Placement, recursive: bool) -> Option<Arc<str>> {
1083    placement.nested().iter().find_map(|mount| {
1084        let inside = starts_with_component(path, mount.rel());
1085        let above = recursive && starts_with_component(mount.rel(), path);
1086        (inside || above).then(|| Arc::from(mount.guest_path()))
1087    })
1088}
1089
1090/// A mount strictly below `path`.
1091fn mount_below(path: &Path, placement: &Placement) -> Option<Arc<str>> {
1092    placement.nested().iter().find_map(|mount| {
1093        let below = starts_with_component(mount.rel(), path) && mount.rel() != path;
1094        below.then(|| Arc::from(mount.guest_path()))
1095    })
1096}
1097
1098/// Whether `path` is `prefix` or below it, comparing whole components. The root
1099/// `.` is above everything. Case is folded on hosts whose filesystems usually
1100/// fold it, so `/Memory/x` cannot reach the placeholder behind `/memory`.
1101fn starts_with_component(path: &Path, prefix: &Path) -> bool {
1102    if prefix == Path::new(".") {
1103        return true;
1104    }
1105    let mut parts = path.components().filter(|c| *c != Component::CurDir);
1106    for want in prefix.components().filter(|c| *c != Component::CurDir) {
1107        match parts.next() {
1108            Some(have) if same_component(have.as_os_str(), want.as_os_str()) => {}
1109            _ => return false,
1110        }
1111    }
1112    true
1113}
1114
1115fn same_component(a: &OsStr, b: &OsStr) -> bool {
1116    if cfg!(any(target_os = "macos", windows)) {
1117        a.to_string_lossy()
1118            .eq_ignore_ascii_case(&b.to_string_lossy())
1119    } else {
1120        a == b
1121    }
1122}
1123
1124fn metadata_denied() -> ContainError {
1125    ContainError::Io(io::Error::other(
1126        "Git metadata is protected; use submilli:git with Git capabilities",
1127    ))
1128}
1129
1130fn check_metadata_mutation(
1131    root: &Dir,
1132    path: &Path,
1133    recursive: bool,
1134    follow_final: bool,
1135) -> Result<(), ContainError> {
1136    if protected_metadata(path) {
1137        return Err(metadata_denied());
1138    }
1139    match check_metadata_prefixes(root, path, follow_final, &mut |_| {
1140        Ok::<_, std::convert::Infallible>(())
1141    }) {
1142        Ok(()) => {}
1143        Err(PrefixError::Filesystem(error)) => return Err(error),
1144        Err(PrefixError::Work(never)) => match never {},
1145    }
1146    match root.symlink_metadata(path) {
1147        Ok(meta) => {
1148            #[cfg(unix)]
1149            {
1150                use cap_std::fs::MetadataExt;
1151                if meta.is_file() && meta.nlink() > 1 {
1152                    return Err(metadata_denied());
1153                }
1154            }
1155            if recursive && meta.is_dir() {
1156                reject_metadata_descendants(&root.open_dir(path)?, &mut 0, 0)?;
1157            }
1158        }
1159        Err(error) if error.kind() == io::ErrorKind::NotFound => {}
1160        Err(error) => return Err(error.into()),
1161    }
1162    Ok(())
1163}
1164
1165#[derive(Debug)]
1166enum PrefixError<E> {
1167    Filesystem(ContainError),
1168    Work(E),
1169}
1170
1171impl<E> From<io::Error> for PrefixError<E> {
1172    fn from(error: io::Error) -> Self {
1173        Self::Filesystem(error.into())
1174    }
1175}
1176
1177fn check_metadata_prefixes<E>(
1178    root: &Dir,
1179    path: &Path,
1180    follow_final: bool,
1181    work: &mut impl FnMut(u64) -> Result<(), E>,
1182) -> Result<(), PrefixError<E>> {
1183    if protected_metadata(path) {
1184        return Err(PrefixError::Filesystem(metadata_denied()));
1185    }
1186    work(1).map_err(PrefixError::Work)?;
1187    let mut directory = root.try_clone()?;
1188    let mut prefix = PathBuf::new();
1189    let mut parts = path
1190        .components()
1191        .filter(|part| *part != Component::CurDir)
1192        .peekable();
1193    let mut hops = 0;
1194    while let Some(part) = parts.next() {
1195        prefix.push(part);
1196        let more = parts.peek().is_some();
1197        if !more && !follow_final {
1198            break;
1199        }
1200        work(1).map_err(PrefixError::Work)?;
1201        let metadata = match directory.symlink_metadata(part.as_os_str()) {
1202            Ok(metadata) => metadata,
1203            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
1204            Err(error) => return Err(error.into()),
1205        };
1206        if metadata.file_type().is_symlink() {
1207            hops += 1;
1208            if hops > MAX_LINK_HOPS {
1209                return Err(io::Error::other("too many levels of symbolic links").into());
1210            }
1211            // Canonicalize only aliases. Ordinary prefixes use one-component
1212            // directory handles, so they do not reopen every ancestor.
1213            work((prefix.components().count() as u64).saturating_mul(2))
1214                .map_err(PrefixError::Work)?;
1215            let resolved = match root.canonicalize(&prefix) {
1216                Ok(resolved) => resolved,
1217                Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
1218                Err(error) => return Err(error.into()),
1219            };
1220            if protected_metadata(&resolved) {
1221                return Err(PrefixError::Filesystem(metadata_denied()));
1222            }
1223            if more {
1224                work(resolved.components().count() as u64 + 1).map_err(PrefixError::Work)?;
1225                directory = root.open_dir(&resolved)?;
1226            }
1227        } else if more {
1228            work(1).map_err(PrefixError::Work)?;
1229            directory = directory.open_dir_nofollow(part.as_os_str())?;
1230        }
1231    }
1232    Ok(())
1233}
1234
1235/// How many entries a recursive removal scans before it refuses.
1236pub const MAX_REMOVE_ENTRIES: usize = 10_000;
1237
1238fn reject_metadata_descendants(
1239    dir: &Dir,
1240    count: &mut usize,
1241    depth: usize,
1242) -> Result<(), ContainError> {
1243    if depth > 64 {
1244        return Err(ContainError::Io(io::Error::other(
1245            "directory nesting limit exceeded",
1246        )));
1247    }
1248    for entry in dir.entries()? {
1249        *count += 1;
1250        if *count > MAX_REMOVE_ENTRIES {
1251            return Err(ContainError::Io(io::Error::other(
1252                "directory scan limit exceeded",
1253            )));
1254        }
1255        let entry = entry?;
1256        let name = entry.file_name();
1257        if protected_metadata(Path::new(&name)) {
1258            return Err(metadata_denied());
1259        }
1260        if dir.symlink_metadata(&name)?.is_dir() {
1261            reject_metadata_descendants(&dir.open_dir(name)?, count, depth + 1)?;
1262        }
1263    }
1264    Ok(())
1265}
1266
1267#[cfg(test)]
1268mod tests {
1269    use super::*;
1270
1271    const ROOT: &str = "/";
1272
1273    #[test]
1274    fn metadata_ancestors_use_one_component_handles() {
1275        let mut measured = Vec::new();
1276        for depth in [64, 128] {
1277            let temporary = tempfile::tempdir().unwrap();
1278            let relative = std::iter::repeat_n("d", depth)
1279                .collect::<Vec<_>>()
1280                .join("/");
1281            std::fs::create_dir_all(temporary.path().join(&relative)).unwrap();
1282            let root =
1283                Dir::open_ambient_dir(temporary.path(), cap_std::ambient_authority()).unwrap();
1284            let path = Path::new(&relative);
1285            let before = std::time::Instant::now();
1286            let mut previous_work = 0;
1287            for prefix in path
1288                .ancestors()
1289                .filter(|prefix| !prefix.as_os_str().is_empty())
1290            {
1291                root.canonicalize(prefix).unwrap();
1292                previous_work += prefix.components().count();
1293            }
1294            let old_time = before.elapsed();
1295            let before = std::time::Instant::now();
1296            let mut work = 0;
1297            check_metadata_prefixes(&root, path, true, &mut |units| {
1298                work += units;
1299                Ok::<_, std::convert::Infallible>(())
1300            })
1301            .unwrap();
1302            eprintln!(
1303                "metadata depth {depth}: old {previous_work} components/{old_time:?}, new {work} calls/{:?}",
1304                before.elapsed()
1305            );
1306            assert_eq!(previous_work, depth * (depth + 1) / 2);
1307            assert_eq!(work, 2 * depth as u64);
1308            assert!(work < previous_work as u64 / 2);
1309            measured.push(work);
1310        }
1311        assert_eq!(measured[1], measured[0] * 2);
1312    }
1313
1314    #[cfg(unix)]
1315    #[test]
1316    fn metadata_prefixes_protect_aliases_but_allow_unlinking_final_alias() {
1317        let temporary = tempfile::tempdir().unwrap();
1318        std::fs::create_dir_all(temporary.path().join("repo/.git")).unwrap();
1319        std::fs::create_dir(temporary.path().join("ordinary")).unwrap();
1320        std::os::unix::fs::symlink("repo/.git", temporary.path().join("metadata")).unwrap();
1321        std::os::unix::fs::symlink("ordinary", temporary.path().join("alias")).unwrap();
1322        let root = Dir::open_ambient_dir(temporary.path(), cap_std::ambient_authority()).unwrap();
1323        let mut work = |_| Ok::<_, std::convert::Infallible>(());
1324        let error = check_metadata_prefixes(&root, Path::new("metadata/config"), false, &mut work)
1325            .unwrap_err();
1326        assert!(matches!(error, PrefixError::Filesystem(_)));
1327        check_metadata_prefixes(&root, Path::new("metadata"), false, &mut work).unwrap();
1328        check_metadata_prefixes(&root, Path::new("alias/new"), false, &mut work).unwrap();
1329    }
1330
1331    fn rel(cwd: &str, path: &str) -> PathBuf {
1332        relative(cwd, path).expect("must resolve")
1333    }
1334
1335    #[test]
1336    fn absolute_path_resolves_under_root() {
1337        assert_eq!(rel(ROOT, "/foo/bar"), Path::new("foo/bar"));
1338    }
1339
1340    #[test]
1341    fn relative_path_resolves_under_cwd_root() {
1342        assert_eq!(rel(ROOT, "foo/bar"), Path::new("foo/bar"));
1343    }
1344
1345    #[test]
1346    fn bare_root_resolves_to_root() {
1347        assert_eq!(rel(ROOT, "/"), Path::new("."));
1348    }
1349
1350    #[test]
1351    fn empty_string_resolves_to_cwd() {
1352        assert_eq!(rel(ROOT, ""), Path::new("."));
1353        assert_eq!(rel("/home", ""), Path::new("home"));
1354    }
1355
1356    #[test]
1357    fn dot_segments_collapse() {
1358        assert_eq!(rel(ROOT, "/foo/./bar"), Path::new("foo/bar"));
1359        assert_eq!(rel(ROOT, "./foo"), Path::new("foo"));
1360    }
1361
1362    #[test]
1363    fn parent_within_root_is_ok() {
1364        assert_eq!(rel(ROOT, "/foo/../bar"), Path::new("bar"));
1365        assert_eq!(rel(ROOT, "foo/bar/../baz"), Path::new("foo/baz"));
1366        assert_eq!(rel(ROOT, "/foo/.."), Path::new("."));
1367    }
1368
1369    #[test]
1370    fn parent_above_root_is_escape() {
1371        assert_eq!(relative(ROOT, "/../etc/passwd"), Err(ResolveError::Escape));
1372        assert_eq!(relative(ROOT, ".."), Err(ResolveError::Escape));
1373        assert_eq!(relative(ROOT, "../foo"), Err(ResolveError::Escape));
1374        assert_eq!(relative(ROOT, "foo/../../bar"), Err(ResolveError::Escape));
1375    }
1376
1377    #[test]
1378    fn double_slashes_collapse() {
1379        assert_eq!(rel(ROOT, "//foo//bar"), Path::new("foo/bar"));
1380    }
1381
1382    #[test]
1383    fn nul_byte_rejected() {
1384        assert_eq!(relative(ROOT, "foo\0bar"), Err(ResolveError::EmbeddedNul));
1385        assert_eq!(relative("/ho\0me", "foo"), Err(ResolveError::EmbeddedNul));
1386    }
1387
1388    #[test]
1389    fn cwd_anchors_relative_paths() {
1390        assert_eq!(rel("/home", "foo"), Path::new("home/foo"));
1391        assert_eq!(
1392            rel("/home/user", "docs/a.txt"),
1393            Path::new("home/user/docs/a.txt"),
1394        );
1395    }
1396
1397    #[test]
1398    fn absolute_path_ignores_cwd() {
1399        assert_eq!(rel("/home", "/etc/passwd"), Path::new("etc/passwd"));
1400    }
1401
1402    #[test]
1403    fn parent_cancels_cwd_levels() {
1404        assert_eq!(rel("/home", ".."), Path::new("."));
1405        assert_eq!(rel("/home/user", "../other"), Path::new("home/other"));
1406    }
1407
1408    #[test]
1409    fn parent_escaping_through_cwd_is_escape() {
1410        assert_eq!(relative("/home", "../.."), Err(ResolveError::Escape));
1411        assert_eq!(
1412            relative("/home", "../../etc/passwd"),
1413            Err(ResolveError::Escape),
1414        );
1415    }
1416
1417    #[test]
1418    fn invalid_cwd_surfaces_as_escape() {
1419        assert_eq!(relative("..", "foo"), Err(ResolveError::Escape));
1420    }
1421
1422    #[test]
1423    fn cwd_without_leading_slash_treated_as_anchored() {
1424        assert_eq!(rel("home", "foo"), rel("/home", "foo"));
1425    }
1426
1427    #[test]
1428    fn guest_normalize_stays_in_guest_space() {
1429        assert_eq!(guest_normalize(ROOT, "/").unwrap(), "/");
1430        assert_eq!(guest_normalize(ROOT, "/tree/").unwrap(), "/tree");
1431        assert_eq!(guest_normalize(ROOT, "tree/sub/..").unwrap(), "/tree");
1432        assert_eq!(
1433            guest_normalize(ROOT, "tree/sub/file").unwrap(),
1434            "/tree/sub/file"
1435        );
1436        assert_eq!(
1437            guest_normalize(ROOT, "tree/./sub/../file").unwrap(),
1438            "/tree/file"
1439        );
1440    }
1441}
1442
1443#[cfg(test)]
1444mod contained_tests {
1445    use super::*;
1446
1447    const ROOT: &str = "/";
1448
1449    fn seeded() -> (tempfile::TempDir, Vfs) {
1450        let td = tempfile::tempdir().expect("tempdir");
1451        std::fs::create_dir_all(td.path().join("dir/sub")).expect("mkdir");
1452        std::fs::write(td.path().join("dir/a.txt"), b"inside").expect("write");
1453        let vfs = Vfs::external(td.path().to_path_buf()).expect("vfs");
1454        (td, vfs)
1455    }
1456
1457    #[test]
1458    fn bare_root_is_dot_not_empty() {
1459        assert_eq!(relative(ROOT, "/").unwrap(), Path::new("."));
1460        assert_eq!(relative(ROOT, "").unwrap(), Path::new("."));
1461        assert_eq!(relative(ROOT, "/foo/..").unwrap(), Path::new("."));
1462    }
1463
1464    #[test]
1465    fn resolved_paths_are_root_relative() {
1466        let rel = relative(ROOT, "/foo/bar").unwrap();
1467        assert_eq!(rel, Path::new("foo/bar"));
1468        assert!(!rel.is_absolute(), "must not carry a leading separator");
1469    }
1470
1471    #[test]
1472    fn trailing_slash_is_normalized_away() {
1473        assert_eq!(
1474            relative(ROOT, "/foo/").unwrap(),
1475            relative(ROOT, "/foo").unwrap()
1476        );
1477        assert_eq!(relative(ROOT, "foo//bar//").unwrap(), Path::new("foo/bar"));
1478    }
1479
1480    #[test]
1481    fn lexical_escape_keeps_its_existing_error() {
1482        assert_eq!(relative(ROOT, "../outside"), Err(ResolveError::Escape));
1483        assert_eq!(
1484            ResolveError::Escape.to_string(),
1485            "path escapes the VFS root",
1486        );
1487        assert_eq!(relative(ROOT, "a\0b"), Err(ResolveError::EmbeddedNul));
1488    }
1489
1490    #[test]
1491    fn content_form_reaches_the_full_path() {
1492        let (_td, vfs) = seeded();
1493        let p = resolve_content(&vfs, ROOT, "/dir/a.txt").expect("resolve");
1494        assert_eq!(p.read().expect("read"), b"inside");
1495    }
1496
1497    #[test]
1498    fn link_form_yields_the_parent_and_the_bare_final_component() {
1499        let (_td, vfs) = seeded();
1500        let p = resolve_link(&vfs, ROOT, "/dir/a.txt").expect("resolve");
1501        assert_eq!(p.name, std::ffi::OsStr::new("a.txt"));
1502        // The handle is the *parent*, so the file is one component away from it.
1503        assert!(p.parent.metadata("a.txt").is_ok());
1504    }
1505
1506    #[test]
1507    fn link_form_on_the_root_names_dot() {
1508        let (_td, vfs) = seeded();
1509        let p = resolve_link(&vfs, ROOT, "/").expect("resolve");
1510        assert_eq!(p.name, std::ffi::OsStr::new("."));
1511        assert!(p.symlink_metadata().expect("stat root").is_dir());
1512    }
1513
1514    #[test]
1515    fn disabled_vfs_has_no_handle_to_resolve_against() {
1516        let vfs = Vfs::none();
1517        assert!(matches!(
1518            resolve_content(&vfs, ROOT, "/a"),
1519            Err(ContainError::Disabled)
1520        ));
1521        assert!(matches!(
1522            resolve_link(&vfs, ROOT, "/a"),
1523            Err(ContainError::Disabled)
1524        ));
1525    }
1526
1527    #[test]
1528    fn escape_classifier_separates_refusal_from_eacces() {
1529        let refusal = io::Error::new(io::ErrorKind::PermissionDenied, "escape");
1530        assert!(
1531            is_escape(&refusal),
1532            "constructed refusal carries no OS error"
1533        );
1534
1535        let eacces = io::Error::from_raw_os_error(13);
1536        assert_eq!(eacces.kind(), io::ErrorKind::PermissionDenied);
1537        assert!(
1538            !is_escape(&eacces),
1539            "a real EACCES must not read as an escape"
1540        );
1541
1542        assert!(!is_escape(&io::Error::from(io::ErrorKind::NotFound)));
1543    }
1544
1545    #[test]
1546    #[cfg(unix)]
1547    fn escape_classifier_matches_a_real_cap_std_refusal() {
1548        let (td, vfs) = seeded();
1549        let outside = td.path().parent().expect("parent");
1550        std::os::unix::fs::symlink(outside, td.path().join("escape")).expect("symlink");
1551
1552        let err = resolve_content(&vfs, ROOT, "/escape/whatever")
1553            .expect("lexically fine")
1554            .metadata()
1555            .expect_err("must refuse");
1556        assert!(
1557            matches!(err, ContainError::Escape),
1558            "a real cap-std escape must classify as Escape, got {err:?}",
1559        );
1560        assert_eq!(err.to_string(), "path escapes the VFS root");
1561    }
1562
1563    #[test]
1564    fn temp_sibling_stays_beside_its_target() {
1565        let (_td, vfs) = seeded();
1566        let p = resolve_content(&vfs, ROOT, "/dir/a.txt").expect("resolve");
1567        let tmp = p.temp_sibling();
1568        assert_eq!(tmp.rel.parent(), Some(Path::new("dir")));
1569        assert!(
1570            tmp.rel
1571                .file_name()
1572                .unwrap()
1573                .to_string_lossy()
1574                .starts_with("a.txt."),
1575            "temp name should derive from the target: {:?}",
1576            tmp.rel,
1577        );
1578        tmp.create().expect("create temp through the handle");
1579        assert!(_td.path().join(&tmp.rel).exists());
1580    }
1581}