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