Skip to main content

wowlab_fs/
containment.rs

1//! Resolution of existing paths within reviewed filesystem boundaries.
2
3use crate::{
4    directory, error,
5    path::{Component, Path, PathBuf},
6};
7
8/// An owned diagnostic for a path that is not below a required root.
9#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
10#[error("{} is outside {}", path.display(), root.display())]
11pub struct OutsideRoot {
12    root: PathBuf,
13    path: PathBuf,
14}
15
16impl OutsideRoot {
17    /// Return the required root.
18    #[must_use]
19    pub fn root(&self) -> &Path {
20        &self.root
21    }
22
23    /// Return the path found outside the root.
24    #[must_use]
25    pub fn path(&self) -> &Path {
26        &self.path
27    }
28}
29
30/// Require `path` to be lexically below `root` and return its relative suffix.
31///
32/// This operation is lexical; use [`resolve_existing`] when the boundary must also hold after symbolic-link resolution.
33///
34/// # Errors
35///
36/// Returns an owned diagnostic when `path` is not below `root`.
37pub fn relative_to<'a>(root: &Path, path: &'a Path) -> Result<&'a Path, OutsideRoot> {
38    match path.strip_prefix(root) {
39        Ok(relative) => Ok(relative),
40        Err(_) => Err(OutsideRoot {
41            root: root.to_path_buf(),
42            path: path.to_path_buf(),
43        }),
44    }
45}
46
47/// Failure to resolve an existing relative path within a root.
48#[derive(Debug, thiserror::Error)]
49#[error("{kind}")]
50pub struct ResolveError {
51    root: PathBuf,
52    path: PathBuf,
53    #[source]
54    kind: Box<ResolveErrorKind>,
55}
56
57#[derive(Debug, thiserror::Error)]
58enum ResolveErrorKind {
59    #[error(
60        "{} is not a non-empty relative path without parent traversal",
61        path.display()
62    )]
63    InvalidRelativePath { path: PathBuf },
64    #[error(transparent)]
65    Filesystem(error::Error),
66    #[error(transparent)]
67    OutsideRoot(OutsideRoot),
68}
69
70impl ResolveError {
71    /// Return the root used for resolution.
72    #[must_use]
73    pub fn root(&self) -> &Path {
74        &self.root
75    }
76
77    /// Return the requested or resolved path associated with the failure.
78    #[must_use]
79    pub fn path(&self) -> &Path {
80        &self.path
81    }
82
83    /// Return the confinement failure when the path resolved outside the root.
84    #[must_use]
85    pub fn outside_root(&self) -> Option<&OutsideRoot> {
86        match self.kind.as_ref() {
87            ResolveErrorKind::OutsideRoot(source) => Some(source),
88            ResolveErrorKind::InvalidRelativePath { .. } | ResolveErrorKind::Filesystem(_) => None,
89        }
90    }
91}
92
93/// Resolve an existing reviewed relative path while confining symbolic links.
94///
95/// The result is host-normalized, absolute, and below the canonical root.
96/// Relative inputs may contain only ordinary or current-directory components.
97///
98/// # Errors
99///
100/// Returns an error when the input is invalid, canonicalization fails, or symbolic-link resolution escapes the root.
101pub fn resolve_existing(root: &Path, relative: &Path) -> Result<PathBuf, ResolveError> {
102    if relative.is_empty()
103        || !relative
104            .components()
105            .all(|component| matches!(component, Component::Normal(_) | Component::Current))
106    {
107        return Err(ResolveError {
108            root: root.to_path_buf(),
109            path: relative.to_path_buf(),
110            kind: Box::new(ResolveErrorKind::InvalidRelativePath {
111                path: relative.to_path_buf(),
112            }),
113        });
114    }
115
116    let canonical_root = directory::canonicalize(root).map_err(|source| ResolveError {
117        root: root.to_path_buf(),
118        path: root.to_path_buf(),
119        kind: Box::new(ResolveErrorKind::Filesystem(source)),
120    })?;
121    let candidate = root.join(relative);
122    let canonical_candidate =
123        directory::canonicalize(&candidate).map_err(|source| ResolveError {
124            root: canonical_root.clone(),
125            path: candidate,
126            kind: Box::new(ResolveErrorKind::Filesystem(source)),
127        })?;
128
129    relative_to(&canonical_root, &canonical_candidate).map_err(|source| ResolveError {
130        root: canonical_root,
131        path: canonical_candidate.clone(),
132        kind: Box::new(ResolveErrorKind::OutsideRoot(source)),
133    })?;
134
135    Ok(canonical_candidate)
136}
137
138#[cfg(all(test, not(target_family = "wasm")))]
139mod tests {
140    use googletest::prelude::*;
141
142    use super::{relative_to, resolve_existing};
143    use crate::{directory, file, link, temporary::Directory};
144
145    #[gtest]
146    fn relative_paths_retain_native_components() -> Result<()> {
147        let root = crate::path::Path::new("repository");
148        let path = crate::path::Path::new("repository/specs/class/manifest.toml");
149
150        verify_that!(
151            relative_to(root, path).or_fail()?,
152            eq(crate::path::Path::new("specs/class/manifest.toml"))
153        )?;
154
155        Ok(())
156    }
157
158    #[gtest]
159    fn existing_paths_are_canonical_and_confined() -> Result<()> {
160        let directory = Directory::new().or_fail()?;
161        let root = directory.path().join("repository");
162        let component = root.join("parts/core.toml");
163
164        directory::ensure(component.parent().or_fail()?).or_fail()?;
165        file::write_text(&component, "[auras]").or_fail()?;
166
167        verify_eq!(
168            resolve_existing(&root, crate::path::Path::new("parts/core.toml")).or_fail()?,
169            directory::canonicalize(&component).or_fail()?
170        )?;
171
172        Ok(())
173    }
174
175    #[gtest]
176    fn file_links_cannot_escape_the_canonical_root() -> Result<()> {
177        let directory = Directory::new().or_fail()?;
178        let root = directory.path().join("repository");
179        let component = root.join("parts/core.toml");
180        let outside = directory.path().join("outside.toml");
181
182        directory::ensure(component.parent().or_fail()?).or_fail()?;
183        file::write_text(&outside, "[auras]").or_fail()?;
184        link::file(&outside, &component).or_fail()?;
185
186        let error = resolve_existing(&root, crate::path::Path::new("parts/core.toml"))
187            .err()
188            .or_fail()?;
189
190        verify_eq!(
191            error.outside_root().map(super::OutsideRoot::path),
192            Some(directory::canonicalize(&outside).or_fail()?.as_ref())
193        )?;
194
195        Ok(())
196    }
197}