Skip to main content

exarch_core/security/
fs_view.rs

1//! Filesystem view used by path, symlink and hardlink validation.
2//!
3//! Extraction validates against the real destination ([`HostFs`]); `verify`
4//! validates against an in-memory model of what extraction would create
5//! ([`VirtualTree`]) so that an untrusted archive never causes a write.
6//!
7//! # Limitations
8//!
9//! [`VirtualTree`] models only the archive's own entries. It cannot account
10//! for files, directories or links already present in the extraction
11//! destination, so a verification PASS does not guarantee that extraction into
12//! a non-empty destination succeeds. Further differences from the host
13//! filesystem:
14//!
15//! - symlink chains are cut off after [`MAX_SYMLINK_HOPS`] hops, a per-OS
16//!   constant that matches `std::fs::canonicalize` on the supported hosts
17//!   (macOS 33, glibc 40, musl 39, Windows 63 unverified, other Unix 32); a
18//!   different filesystem or kernel configuration can still disagree;
19//! - name lengths use per-OS limits ([`MAX_COMPONENT_LEN`], [`MAX_PATH_LEN`]:
20//!   Linux 4095, macOS and other Unix 1023, Windows 32767 UTF-16 units); a
21//!   symlink target is checked against [`MAX_SYMLINK_TARGET_LEN`] only (Windows
22//!   4091 UTF-16 units). A filesystem with tighter limits can still refuse a
23//!   name the model accepts;
24//! - on macOS names are compared after NFD and a per-character
25//!   upper/lower/upper case fold, on Windows after the same fold without
26//!   normalisation, and elsewhere exactly. The fold is an approximation of the
27//!   host's table: two names it merges but the host keeps apart (for example
28//!   `i` and dotless `ı`) are reported as a name collision, never silently
29//!   merged;
30//! - only extraction that skips an existing entry is modelled; with overwrite
31//!   semantics extraction keeps the last entry at a path, which cannot be
32//!   modelled here;
33//! - the first entry at a path wins, mirroring extraction that skips an
34//!   existing file; the one exception is a symlink over an earlier file, which
35//!   replaces it so that verification stays conservative for extraction that
36//!   overwrites existing files.
37
38use std::borrow::Cow;
39use std::collections::HashMap;
40use std::collections::VecDeque;
41use std::ffi::OsStr;
42use std::ffi::OsString;
43use std::io;
44use std::path::Component;
45use std::path::Path;
46use std::path::PathBuf;
47
48use crate::closed_enum::closed_enum;
49
50/// Maximum number of symlinks followed while resolving a single path, as the
51/// host's `realpath` allows it.
52#[cfg(target_os = "macos")]
53pub const MAX_SYMLINK_HOPS: usize = 33;
54
55/// Maximum number of symlinks followed while resolving a single path, as the
56/// host's `realpath` allows it.
57#[cfg(all(target_os = "linux", target_env = "musl"))]
58pub const MAX_SYMLINK_HOPS: usize = 39;
59
60/// Maximum number of symlinks followed while resolving a single path, as the
61/// host's `realpath` allows it.
62#[cfg(all(target_os = "linux", not(target_env = "musl")))]
63pub const MAX_SYMLINK_HOPS: usize = 40;
64
65/// Maximum number of symlinks followed while resolving a single path, as the
66/// host's `realpath` allows it.
67#[cfg(target_os = "windows")]
68pub const MAX_SYMLINK_HOPS: usize = 63;
69
70/// Maximum number of symlinks followed while resolving a single path. A low
71/// guess can only turn a PASS into a false FAIL.
72#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
73pub const MAX_SYMLINK_HOPS: usize = 32;
74
75/// Longest single path component the host accepts (`NAME_MAX`), in bytes, or
76/// in UTF-16 units on Windows.
77pub const MAX_COMPONENT_LEN: usize = 255;
78
79/// Longest relative path the host accepts (`PATH_MAX` less the terminator), in
80/// bytes.
81#[cfg(target_os = "linux")]
82pub const MAX_PATH_LEN: usize = 4095;
83
84/// Longest relative path the host accepts, in UTF-16 units.
85///
86/// 32767 is the documented limit of an extended-length path. A symlink target
87/// is bounded much lower, see [`MAX_SYMLINK_TARGET_LEN`].
88#[cfg(target_os = "windows")]
89pub const MAX_PATH_LEN: usize = 32767;
90
91/// Longest relative path the host accepts (`PATH_MAX` less the terminator), in
92/// bytes. A low guess can only turn a PASS into a false FAIL.
93#[cfg(not(any(target_os = "linux", target_os = "windows")))]
94pub const MAX_PATH_LEN: usize = 1023;
95
96/// Longest symlink target the host accepts, in UTF-16 units.
97///
98/// `CreateSymbolicLinkW` stores the substitute and print names in a 16 KiB
99/// reparse buffer after a 20-byte header. Extraction only creates relative
100/// targets (a `Prefix` or `RootDir` component is rejected), where both names
101/// are equal, so `2 * 2n <= 16384 - 20` gives `n <= 4091`. Absolute targets
102/// (`\??\` prefix) would allow fewer units but are never created.
103#[cfg(target_os = "windows")]
104pub const MAX_SYMLINK_TARGET_LEN: usize = 4091;
105
106/// Longest symlink target the host accepts (`PATH_MAX` less the terminator),
107/// in bytes.
108#[cfg(not(target_os = "windows"))]
109pub const MAX_SYMLINK_TARGET_LEN: usize = MAX_PATH_LEN;
110
111/// Longest symlink target in bytes that can pass the
112/// [`MAX_SYMLINK_TARGET_LEN`] check; the read and allocation cap for targets
113/// stored as bytes.
114///
115/// On Windows a UTF-16 unit takes at most 3 UTF-8 bytes, so a byte cap never
116/// rejects a target the unit check accepts.
117#[cfg(target_os = "windows")]
118pub const MAX_SYMLINK_TARGET_BYTES: usize = MAX_SYMLINK_TARGET_LEN * 3;
119
120/// Longest symlink target in bytes that can pass the
121/// [`MAX_SYMLINK_TARGET_LEN`] check; the read and allocation cap for targets
122/// stored as bytes.
123#[cfg(not(target_os = "windows"))]
124pub const MAX_SYMLINK_TARGET_BYTES: usize = MAX_SYMLINK_TARGET_LEN;
125
126closed_enum! {
127    /// The kind of link a target belongs to; the host limits them differently.
128    ///
129    /// # Examples
130    ///
131    /// ```
132    /// use exarch_core::LinkKind;
133    ///
134    /// assert_eq!(LinkKind::Symlink.as_str(), "symlink");
135    /// ```
136    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
137    #[non_exhaustive]
138    pub enum LinkKind {
139        /// A symbolic link; `symlink(2)` bounds only the whole target
140        Symlink => "symlink",
141        /// A hard link; the target is resolved as a path, so components count too
142        Hardlink => "hardlink",
143    }
144}
145
146#[cfg(windows)]
147fn name_len(name: &OsStr) -> usize {
148    std::os::windows::ffi::OsStrExt::encode_wide(name).count()
149}
150
151#[cfg(not(windows))]
152fn name_len(name: &OsStr) -> usize {
153    name.as_encoded_bytes().len()
154}
155
156/// Rejects entry names the host would refuse with `ENAMETOOLONG`.
157///
158/// # Errors
159///
160/// Returns an error if the path or any of its components is too long.
161pub fn check_name_lengths(path: &Path) -> io::Result<()> {
162    let too_long = name_len(path.as_os_str()) > MAX_PATH_LEN
163        || path
164            .components()
165            .any(|c| name_len(c.as_os_str()) > MAX_COMPONENT_LEN);
166    if too_long {
167        return Err(name_too_long());
168    }
169    Ok(())
170}
171
172/// Rejects a link target the host would refuse with `ENAMETOOLONG`.
173///
174/// A symlink target is checked against [`MAX_SYMLINK_TARGET_LEN`] only, as
175/// `symlink(2)` does; a hardlink target is checked like an entry name.
176///
177/// # Errors
178///
179/// Returns an error if the target is too long for `kind`.
180pub fn check_link_target_length(target: &Path, kind: LinkKind) -> io::Result<()> {
181    match kind {
182        LinkKind::Symlink if name_len(target.as_os_str()) > MAX_SYMLINK_TARGET_LEN => {
183            Err(name_too_long())
184        }
185        LinkKind::Symlink => Ok(()),
186        LinkKind::Hardlink => check_name_lengths(target),
187    }
188}
189
190/// The error for a name beyond the host's limits.
191#[must_use]
192pub fn name_too_long() -> io::Error {
193    io::Error::new(io::ErrorKind::InvalidFilename, "file name too long")
194}
195
196/// Filesystem probes needed while validating archive entries.
197pub trait FsView {
198    /// Returns `true` if `path` exists, following symlinks.
199    fn exists(&self, path: &Path) -> bool;
200
201    /// Returns `true` if `path` exists and is a directory, following symlinks.
202    fn is_dir(&self, path: &Path) -> bool;
203
204    /// Returns whether `path` itself is a symlink, without following it.
205    ///
206    /// # Errors
207    ///
208    /// Returns an error if `path` cannot be inspected (for example, it does
209    /// not exist).
210    fn is_symlink(&self, path: &Path) -> io::Result<bool>;
211
212    /// Resolves `path` to its canonical form.
213    ///
214    /// # Errors
215    ///
216    /// Returns an error if a component is missing or the chain cannot be
217    /// resolved.
218    fn canonicalize(&self, path: &Path) -> io::Result<PathBuf>;
219}
220
221/// The real filesystem.
222#[derive(Debug, Clone, Copy, Default)]
223pub struct HostFs;
224
225impl FsView for HostFs {
226    fn exists(&self, path: &Path) -> bool {
227        path.exists()
228    }
229
230    fn is_dir(&self, path: &Path) -> bool {
231        path.is_dir()
232    }
233
234    fn is_symlink(&self, path: &Path) -> io::Result<bool> {
235        std::fs::symlink_metadata(path).map(|m| m.file_type().is_symlink())
236    }
237
238    fn canonicalize(&self, path: &Path) -> io::Result<PathBuf> {
239        std::fs::canonicalize(path)
240    }
241}
242
243/// How two colliding names differ, which sets how serious the collision is.
244#[derive(Debug, Clone, Copy, PartialEq, Eq)]
245pub enum CollisionKind {
246    /// Both are directories and neither holds anything in the archive, so the
247    /// merge changes nothing
248    Directories,
249    /// Both are directories and at least one holds entries, which then land in
250    /// one directory and can overwrite each other
251    DirectoriesWithContents,
252    /// Both are files, so one silently replaces or is skipped for the other
253    Files,
254    /// The kinds differ or a symlink is involved, so later entries can
255    /// resolve differently than the archive intends
256    Mixed,
257}
258
259/// Two entry names that differ exactly but are one name on the host.
260#[derive(Debug, Clone, PartialEq, Eq)]
261pub struct NameCollision {
262    /// Path of the node that was there first
263    pub existing: PathBuf,
264    /// Path of the entry that hit it
265    pub incoming: PathBuf,
266    /// How the two differ
267    pub kind: CollisionKind,
268}
269
270impl CollisionKind {
271    fn of(existing: &Node, incoming: &Node, existing_has_contents: bool) -> Self {
272        match (existing, incoming) {
273            (Node::Dir, Node::Dir) if existing_has_contents => Self::DirectoriesWithContents,
274            (Node::Dir, Node::Dir) => Self::Directories,
275            (Node::File, Node::File) => Self::Files,
276            _ => Self::Mixed,
277        }
278    }
279}
280
281/// What already occupies a path an insert collided with.
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub enum Occupant {
284    /// A regular file or materialized hardlink.
285    File,
286    /// A directory.
287    Directory,
288    /// A symlink, readable or opaque.
289    Symlink,
290}
291
292impl Occupant {
293    /// The noun used in reports.
294    pub(crate) const fn noun(self) -> &'static str {
295        match self {
296            Self::File => "file",
297            Self::Directory => "directory",
298            Self::Symlink => "symlink",
299        }
300    }
301}
302
303/// A failed [`VirtualTree::try_insert`].
304#[derive(Debug)]
305pub struct Conflict {
306    /// The error `insert` would report.
307    pub(crate) error: io::Error,
308    /// `None` when the failure is in the parent chain, not at the path itself.
309    pub(crate) occupant: Option<Occupant>,
310}
311
312impl Conflict {
313    pub(crate) fn already_exists(occupant: Occupant) -> Self {
314        Self {
315            error: io::Error::from(io::ErrorKind::AlreadyExists),
316            occupant: Some(occupant),
317        }
318    }
319}
320
321impl From<io::Error> for Conflict {
322    fn from(error: io::Error) -> Self {
323        Self {
324            error,
325            occupant: None,
326        }
327    }
328}
329
330type FoldFn = for<'a> fn(&'a OsStr) -> Cow<'a, OsStr>;
331
332/// The kind of a node in the in-memory tree.
333#[derive(Debug, Clone, PartialEq, Eq)]
334pub enum Node {
335    /// A directory.
336    Dir,
337    /// A regular file or materialized hardlink.
338    File,
339    /// A symlink with its target exactly as stored in the archive.
340    Symlink(PathBuf),
341    /// A symlink whose target was not read, so nothing can be resolved through
342    /// it: it exists, but a path that runs through it or lands on it while
343    /// being resolved is an error.
344    OpaqueSymlink,
345}
346
347#[derive(Debug)]
348struct TreeNode {
349    kind: Node,
350    /// The component exactly as the archive spells it.
351    name: OsString,
352    /// Only populated for directories; every spelling seen so far (the node's
353    /// own and its aliases) mapped to the child, so a repeated spelling is
354    /// found without folding it.
355    children: HashMap<OsString, usize>,
356    /// Folded component to child; empty on hosts that compare names exactly.
357    folded: HashMap<OsString, usize>,
358    /// Pairs of spellings of this directory that merged while it was empty: if
359    /// it later gains an entry the merge stops being harmless.
360    empty_merges: Vec<(PathBuf, PathBuf)>,
361}
362
363impl TreeNode {
364    fn new(kind: Node, name: &OsStr) -> Self {
365        Self {
366            kind,
367            name: name.to_os_string(),
368            children: HashMap::new(),
369            folded: HashMap::new(),
370            empty_merges: Vec::new(),
371        }
372    }
373}
374
375const ROOT: usize = 0;
376
377enum Resolved {
378    Inside { rel: PathBuf, id: usize },
379    Outside(PathBuf),
380}
381
382/// A child found by name.
383#[derive(Clone, Copy)]
384struct Found {
385    id: usize,
386    /// Found only through the fold: the archive spells it differently.
387    alias: bool,
388}
389
390/// In-memory model of the destination directory built from archive entries.
391///
392/// Stored as a per-directory component trie, so a lookup or insert costs
393/// O(bytes of the path) regardless of how many entries share a prefix. A name
394/// is folded only the first time it is seen; repeats, such as the parents of
395/// every entry in a directory, hit the exact-spelling map.
396pub struct VirtualTree {
397    root: PathBuf,
398    nodes: Vec<TreeNode>,
399    fold: FoldFn,
400    collisions: Vec<NameCollision>,
401    /// Directories holding at least one empty merge.
402    empty_merge_nodes: Vec<usize>,
403}
404
405impl VirtualTree {
406    /// Creates an empty tree rooted at `root` (the canonical destination).
407    pub fn new(root: &Path) -> Self {
408        Self::with_fold(root, fold)
409    }
410
411    pub(crate) fn with_fold(root: &Path, fold: FoldFn) -> Self {
412        Self {
413            root: root.to_path_buf(),
414            nodes: vec![TreeNode::new(Node::Dir, OsStr::new(""))],
415            fold,
416            collisions: Vec::new(),
417            empty_merge_nodes: Vec::new(),
418        }
419    }
420
421    /// Records `node` at `rel` (relative to the root), creating missing
422    /// parents as directories and following in-root symlinked parents the way
423    /// `create_dir_all` does.
424    ///
425    /// Names that differ exactly but fold to one key are one name on the host.
426    /// Each such pair is recorded once as a [`NameCollision`] (see
427    /// [`take_collisions`](Self::take_collisions)) and then handled like an
428    /// exact repeat, so a merge is never silent.
429    ///
430    /// The first node at a path wins, matching extraction that skips an
431    /// existing entry. A symlink over a file is ambiguous (extraction skips
432    /// it by default but replaces the file when overwriting): the file is
433    /// replaced so later entries are checked against the riskier tree, and an
434    /// error is returned so the archive is never reported as safe. A symlink
435    /// over a directory or a symlink with a different target is refused the
436    /// same way, but the existing node stays; a symlink with the same target is
437    /// an unchanged duplicate.
438    ///
439    /// # Errors
440    ///
441    /// Returns a conflict where extraction can fail: a parent that is a file or
442    /// a dangling symlink, a parent chain that loops or leaves the root, a
443    /// directory entry over an existing non-directory, a symlink over an
444    /// earlier file, directory or symlink with another target, or a file over
445    /// an earlier directory (`IsADirectory`) or symlink.
446    ///
447    /// A failure also names what already occupies the path, so a caller can
448    /// tell what overwriting would do.
449    pub(crate) fn try_insert(&mut self, rel: &Path, node: Node) -> Result<(), Conflict> {
450        let Some(name) = rel.file_name() else {
451            return Ok(());
452        };
453        let parent = self.ensure_dir(rel.parent().unwrap_or_else(|| Path::new("")))?;
454        let existing = self.lookup(parent, name);
455        if let Some(found) = existing {
456            self.settle_alias(parent, found, name, &node, rel, false);
457        }
458        match (existing.map(|f| f.id), node) {
459            (None, node) => {
460                self.add_child(parent, name, node);
461                Ok(())
462            }
463            (Some(id), Node::Dir) => match self.node(id).kind {
464                Node::Dir => Ok(()),
465                Node::File => Err(Conflict::already_exists(Occupant::File)),
466                Node::OpaqueSymlink => Err(Conflict::already_exists(Occupant::Symlink)),
467                Node::Symlink(_) => {
468                    if self.resolves_to_dir(&self.root.join(rel)) {
469                        Ok(())
470                    } else {
471                        Err(Conflict::already_exists(Occupant::Symlink))
472                    }
473                }
474            },
475            (Some(id), node @ (Node::Symlink(_) | Node::OpaqueSymlink)) => {
476                match self.node(id).kind {
477                    Node::File => {
478                        self.node_mut(id).kind = node;
479                        Err(Conflict::already_exists(Occupant::File))
480                    }
481                    Node::Dir => Err(Conflict::already_exists(Occupant::Directory)),
482                    Node::Symlink(ref kept) if matches!(&node, Node::Symlink(t) if t == kept) => {
483                        Ok(())
484                    }
485                    Node::Symlink(_) | Node::OpaqueSymlink => {
486                        Err(Conflict::already_exists(Occupant::Symlink))
487                    }
488                }
489            }
490            (Some(id), Node::File) => match self.node(id).kind {
491                Node::File => Ok(()),
492                Node::Dir => Err(Conflict {
493                    error: io::Error::from(io::ErrorKind::IsADirectory),
494                    occupant: Some(Occupant::Directory),
495                }),
496                Node::Symlink(_) | Node::OpaqueSymlink => {
497                    Err(Conflict::already_exists(Occupant::Symlink))
498                }
499            },
500        }
501    }
502
503    #[cfg(test)]
504    pub(crate) fn insert(&mut self, rel: &Path, node: Node) -> io::Result<()> {
505        self.try_insert(rel, node)
506            .map_err(|conflict| conflict.error)
507    }
508
509    /// What an earlier entry left at `rel`, without following a link at the
510    /// path itself; a parent link is followed as extraction would.
511    pub(crate) fn occupant(&self, rel: &Path) -> Option<Occupant> {
512        let name = rel.file_name()?;
513        let parent = match self.resolve(&self.root.join(rel.parent()?)) {
514            Ok(Resolved::Inside { id, .. }) if self.node(id).kind == Node::Dir => id,
515            _ => return None,
516        };
517        let found = self.lookup(parent, name)?;
518        Some(match self.node(found.id).kind {
519            Node::Dir => Occupant::Directory,
520            Node::File => Occupant::File,
521            Node::Symlink(_) | Node::OpaqueSymlink => Occupant::Symlink,
522        })
523    }
524
525    /// Returns `true` if `path` resolves, through symlinks, to a directory.
526    pub fn resolves_to_dir(&self, path: &Path) -> bool {
527        matches!(
528            self.resolve(path),
529            Ok(Resolved::Inside { id, .. }) if self.node(id).kind == Node::Dir
530        )
531    }
532
533    /// Drains the name collisions recorded by inserts since the last call.
534    ///
535    /// Merges of directories that hold nothing are not included: they are
536    /// harmless unless an entry lands in the directory later, so they wait for
537    /// [`take_empty_merges`](Self::take_empty_merges).
538    pub(crate) fn take_collisions(&mut self) -> Vec<NameCollision> {
539        std::mem::take(&mut self.collisions)
540    }
541
542    /// Drains the merges of directories that are still empty.
543    pub(crate) fn take_empty_merges(&mut self) -> Vec<NameCollision> {
544        let nodes = std::mem::take(&mut self.empty_merge_nodes);
545        nodes
546            .into_iter()
547            .flat_map(|id| std::mem::take(&mut self.node_mut(id).empty_merges))
548            .map(|(existing, incoming)| NameCollision {
549                existing,
550                incoming,
551                kind: CollisionKind::Directories,
552            })
553            .collect()
554    }
555
556    /// Handles a child found by a differently spelled name: remembers the
557    /// spelling, so the pair is reported once, and records the collision.
558    ///
559    /// `incoming_has_contents` is true when the spelling is used as the parent
560    /// of an entry, which makes a directory merge harmful at once; an empty
561    /// merge is flagged so it is upgraded if the directory gains an entry.
562    fn settle_alias(
563        &mut self,
564        parent: usize,
565        found: Found,
566        name: &OsStr,
567        incoming_node: &Node,
568        incoming: &Path,
569        incoming_has_contents: bool,
570    ) {
571        if !found.alias {
572            return;
573        }
574        self.node_mut(parent)
575            .children
576            .insert(name.to_os_string(), found.id);
577        let existing = self.node(found.id);
578        let has_contents = incoming_has_contents || !existing.children.is_empty();
579        let kind = CollisionKind::of(&existing.kind, incoming_node, has_contents);
580        let existing_path = incoming.with_file_name(&existing.name);
581        if kind == CollisionKind::Directories {
582            let node = self.node_mut(found.id);
583            node.empty_merges
584                .push((existing_path, incoming.to_path_buf()));
585            self.empty_merge_nodes.push(found.id);
586            return;
587        }
588        self.collisions.push(NameCollision {
589            existing: existing_path,
590            incoming: incoming.to_path_buf(),
591            kind,
592        });
593    }
594
595    fn node(&self, id: usize) -> &TreeNode {
596        &self.nodes[id]
597    }
598
599    fn node_mut(&mut self, id: usize) -> &mut TreeNode {
600        &mut self.nodes[id]
601    }
602
603    fn lookup(&self, parent: usize, name: &OsStr) -> Option<Found> {
604        if name_len(name) > MAX_COMPONENT_LEN {
605            return None;
606        }
607        let node = self.node(parent);
608        if let Some(&id) = node.children.get(name) {
609            return Some(Found { id, alias: false });
610        }
611        match (self.fold)(name) {
612            Cow::Borrowed(_) => None,
613            Cow::Owned(key) => node.folded.get(&key).map(|&id| Found { id, alias: true }),
614        }
615    }
616
617    fn child(&self, parent: usize, name: &OsStr) -> Option<usize> {
618        self.lookup(parent, name).map(|found| found.id)
619    }
620
621    /// Adds a node under `parent`.
622    fn add_child(&mut self, parent: usize, name: &OsStr, kind: Node) -> usize {
623        let id = self.nodes.len();
624        self.nodes.push(TreeNode::new(kind, name));
625        let key = match (self.fold)(name) {
626            Cow::Borrowed(_) => None,
627            Cow::Owned(key) => Some(key),
628        };
629        let upgrade = {
630            let parent_node = self.node_mut(parent);
631            parent_node.children.insert(name.to_os_string(), id);
632            if let Some(key) = key {
633                parent_node.folded.insert(key, id);
634            }
635            std::mem::take(&mut parent_node.empty_merges)
636        };
637        for (existing, incoming) in upgrade {
638            self.collisions.push(NameCollision {
639                existing,
640                incoming,
641                kind: CollisionKind::DirectoriesWithContents,
642            });
643        }
644        id
645    }
646
647    fn ensure_dir(&mut self, rel: &Path) -> io::Result<usize> {
648        let mut current = ROOT;
649        let mut walked = PathBuf::new();
650        for component in rel.components() {
651            let name = match component {
652                Component::Normal(name) => name,
653                Component::CurDir => continue,
654                _ => return Err(io::Error::from(io::ErrorKind::InvalidInput)),
655            };
656            walked.push(name);
657            current = match self.lookup(current, name) {
658                None => self.add_child(current, name, Node::Dir),
659                Some(found) => {
660                    self.settle_alias(current, found, name, &Node::Dir, &walked, true);
661                    match self.node(found.id).kind {
662                        Node::Dir => found.id,
663                        Node::File => return Err(io::Error::from(io::ErrorKind::NotADirectory)),
664                        Node::OpaqueSymlink => return Err(unread_target_error()),
665                        Node::Symlink(_) => match self.resolve(&self.root.join(&walked))? {
666                            Resolved::Inside { id, .. } if self.node(id).kind == Node::Dir => id,
667                            Resolved::Inside { .. } => {
668                                return Err(io::Error::from(io::ErrorKind::NotADirectory));
669                            }
670                            Resolved::Outside(_) => {
671                                return Err(io::Error::other("path leaves the extraction root"));
672                            }
673                        },
674                    }
675                }
676            };
677        }
678        Ok(current)
679    }
680
681    fn resolve(&self, path: &Path) -> io::Result<Resolved> {
682        let Ok(rel) = path.strip_prefix(&self.root) else {
683            return Ok(Resolved::Outside(lexical(path.components())));
684        };
685        let mut pending: VecDeque<Component<'_>> = rel.components().collect();
686        let mut stack = vec![ROOT];
687        let mut current = PathBuf::new();
688        let mut hops = 0;
689
690        while let Some(component) = pending.pop_front() {
691            match component {
692                Component::CurDir => {}
693                Component::ParentDir => {
694                    if stack.len() == 1 {
695                        return Ok(Resolved::Outside(self.leave_root(pending)));
696                    }
697                    stack.pop();
698                    current.pop();
699                }
700                Component::Normal(name) => {
701                    let top = stack.last().copied().unwrap_or(ROOT);
702                    let Some(id) = self.child(top, name) else {
703                        return Err(io::Error::from(io::ErrorKind::NotFound));
704                    };
705                    match &self.node(id).kind {
706                        Node::Dir => {
707                            stack.push(id);
708                            current.push(name);
709                        }
710                        Node::File => {
711                            if !pending.is_empty() {
712                                return Err(io::Error::from(io::ErrorKind::NotADirectory));
713                            }
714                            stack.push(id);
715                            current.push(name);
716                        }
717                        Node::OpaqueSymlink => {
718                            if !pending.is_empty() {
719                                return Err(unread_target_error());
720                            }
721                            stack.push(id);
722                            current.push(name);
723                        }
724                        Node::Symlink(target) => {
725                            hops += 1;
726                            if hops > MAX_SYMLINK_HOPS {
727                                return Err(io::Error::other("too many levels of symbolic links"));
728                            }
729                            if target.has_root()
730                                || target.components().next().is_some_and(is_prefix)
731                            {
732                                return Ok(Resolved::Outside(lexical(
733                                    target.components().chain(pending),
734                                )));
735                            }
736                            for part in target.components().rev() {
737                                pending.push_front(part);
738                            }
739                        }
740                    }
741                }
742                Component::RootDir | Component::Prefix(_) => {
743                    return Ok(Resolved::Outside(lexical(
744                        std::iter::once(component).chain(pending),
745                    )));
746                }
747            }
748        }
749        Ok(Resolved::Inside {
750            rel: current,
751            id: stack.last().copied().unwrap_or(ROOT),
752        })
753    }
754
755    fn leave_root(&self, remaining: VecDeque<Component<'_>>) -> PathBuf {
756        let base = self.root.parent().unwrap_or(&self.root);
757        let mut out = base.to_path_buf();
758        for component in remaining {
759            match component {
760                Component::ParentDir => {
761                    out.pop();
762                }
763                Component::Normal(name) => out.push(name),
764                _ => {}
765            }
766        }
767        out
768    }
769}
770
771impl FsView for VirtualTree {
772    fn exists(&self, path: &Path) -> bool {
773        matches!(self.resolve(path), Ok(Resolved::Inside { .. }))
774    }
775
776    fn is_dir(&self, path: &Path) -> bool {
777        self.resolves_to_dir(path)
778    }
779
780    fn is_symlink(&self, path: &Path) -> io::Result<bool> {
781        let not_found = || io::Error::from(io::ErrorKind::NotFound);
782        let Ok(rel) = path.strip_prefix(&self.root) else {
783            return Err(not_found());
784        };
785        let Some(name) = rel.file_name() else {
786            return Ok(false);
787        };
788        let parent = self
789            .root
790            .join(rel.parent().unwrap_or_else(|| Path::new("")));
791        let Resolved::Inside { id: parent, .. } = self.resolve(&parent)? else {
792            return Err(not_found());
793        };
794        let id = self.child(parent, name).ok_or_else(not_found)?;
795        Ok(matches!(
796            self.node(id).kind,
797            Node::Symlink(_) | Node::OpaqueSymlink
798        ))
799    }
800
801    fn canonicalize(&self, path: &Path) -> io::Result<PathBuf> {
802        match self.resolve(path)? {
803            Resolved::Inside { id, .. } if self.node(id).kind == Node::OpaqueSymlink => {
804                Err(unread_target_error())
805            }
806            Resolved::Inside { rel, .. } => Ok(self.root.join(rel)),
807            Resolved::Outside(path) => Ok(path),
808        }
809    }
810}
811
812fn unread_target_error() -> io::Error {
813    io::Error::other("path runs through a symlink whose target was not read")
814}
815
816fn is_prefix(component: Component<'_>) -> bool {
817    matches!(component, Component::Prefix(_))
818}
819
820fn lexical<'a>(components: impl Iterator<Item = Component<'a>>) -> PathBuf {
821    let mut out = PathBuf::new();
822    for component in components {
823        match component {
824            Component::ParentDir => {
825                out.pop();
826            }
827            Component::CurDir => {}
828            other => out.push(other),
829        }
830    }
831    out
832}
833
834/// Compares names the way the host does, as far as it can be modelled.
835///
836/// Non-UTF-8 names are folded lossily, which can only merge more names, and a
837/// merge of differently spelled names is always reported.
838#[cfg(any(target_os = "macos", target_os = "windows"))]
839fn fold(name: &OsStr) -> Cow<'_, OsStr> {
840    let text = name.to_string_lossy();
841    let folded = if text.is_ascii() {
842        text.to_ascii_uppercase()
843    } else {
844        fold_unicode(&text)
845    };
846    Cow::Owned(OsString::from(folded))
847}
848
849#[cfg(not(any(target_os = "macos", target_os = "windows")))]
850fn fold(name: &OsStr) -> Cow<'_, OsStr> {
851    Cow::Borrowed(name)
852}
853
854/// APFS compares canonically decomposed names case-insensitively.
855#[cfg(target_os = "macos")]
856fn fold_unicode(text: &str) -> String {
857    use unicode_normalization::UnicodeNormalization;
858    upper_lower_upper(text.nfd())
859}
860
861/// NTFS compares names case-insensitively without normalisation.
862#[cfg(target_os = "windows")]
863fn fold_unicode(text: &str) -> String {
864    upper_lower_upper(text.chars())
865}
866
867/// Full per-character case folding built from the standard library only.
868///
869/// Upper-casing first merges `ß` with `ẞ`, lower-casing merges the final
870/// sigma and `ϴ` with `θ`, and the last upper-casing makes the key stable.
871#[cfg(any(target_os = "macos", target_os = "windows"))]
872fn upper_lower_upper(chars: impl Iterator<Item = char>) -> String {
873    chars
874        .flat_map(char::to_uppercase)
875        .flat_map(char::to_lowercase)
876        .flat_map(char::to_uppercase)
877        .collect()
878}
879
880#[cfg(test)]
881#[allow(clippy::unwrap_used, clippy::expect_used)]
882mod tests {
883    use super::*;
884
885    fn tree() -> VirtualTree {
886        VirtualTree::new(Path::new("/dest"))
887    }
888
889    fn put(t: &mut VirtualTree, path: &str, node: Node) {
890        t.insert(Path::new(path), node).unwrap();
891    }
892
893    fn sym(target: &str) -> Node {
894        Node::Symlink(PathBuf::from(target))
895    }
896
897    #[test]
898    fn insert_creates_implicit_parents() {
899        let mut t = tree();
900        put(&mut t, "a/b/c.txt", Node::File);
901        assert!(t.exists(Path::new("/dest/a/b")));
902        assert!(t.exists(Path::new("/dest/a/b/c.txt")));
903        assert!(!t.exists(Path::new("/dest/a/x")));
904    }
905
906    #[test]
907    fn missing_component_is_not_found() {
908        let t = tree();
909        let err = t.canonicalize(Path::new("/dest/nope")).unwrap_err();
910        assert_eq!(err.kind(), io::ErrorKind::NotFound);
911    }
912
913    #[test]
914    fn in_root_chain_resolves_to_final_location() {
915        let mut t = tree();
916        put(&mut t, "real", Node::Dir);
917        put(&mut t, "b", sym("real"));
918        put(&mut t, "a", sym("b"));
919        assert_eq!(
920            t.canonicalize(Path::new("/dest/a")).unwrap(),
921            PathBuf::from("/dest/real")
922        );
923    }
924
925    #[test]
926    fn chain_leaving_root_yields_outside_path() {
927        let mut t = tree();
928        put(&mut t, "y", sym("."));
929        put(&mut t, "d", sym("y/.."));
930        put(&mut t, "e", sym("d/.."));
931        let resolved = t.canonicalize(Path::new("/dest/e/secret")).unwrap();
932        assert!(!resolved.starts_with("/dest"), "{resolved:?}");
933    }
934
935    #[test]
936    fn symlink_loop_is_an_error() {
937        let mut t = tree();
938        put(&mut t, "a", sym("b"));
939        put(&mut t, "b", sym("a"));
940        assert!(t.canonicalize(Path::new("/dest/a")).is_err());
941    }
942
943    #[test]
944    fn long_chain_hits_hop_limit() {
945        let mut t = tree();
946        put(&mut t, "l0", Node::Dir);
947        for i in 1..=MAX_SYMLINK_HOPS + 1 {
948            put(&mut t, &format!("l{i}"), sym(&format!("l{}", i - 1)));
949        }
950        let over = format!("/dest/l{}", MAX_SYMLINK_HOPS + 1);
951        let at = format!("/dest/l{MAX_SYMLINK_HOPS}");
952        assert!(t.canonicalize(Path::new(&over)).is_err());
953        assert!(t.canonicalize(Path::new(&at)).is_ok());
954    }
955
956    #[cfg(any(unix, windows))]
957    fn make_symlink(target: &str, link: &Path) -> io::Result<()> {
958        #[cfg(unix)]
959        return std::os::unix::fs::symlink(target, link);
960        #[cfg(windows)]
961        return std::os::windows::fs::symlink_dir(target, link);
962    }
963
964    #[cfg(any(unix, windows))]
965    #[test]
966    fn hop_limit_matches_host_canonicalize() {
967        let temp = tempfile::TempDir::new().unwrap();
968        // The temp dir itself may sit behind a symlink (/var on macOS), which
969        // would spend one hop of the chain below.
970        let root = temp.path().canonicalize().unwrap();
971        std::fs::create_dir(root.join("l0")).unwrap();
972
973        let mut resolvable = 0;
974        for i in 1..=MAX_SYMLINK_HOPS + 8 {
975            let linked = make_symlink(&format!("l{}", i - 1), &root.join(format!("l{i}")));
976            assert!(linked.is_ok(), "cannot create symlinks here: {linked:?}");
977            if std::fs::canonicalize(root.join(format!("l{i}"))).is_err() {
978                break;
979            }
980            resolvable = i;
981        }
982        assert_eq!(resolvable, MAX_SYMLINK_HOPS, "host and model limits differ");
983
984        let mut t = tree();
985        put(&mut t, "l0", Node::Dir);
986        for i in 1..=resolvable + 1 {
987            put(&mut t, &format!("l{i}"), sym(&format!("l{}", i - 1)));
988        }
989        assert!(
990            t.canonicalize(Path::new(&format!("/dest/l{resolvable}")))
991                .is_ok()
992        );
993        assert!(
994            t.canonicalize(Path::new(&format!("/dest/l{}", resolvable + 1)))
995                .is_err()
996        );
997    }
998
999    fn ascii_upper(name: &OsStr) -> Cow<'_, OsStr> {
1000        Cow::Owned(OsString::from(name.to_string_lossy().to_ascii_uppercase()))
1001    }
1002
1003    fn folding_tree() -> VirtualTree {
1004        VirtualTree::with_fold(Path::new("/dest"), ascii_upper)
1005    }
1006
1007    fn collisions_of(t: &mut VirtualTree) -> Vec<(String, String, CollisionKind)> {
1008        t.take_collisions()
1009            .into_iter()
1010            .map(|c| {
1011                (
1012                    c.existing.display().to_string().replace('\\', "/"),
1013                    c.incoming.display().to_string().replace('\\', "/"),
1014                    c.kind,
1015                )
1016            })
1017            .collect()
1018    }
1019
1020    #[test]
1021    fn exact_repeat_is_not_a_collision() {
1022        let mut t = folding_tree();
1023        put(&mut t, "a/f", Node::File);
1024        put(&mut t, "a/f", Node::File);
1025        assert!(t.take_collisions().is_empty());
1026    }
1027
1028    #[test]
1029    fn differently_spelled_files_collide() {
1030        let mut t = folding_tree();
1031        put(&mut t, "d/a.txt", Node::File);
1032        put(&mut t, "d/A.txt", Node::File);
1033        assert_eq!(
1034            collisions_of(&mut t),
1035            [("d/a.txt".into(), "d/A.txt".into(), CollisionKind::Files)]
1036        );
1037        assert!(t.take_collisions().is_empty());
1038    }
1039
1040    #[test]
1041    fn differently_spelled_parent_directories_collide() {
1042        let mut t = folding_tree();
1043        put(&mut t, "Images/x", Node::File);
1044        put(&mut t, "images/y", Node::File);
1045        assert_eq!(
1046            collisions_of(&mut t),
1047            [(
1048                "Images".into(),
1049                "images".into(),
1050                CollisionKind::DirectoriesWithContents
1051            )]
1052        );
1053    }
1054
1055    #[test]
1056    fn a_spelling_is_reported_once_however_many_entries_use_it() {
1057        let mut t = folding_tree();
1058        put(&mut t, "Images/a", Node::File);
1059        for i in 0..100 {
1060            put(&mut t, &format!("images/f{i}"), Node::File);
1061        }
1062        assert_eq!(collisions_of(&mut t).len(), 1);
1063    }
1064
1065    #[test]
1066    fn empty_directories_merge_harmlessly_until_one_gains_an_entry() {
1067        let mut t = folding_tree();
1068        put(&mut t, "Dir", Node::Dir);
1069        put(&mut t, "dir", Node::Dir);
1070        assert!(collisions_of(&mut t).is_empty());
1071        put(&mut t, "Dir/f", Node::File);
1072        put(&mut t, "dir/g", Node::File);
1073        assert_eq!(
1074            collisions_of(&mut t),
1075            [(
1076                "Dir".into(),
1077                "dir".into(),
1078                CollisionKind::DirectoriesWithContents
1079            )]
1080        );
1081    }
1082
1083    #[test]
1084    fn empty_merges_are_reported_once_when_nothing_lands_in_them() {
1085        let mut t = folding_tree();
1086        put(&mut t, "Dir", Node::Dir);
1087        put(&mut t, "dir", Node::Dir);
1088        put(&mut t, "other", Node::File);
1089        let merges: Vec<_> = t
1090            .take_empty_merges()
1091            .into_iter()
1092            .map(|c| {
1093                (
1094                    c.existing.display().to_string(),
1095                    c.incoming.display().to_string(),
1096                    c.kind,
1097                )
1098            })
1099            .collect();
1100        assert_eq!(
1101            merges,
1102            [("Dir".into(), "dir".into(), CollisionKind::Directories)]
1103        );
1104        assert!(t.take_empty_merges().is_empty());
1105    }
1106
1107    #[test]
1108    fn directory_entry_over_a_directory_with_contents_is_not_harmless() {
1109        let mut t = folding_tree();
1110        put(&mut t, "Dir/f", Node::File);
1111        put(&mut t, "dir", Node::Dir);
1112        assert_eq!(
1113            collisions_of(&mut t),
1114            [(
1115                "Dir".into(),
1116                "dir".into(),
1117                CollisionKind::DirectoriesWithContents
1118            )]
1119        );
1120    }
1121
1122    #[test]
1123    fn names_are_folded_once_however_often_they_repeat() {
1124        use std::sync::atomic::AtomicUsize;
1125        use std::sync::atomic::Ordering;
1126
1127        static CALLS: AtomicUsize = AtomicUsize::new(0);
1128        fn counting(name: &OsStr) -> Cow<'_, OsStr> {
1129            CALLS.fetch_add(1, Ordering::Relaxed);
1130            ascii_upper(name)
1131        }
1132
1133        let mut t = VirtualTree::with_fold(Path::new("/dest"), counting);
1134        for i in 0..500 {
1135            put(&mut t, &format!("a/b/c/d/file{i}"), Node::File);
1136        }
1137        // Four directories and 500 files, each folded to look up and to store;
1138        // a fold per parent per entry would be about 2500 calls.
1139        assert!(CALLS.load(Ordering::Relaxed) <= 2 * 504 + 8);
1140    }
1141
1142    #[test]
1143    fn collision_across_kinds_or_with_a_symlink_is_mixed() {
1144        let mut t = folding_tree();
1145        put(&mut t, "a", Node::File);
1146        let _ = t.insert(Path::new("A"), Node::Dir);
1147        put(&mut t, "link", sym("a"));
1148        let _ = t.insert(Path::new("LINK"), Node::File);
1149        assert_eq!(
1150            collisions_of(&mut t),
1151            [
1152                ("a".into(), "A".into(), CollisionKind::Mixed),
1153                ("link".into(), "LINK".into(), CollisionKind::Mixed)
1154            ]
1155        );
1156    }
1157
1158    #[test]
1159    fn symlink_over_an_existing_dir_is_refused_and_keeps_the_dir() {
1160        let mut t = folding_tree();
1161        put(&mut t, "d/f", Node::File);
1162        let err = t.insert(Path::new("d"), sym("sub")).unwrap_err();
1163        assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
1164        let err = t.insert(Path::new("d"), Node::OpaqueSymlink).unwrap_err();
1165        assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
1166        assert!(t.resolves_to_dir(&t.root.join("d")));
1167    }
1168
1169    #[test]
1170    fn collision_is_recorded_when_insert_fails() {
1171        let mut t = folding_tree();
1172        put(&mut t, "y", Node::File);
1173        let err = t.insert(Path::new("Y"), sym(".")).unwrap_err();
1174        assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
1175        assert_eq!(
1176            collisions_of(&mut t),
1177            [("y".into(), "Y".into(), CollisionKind::Mixed)]
1178        );
1179    }
1180
1181    #[cfg(target_os = "macos")]
1182    mod macos {
1183        use super::super::fold;
1184        use super::*;
1185
1186        fn key(s: &str) -> OsString {
1187            fold(OsStr::new(s)).into_owned()
1188        }
1189
1190        #[test]
1191        fn special_case_pairs_fold_equal() {
1192            assert_eq!(key("\u{df}"), key("\u{1e9e}"));
1193            assert_eq!(key("\u{3b8}"), key("\u{3f4}"));
1194            assert_eq!(key("\u{3b8}"), key("\u{3d1}"));
1195            assert_eq!(key("\u{3c2}"), key("\u{3a3}"));
1196        }
1197
1198        #[test]
1199        fn canonically_equivalent_names_fold_equal() {
1200            assert_eq!(key("caf\u{e9}"), key("cafe\u{301}"));
1201            assert_eq!(key("\u{ac00}"), key("\u{1100}\u{1161}"));
1202        }
1203
1204        #[test]
1205        fn nfc_and_nfd_spellings_collide_in_the_tree() {
1206            let mut t = tree();
1207            put(&mut t, "caf\u{e9}", Node::File);
1208            put(&mut t, "cafe\u{301}", Node::File);
1209            assert_eq!(t.take_collisions().len(), 1);
1210        }
1211
1212        #[test]
1213        fn fold_never_under_merges_what_apfs_treats_as_equal() {
1214            let temp = tempfile::TempDir::new().unwrap();
1215            let root = temp.path().canonicalize().unwrap();
1216            let mut seen = std::collections::HashSet::new();
1217            let mut under = Vec::new();
1218            // Blocks with case or normalisation behaviour; the Hangul block is
1219            // sampled because it decomposes algorithmically.
1220            let candidates = (0x1..=0x3000)
1221                .chain((0xAC00..=0xD7A3).step_by(11))
1222                .chain(0xF900..=0xFFFF)
1223                .chain(0x1_0400..=0x1_044F)
1224                .chain(0x1_E900..=0x1_E943);
1225            for code in candidates {
1226                let Some(c) = char::from_u32(code).filter(|c| !matches!(c, '/' | '.')) else {
1227                    continue;
1228                };
1229                let name = c.to_string();
1230                let path = root.join(&name);
1231                let host_equal = path.symlink_metadata().is_ok();
1232                if !host_equal && std::fs::File::create_new(&path).is_err() {
1233                    continue;
1234                }
1235                let model_seen = !seen.insert(key(&name));
1236                if host_equal && !model_seen {
1237                    under.push(format!("U+{code:04X}"));
1238                }
1239            }
1240            assert!(under.is_empty(), "under-merged: {under:?}");
1241        }
1242    }
1243
1244    #[cfg(target_os = "windows")]
1245    #[test]
1246    fn fold_covers_ntfs_simple_uppercase() {
1247        use std::collections::HashMap;
1248
1249        let mut by_upper: HashMap<char, OsString> = HashMap::new();
1250        for code in 0x1..=0x1_FFFFu32 {
1251            let Some(c) = char::from_u32(code) else {
1252                continue;
1253            };
1254            let mut upper = c.to_uppercase();
1255            let simple = match (upper.next(), upper.next()) {
1256                (Some(u), None) => u,
1257                _ => c,
1258            };
1259            let key = fold(OsStr::new(&c.to_string())).into_owned();
1260            let expected = by_upper.entry(simple).or_insert_with(|| key.clone());
1261            assert_eq!(*expected, key, "U+{code:04X}");
1262        }
1263    }
1264
1265    #[test]
1266    fn dangling_symlink_does_not_exist_but_is_a_symlink() {
1267        let mut t = tree();
1268        put(&mut t, "d", sym("missing"));
1269        assert!(!t.exists(Path::new("/dest/d")));
1270        assert!(t.is_symlink(Path::new("/dest/d")).unwrap());
1271    }
1272
1273    #[test]
1274    fn opaque_symlink_exists_and_is_a_symlink_but_not_a_directory() {
1275        let mut t = tree();
1276        put(&mut t, "d", Node::OpaqueSymlink);
1277        assert!(t.exists(Path::new("/dest/d")));
1278        assert!(t.is_symlink(Path::new("/dest/d")).unwrap());
1279        assert!(!t.resolves_to_dir(Path::new("/dest/d")));
1280    }
1281
1282    #[test]
1283    fn nothing_resolves_through_an_opaque_symlink() {
1284        let mut t = tree();
1285        put(&mut t, "d", Node::OpaqueSymlink);
1286        assert!(t.canonicalize(Path::new("/dest/d")).is_err());
1287        assert!(t.canonicalize(Path::new("/dest/d/x")).is_err());
1288        assert!(t.insert(Path::new("d/f"), Node::File).is_err());
1289    }
1290
1291    #[test]
1292    fn a_chain_ending_at_an_opaque_symlink_does_not_resolve() {
1293        let mut t = tree();
1294        put(&mut t, "d", Node::OpaqueSymlink);
1295        put(&mut t, "l", sym("d"));
1296        assert!(t.canonicalize(Path::new("/dest/l")).is_err());
1297    }
1298
1299    #[test]
1300    fn opaque_symlink_keeps_its_place_like_a_symlink() {
1301        let mut t = tree();
1302        put(&mut t, "d", Node::OpaqueSymlink);
1303        assert_eq!(
1304            t.insert(Path::new("d"), Node::Dir).unwrap_err().kind(),
1305            io::ErrorKind::AlreadyExists
1306        );
1307        assert_eq!(
1308            t.insert(Path::new("d"), Node::File).unwrap_err().kind(),
1309            io::ErrorKind::AlreadyExists
1310        );
1311        assert!(t.is_symlink(Path::new("/dest/d")).unwrap());
1312
1313        put(&mut t, "f", Node::File);
1314        assert!(t.insert(Path::new("f"), Node::OpaqueSymlink).is_err());
1315        assert!(t.is_symlink(Path::new("/dest/f")).unwrap());
1316    }
1317
1318    #[test]
1319    fn opaque_symlink_collision_is_mixed() {
1320        let mut t = folding_tree();
1321        put(&mut t, "link", Node::OpaqueSymlink);
1322        let _ = t.insert(Path::new("LINK"), Node::File);
1323        assert_eq!(
1324            collisions_of(&mut t),
1325            [("link".into(), "LINK".into(), CollisionKind::Mixed)]
1326        );
1327    }
1328
1329    #[test]
1330    fn is_symlink_follows_symlinked_parents() {
1331        let mut t = tree();
1332        put(&mut t, "real/inner", sym("x"));
1333        put(&mut t, "alias", sym("real"));
1334        assert!(t.is_symlink(Path::new("/dest/alias/inner")).unwrap());
1335        assert!(!t.is_symlink(Path::new("/dest/real")).unwrap());
1336        assert!(t.is_symlink(Path::new("/dest/none")).is_err());
1337    }
1338
1339    #[test]
1340    fn insert_through_symlinked_parent_lands_at_resolved_location() {
1341        let mut t = tree();
1342        put(&mut t, "real", Node::Dir);
1343        put(&mut t, "alias", sym("real"));
1344        put(&mut t, "alias/f", Node::File);
1345        assert!(t.exists(Path::new("/dest/real/f")));
1346    }
1347
1348    #[test]
1349    fn insert_dir_keeps_existing_symlink() {
1350        let mut t = tree();
1351        put(&mut t, "real", Node::Dir);
1352        put(&mut t, "alias", sym("real"));
1353        put(&mut t, "alias", Node::Dir);
1354        assert!(t.is_symlink(Path::new("/dest/alias")).unwrap());
1355    }
1356
1357    #[test]
1358    fn file_never_replaces_existing_symlink() {
1359        let mut t = tree();
1360        put(&mut t, "y", sym("."));
1361        let err = t.insert(Path::new("y"), Node::File).unwrap_err();
1362        assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
1363        assert!(t.is_symlink(Path::new("/dest/y")).unwrap());
1364    }
1365
1366    #[test]
1367    fn symlink_over_symlink_is_refused_and_keeps_the_first() {
1368        let mut t = tree();
1369        put(&mut t, "real", Node::Dir);
1370        put(&mut t, "d", sym("real"));
1371        for node in [sym("other"), Node::OpaqueSymlink] {
1372            let conflict = t.try_insert(Path::new("d"), node).unwrap_err();
1373            assert_eq!(conflict.error.kind(), io::ErrorKind::AlreadyExists);
1374            assert_eq!(conflict.occupant, Some(Occupant::Symlink));
1375        }
1376        assert_eq!(
1377            t.canonicalize(Path::new("/dest/d")).unwrap(),
1378            PathBuf::from("/dest/real")
1379        );
1380    }
1381
1382    #[test]
1383    fn symlink_repeating_the_same_target_is_an_unchanged_duplicate() {
1384        let mut t = tree();
1385        put(&mut t, "d", sym("real"));
1386        put(&mut t, "d", sym("real"));
1387        assert!(t.try_insert(Path::new("d"), Node::OpaqueSymlink).is_err());
1388    }
1389
1390    #[test]
1391    fn file_over_dir_is_refused_with_is_a_directory() {
1392        let mut t = tree();
1393        put(&mut t, "d/f", Node::File);
1394        let conflict = t.try_insert(Path::new("d"), Node::File).unwrap_err();
1395        assert_eq!(conflict.error.kind(), io::ErrorKind::IsADirectory);
1396        assert_eq!(conflict.occupant, Some(Occupant::Directory));
1397    }
1398
1399    #[test]
1400    fn file_over_file_is_kept_silently() {
1401        let mut t = tree();
1402        put(&mut t, "f", Node::File);
1403        put(&mut t, "f", Node::File);
1404        assert_eq!(t.occupant(Path::new("f")), Some(Occupant::File));
1405    }
1406
1407    #[test]
1408    fn occupant_names_what_sits_at_the_path_itself() {
1409        let mut t = tree();
1410        put(&mut t, "real/f", Node::File);
1411        put(&mut t, "alias", sym("real"));
1412        assert_eq!(t.occupant(Path::new("real")), Some(Occupant::Directory));
1413        assert_eq!(t.occupant(Path::new("alias")), Some(Occupant::Symlink));
1414        assert_eq!(t.occupant(Path::new("alias/f")), Some(Occupant::File));
1415        assert_eq!(t.occupant(Path::new("none")), None);
1416    }
1417
1418    #[test]
1419    fn symlink_replaces_earlier_file() {
1420        let mut t = tree();
1421        put(&mut t, "y", Node::File);
1422        let err = t.insert(Path::new("y"), sym(".")).unwrap_err();
1423        assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
1424        assert!(t.is_symlink(Path::new("/dest/y")).unwrap());
1425    }
1426
1427    #[test]
1428    fn dir_over_file_is_an_error() {
1429        let mut t = tree();
1430        put(&mut t, "f", Node::File);
1431        let err = t.insert(Path::new("f"), Node::Dir).unwrap_err();
1432        assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
1433    }
1434
1435    #[test]
1436    fn insert_under_file_is_an_error() {
1437        let mut t = tree();
1438        put(&mut t, "f", Node::File);
1439        let err = t.insert(Path::new("f/g"), Node::File).unwrap_err();
1440        assert_eq!(err.kind(), io::ErrorKind::NotADirectory);
1441    }
1442
1443    #[test]
1444    fn insert_under_chain_leaving_root_is_an_error() {
1445        let mut t = tree();
1446        put(&mut t, "out", sym(".."));
1447        assert!(t.insert(Path::new("out/f"), Node::File).is_err());
1448        assert!(!t.exists(Path::new("/dest/out/f")));
1449    }
1450
1451    #[test]
1452    fn resolves_to_dir_distinguishes_files_and_directories() {
1453        let mut t = tree();
1454        put(&mut t, "d/f", Node::File);
1455        put(&mut t, "alias", sym("d"));
1456        assert!(t.resolves_to_dir(Path::new("/dest/d")));
1457        assert!(t.resolves_to_dir(Path::new("/dest/alias")));
1458        assert!(!t.resolves_to_dir(Path::new("/dest/d/f")));
1459    }
1460
1461    #[test]
1462    fn overlong_component_is_never_found() {
1463        let mut t = tree();
1464        put(&mut t, "d", Node::Dir);
1465        let long = "a".repeat(1 << 20);
1466        let err = t
1467            .canonicalize(&Path::new("/dest/d").join(&long))
1468            .unwrap_err();
1469        assert_eq!(err.kind(), io::ErrorKind::NotFound);
1470    }
1471
1472    #[test]
1473    fn name_length_limits() {
1474        assert!(check_name_lengths(Path::new("a/b")).is_ok());
1475        let long_name = "a".repeat(255 + 1);
1476        assert!(check_name_lengths(Path::new(&long_name)).is_err());
1477        let long_path = path_of_len(HOST_PATH_LIMIT + 1);
1478        assert!(check_name_lengths(Path::new(&long_path)).is_err());
1479        assert!(check_name_lengths(Path::new(&path_of_len(HOST_PATH_LIMIT))).is_ok());
1480    }
1481
1482    use crate::test_utils::HOST_PATH_LIMIT;
1483    use crate::test_utils::path_of_len;
1484
1485    #[test]
1486    fn link_target_length_boundaries() {
1487        let symlink_limit = path_of_len(MAX_SYMLINK_TARGET_LEN);
1488        let symlink_over = path_of_len(MAX_SYMLINK_TARGET_LEN + 1);
1489        assert!(check_link_target_length(Path::new(&symlink_limit), LinkKind::Symlink).is_ok());
1490        assert!(check_link_target_length(Path::new(&symlink_over), LinkKind::Symlink).is_err());
1491
1492        let at_limit = path_of_len(HOST_PATH_LIMIT);
1493        assert_eq!(at_limit.len(), HOST_PATH_LIMIT);
1494        let over = path_of_len(HOST_PATH_LIMIT + 1);
1495        assert!(check_link_target_length(Path::new(&at_limit), LinkKind::Hardlink).is_ok());
1496        assert!(check_link_target_length(Path::new(&over), LinkKind::Hardlink).is_err());
1497    }
1498
1499    #[test]
1500    fn symlink_target_limits_match_the_host() {
1501        assert_eq!(
1502            MAX_SYMLINK_TARGET_LEN,
1503            if cfg!(windows) { 4091 } else { HOST_PATH_LIMIT }
1504        );
1505        assert_eq!(
1506            MAX_SYMLINK_TARGET_BYTES,
1507            if cfg!(windows) {
1508                4091 * 3
1509            } else {
1510                HOST_PATH_LIMIT
1511            }
1512        );
1513    }
1514
1515    /// Finds the longest relative symlink target the host creates and checks
1516    /// that [`MAX_SYMLINK_TARGET_LEN`] does not exceed it.
1517    #[cfg(windows)]
1518    #[test]
1519    fn symlink_target_limit_is_accepted_by_the_host() {
1520        use std::os::windows::fs::symlink_file;
1521
1522        let temp = tempfile::TempDir::new().unwrap();
1523        let mut counter = 0_u32;
1524        let mut creates = |len: usize| -> io::Result<bool> {
1525            counter += 1;
1526            let link = temp.path().join(format!("l{counter}"));
1527            let mut target = "abcdefg\\".repeat((len - 1) / 8);
1528            target.push_str(&"a".repeat(len - target.len()));
1529            match symlink_file(&target, &link) {
1530                Ok(()) => Ok(true),
1531                Err(e) if e.raw_os_error() == Some(1314) => Err(e),
1532                Err(_) => Ok(false),
1533            }
1534        };
1535
1536        match creates(1) {
1537            Ok(_) => {}
1538            Err(e) => {
1539                assert!(
1540                    std::env::var_os("CI").is_none(),
1541                    "symlink creation needs a privilege on CI: {e}"
1542                );
1543                eprintln!("skipped: symlink creation is not permitted: {e}");
1544                return;
1545            }
1546        }
1547
1548        let (mut ok, mut bad) = (1_usize, 32767_usize);
1549        while bad - ok > 1 {
1550            let mid = ok + (bad - ok) / 2;
1551            if creates(mid).unwrap() {
1552                ok = mid;
1553            } else {
1554                bad = mid;
1555            }
1556        }
1557        eprintln!("largest relative symlink target: {ok} UTF-16 units");
1558        assert!(MAX_SYMLINK_TARGET_LEN <= ok, "host limit is {ok}");
1559    }
1560
1561    #[test]
1562    fn symlink_target_ignores_component_limit() {
1563        let long_component = "a".repeat(255 + 45);
1564        let target = Path::new(&long_component);
1565        assert!(check_link_target_length(target, LinkKind::Symlink).is_ok());
1566        assert!(check_link_target_length(target, LinkKind::Hardlink).is_err());
1567        assert!(check_name_lengths(target).is_err());
1568    }
1569
1570    #[cfg(any(target_os = "macos", target_os = "windows"))]
1571    #[test]
1572    fn keys_are_case_insensitive_on_case_folding_hosts() {
1573        let mut t = tree();
1574        put(&mut t, "Dir/File", Node::File);
1575        assert!(t.exists(Path::new("/dest/dir/file")));
1576    }
1577
1578    #[test]
1579    fn host_fs_probes_the_real_filesystem() {
1580        let temp = tempfile::TempDir::new().unwrap();
1581        assert!(HostFs.exists(temp.path()));
1582        assert!(!HostFs.is_symlink(temp.path()).unwrap());
1583        assert!(HostFs.canonicalize(&temp.path().join("nope")).is_err());
1584    }
1585}