Skip to main content

noyalib/include/
fs.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 Noyalib. All rights reserved.
3
4//! Capability-rooted filesystem support for `!include`.
5
6use super::{IncludeRequest, IncludeResolver, InputSource, split_fragment};
7use crate::error::{Error, Result};
8use crate::prelude::*;
9
10/// How [`SafeFileResolver`] handles symbolic links it
11/// encounters while resolving a path.
12///
13/// # Examples
14///
15/// ```
16/// use noyalib::include::SymlinkPolicy;
17/// assert_eq!(SymlinkPolicy::default(), SymlinkPolicy::FollowWithinRoot);
18/// ```
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
20#[non_exhaustive]
21pub enum SymlinkPolicy {
22    /// Follow symlinks that resolve to a path still inside the
23    /// resolver's root directory. Reject anything pointing
24    /// outside. Default.
25    #[default]
26    FollowWithinRoot,
27    /// Reject all symbolic links regardless of target. Strictest
28    /// posture; appropriate for untrusted document graphs.
29    Reject,
30}
31
32/// Filesystem-backed [`IncludeResolver`] with root-dir
33/// sandboxing.
34///
35/// Behind the `include_fs` Cargo feature (which implies
36/// `include` + `std`).
37///
38/// # Sandboxing
39///
40/// On Unix, the root is opened once as a directory capability. Every
41/// subsequent file open is relative to that handle, so renaming or
42/// replacing the path used to construct the resolver cannot redirect
43/// later reads. Targets canonicalise to a root-relative path and then
44/// open every component without following symlinks. Windows retains
45/// canonical root checks. Path-traversal attempts (`../../etc/passwd`)
46/// and symlink targets outside the root are rejected before content is
47/// read.
48///
49/// # What is read
50///
51/// Only regular files are read. A directory, FIFO, socket or device is
52/// refused, and on Unix the file is opened non-blocking so that a FIFO
53/// planted under the root cannot stall the parse before it is refused.
54/// At most [`IncludeRequest::max_bytes`] plus one bytes are read, so an
55/// oversized file costs no more memory than the budget it exceeds.
56///
57/// Paths in error messages and [`InputSource::name`] are relative to
58/// the root and use `/` separators on every platform: a document author
59/// learns nothing about where the root sits on the host, and a source
60/// has the same name on Windows as elsewhere.
61///
62/// # Symlinks
63///
64/// Controlled by [`SymlinkPolicy`]. On Unix, the default
65/// [`SymlinkPolicy::FollowWithinRoot`] resolves symlinks through
66/// the directory capability. [`SymlinkPolicy::Reject`] opens each
67/// directory component and the final file without following
68/// symlinks, avoiding a metadata-then-open race. Windows enforces
69/// the same policies through canonical path and metadata checks; there
70/// the path is canonicalised and then opened by name, so a link swapped
71/// in between those two steps is not detected. The regular-file check
72/// runs on the opened handle on every platform, which also refuses
73/// Windows device names such as `CON` or `NUL`. Hard links are not
74/// symlinks and are followed on every platform: a hard link inside the
75/// root to a file outside it is read.
76///
77/// # Examples
78///
79/// ```no_run
80/// use noyalib::include::{SafeFileResolver, SymlinkPolicy};
81///
82/// let resolver = SafeFileResolver::new("/srv/configs")
83///     .symlink_policy(SymlinkPolicy::Reject)
84///     .into_resolver();
85/// let cfg = noyalib::ParserConfig::new().include_resolver(resolver);
86/// # let _ = cfg;
87/// ```
88#[derive(Debug, Clone)]
89pub struct SafeFileResolver {
90    symlink_policy: SymlinkPolicy,
91    capability: Arc<RootCapability>,
92}
93
94#[derive(Debug)]
95enum RootCapability {
96    #[cfg(unix)]
97    Ready {
98        dir: std::fs::File,
99        canonical_root: std::path::PathBuf,
100    },
101    #[cfg(not(unix))]
102    Ready {
103        canonical_root: std::path::PathBuf,
104    },
105    Failed(String),
106}
107
108impl SafeFileResolver {
109    /// Construct a resolver rooted at `root`. All resolved paths
110    /// must canonicalise to a descendant of `root`.
111    ///
112    /// # Examples
113    ///
114    /// ```
115    /// use noyalib::include::SafeFileResolver;
116    /// let r = SafeFileResolver::new("/srv/configs");
117    /// let _ = r;
118    /// ```
119    ///
120    /// The root is opened during construction. Because this constructor
121    /// retains its historical infallible signature, an open failure is
122    /// stored and returned when the resolver is first invoked.
123    #[must_use]
124    pub fn new(root: impl Into<std::path::PathBuf>) -> Self {
125        let root = root.into();
126        let capability = match open_root_capability(&root) {
127            Ok(capability) => capability,
128            Err(error) => RootCapability::Failed(error.to_string()),
129        };
130        Self {
131            symlink_policy: SymlinkPolicy::default(),
132            capability: Arc::new(capability),
133        }
134    }
135
136    /// Set the [`SymlinkPolicy`].
137    #[must_use]
138    pub fn symlink_policy(mut self, policy: SymlinkPolicy) -> Self {
139        self.symlink_policy = policy;
140        self
141    }
142
143    /// Convert this configuration into a boxed [`IncludeResolver`]
144    /// suitable for [`crate::ParserConfig::include_resolver`].
145    #[must_use]
146    pub fn into_resolver(self) -> IncludeResolver {
147        let this = self;
148        IncludeResolver::new(move |req: IncludeRequest<'_>| this.resolve(req))
149    }
150
151    fn resolve(&self, req: IncludeRequest<'_>) -> Result<InputSource> {
152        // Strip the optional `#anchor` fragment. The loader handles anchor
153        // selection after parse, so the resolver only needs the path portion.
154        let (path_part, _fragment) = split_fragment(req.spec);
155        let relative = normalize_relative_path(path_part).map_err(|message| {
156            Error::Custom(format!("include resolver: `{path_part}` {message}"))
157        })?;
158        let capability = self.root_capability()?;
159        let (file, name) =
160            open_from_root(capability, &relative, self.symlink_policy).map_err(|error| {
161                Error::Custom(format!(
162                    "include resolver: `{}` escapes sandbox root, contains a rejected symlink, or cannot read securely: {error}",
163                    slash_path(&relative)
164                ))
165            })?;
166        let bytes = read_regular_file(file, req.max_bytes).map_err(|error| {
167            Error::Custom(format!(
168                "include resolver: cannot read `{}`: {error}",
169                slash_path(&name)
170            ))
171        })?;
172        Ok(InputSource::new(slash_path(&name), bytes))
173    }
174
175    fn root_capability(&self) -> Result<&RootCapability> {
176        match self.capability.as_ref() {
177            ready @ RootCapability::Ready { .. } => Ok(ready),
178            RootCapability::Failed(error) => Err(Error::Custom(format!(
179                "include resolver: cannot open root: {error}"
180            ))),
181        }
182    }
183}
184
185/// Read `file` if it is a regular file, stopping one byte past
186/// `max_bytes` so the caller's budget check sees the overrun without
187/// the whole file being buffered.
188fn read_regular_file(file: std::fs::File, max_bytes: usize) -> std::io::Result<String> {
189    use std::io::Read as _;
190
191    if !file.metadata()?.is_file() {
192        return Err(std::io::Error::new(
193            std::io::ErrorKind::InvalidInput,
194            "not a regular file",
195        ));
196    }
197    let limit = u64::try_from(max_bytes)
198        .unwrap_or(u64::MAX)
199        .saturating_add(1);
200    let mut bytes = String::new();
201    let _read = file.take(limit).read_to_string(&mut bytes)?;
202    Ok(bytes)
203}
204
205/// A root-relative path written with `/` between its components, so
206/// error messages and source names read the same on every platform.
207fn slash_path(path: &std::path::Path) -> String {
208    let parts: Vec<_> = path
209        .components()
210        .map(|c| c.as_os_str().to_string_lossy())
211        .collect();
212    parts.join("/")
213}
214
215fn normalize_relative_path(path: &str) -> core::result::Result<std::path::PathBuf, &'static str> {
216    use std::path::Component;
217
218    let mut normalized = std::path::PathBuf::new();
219    for component in std::path::Path::new(path).components() {
220        match component {
221            Component::Prefix(_) | Component::RootDir => {
222                return Err("must be relative to the root");
223            }
224            Component::CurDir => {}
225            Component::ParentDir => {
226                if !normalized.pop() {
227                    return Err("escapes sandbox root");
228                }
229            }
230            Component::Normal(part) => normalized.push(part),
231        }
232    }
233    Ok(normalized)
234}
235
236#[cfg(unix)]
237fn open_root_capability(root: &std::path::Path) -> std::io::Result<RootCapability> {
238    use rustix::fs::{Mode, OFlags};
239
240    let fd = rustix::fs::open(
241        root,
242        OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
243        Mode::empty(),
244    )?;
245    let dir = std::fs::File::from(fd);
246    #[cfg(target_vendor = "apple")]
247    let canonical_root = {
248        use std::os::unix::ffi::OsStringExt as _;
249
250        let path = rustix::fs::getpath(&dir)?;
251        std::path::PathBuf::from(std::ffi::OsString::from_vec(path.into_bytes()))
252    };
253    #[cfg(not(target_vendor = "apple"))]
254    let canonical_root = std::fs::canonicalize(root)?;
255
256    Ok(RootCapability::Ready {
257        dir,
258        canonical_root,
259    })
260}
261
262#[cfg(not(unix))]
263fn open_root_capability(root: &std::path::Path) -> std::io::Result<RootCapability> {
264    Ok(RootCapability::Ready {
265        canonical_root: std::fs::canonicalize(root)?,
266    })
267}
268
269#[cfg(unix)]
270fn open_from_root(
271    capability: &RootCapability,
272    relative: &std::path::Path,
273    policy: SymlinkPolicy,
274) -> std::io::Result<(std::fs::File, std::path::PathBuf)> {
275    let RootCapability::Ready {
276        dir,
277        canonical_root,
278    } = capability
279    else {
280        unreachable!("failed root capabilities are rejected before open")
281    };
282    let active_root = current_root_path(dir).unwrap_or_else(|_| canonical_root.clone());
283    let open_path = if policy == SymlinkPolicy::FollowWithinRoot {
284        let canonical = std::fs::canonicalize(active_root.join(relative))?;
285        canonical
286            .strip_prefix(&active_root)
287            .map_err(|_| {
288                std::io::Error::new(
289                    std::io::ErrorKind::PermissionDenied,
290                    "resolved path escapes sandbox root",
291                )
292            })?
293            .to_path_buf()
294    } else {
295        relative.to_path_buf()
296    };
297    let file = open_relative_nofollow(dir, &open_path)?;
298    // The root-relative path is the source's identity: canonical within
299    // the root (symlinks resolved, or rejected), so cycle detection
300    // still sees one name per file, and free of the host's layout.
301    Ok((file, open_path))
302}
303
304#[cfg(all(unix, target_vendor = "apple"))]
305fn current_root_path(root: &std::fs::File) -> std::io::Result<std::path::PathBuf> {
306    use std::os::unix::ffi::OsStringExt as _;
307
308    let path = rustix::fs::getpath(root)?;
309    Ok(std::path::PathBuf::from(std::ffi::OsString::from_vec(
310        path.into_bytes(),
311    )))
312}
313
314#[cfg(all(unix, any(target_os = "linux", target_os = "android")))]
315fn current_root_path(root: &std::fs::File) -> std::io::Result<std::path::PathBuf> {
316    use std::os::fd::AsRawFd as _;
317
318    std::fs::read_link(format!("/proc/self/fd/{}", root.as_raw_fd()))
319}
320
321#[cfg(all(
322    unix,
323    not(any(target_vendor = "apple", target_os = "linux", target_os = "android"))
324))]
325fn current_root_path(_root: &std::fs::File) -> std::io::Result<std::path::PathBuf> {
326    Err(std::io::Error::new(
327        std::io::ErrorKind::Unsupported,
328        "the target cannot recover a path from a directory handle",
329    ))
330}
331
332#[cfg(not(unix))]
333fn open_from_root(
334    capability: &RootCapability,
335    relative: &std::path::Path,
336    policy: SymlinkPolicy,
337) -> std::io::Result<(std::fs::File, std::path::PathBuf)> {
338    let RootCapability::Ready { canonical_root } = capability else {
339        unreachable!("failed root capabilities are rejected before open")
340    };
341    let canonical = std::fs::canonicalize(canonical_root.join(relative))?;
342    let Ok(beneath) = canonical.strip_prefix(canonical_root) else {
343        return Err(std::io::Error::new(
344            std::io::ErrorKind::PermissionDenied,
345            "resolved path escapes sandbox root",
346        ));
347    };
348    if policy == SymlinkPolicy::Reject && path_contains_symlink(canonical_root, relative)? {
349        return Err(std::io::Error::new(
350            std::io::ErrorKind::PermissionDenied,
351            "symlink rejected by policy",
352        ));
353    }
354    Ok((std::fs::File::open(&canonical)?, beneath.to_path_buf()))
355}
356
357#[cfg(unix)]
358fn open_relative_nofollow(
359    root: &std::fs::File,
360    relative: &std::path::Path,
361) -> std::io::Result<std::fs::File> {
362    use rustix::fs::{Mode, OFlags};
363
364    let file_name = relative.file_name().ok_or_else(|| {
365        std::io::Error::new(
366            std::io::ErrorKind::InvalidInput,
367            "include path names no file",
368        )
369    })?;
370    let mut parent = rustix::fs::openat(
371        root,
372        ".",
373        OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
374        Mode::empty(),
375    )?;
376    if let Some(ancestors) = relative.parent() {
377        for component in ancestors.components() {
378            parent = rustix::fs::openat(
379                &parent,
380                component.as_os_str(),
381                OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
382                Mode::empty(),
383            )?;
384        }
385    }
386    // `NONBLOCK`: opening a FIFO for reading otherwise waits for a
387    // writer. The regular-file check after the open refuses it; for a
388    // regular file the flag has no effect on reads.
389    let fd = rustix::fs::openat(
390        &parent,
391        file_name,
392        OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK,
393        Mode::empty(),
394    )?;
395    Ok(std::fs::File::from(fd))
396}
397
398#[cfg(not(unix))]
399fn path_contains_symlink(
400    root: &std::path::Path,
401    relative: &std::path::Path,
402) -> std::io::Result<bool> {
403    let mut candidate = root.to_path_buf();
404    for component in relative.components() {
405        candidate.push(component.as_os_str());
406        if std::fs::symlink_metadata(&candidate)?
407            .file_type()
408            .is_symlink()
409        {
410            return Ok(true);
411        }
412    }
413    Ok(false)
414}
415
416#[cfg(test)]
417mod tests {
418    use super::slash_path;
419    use std::path::Path;
420
421    #[test]
422    fn source_names_use_forward_slashes_on_every_platform() {
423        // `join` writes the platform separator (`\` on Windows); the
424        // name a document author sees must not depend on it.
425        let nested = Path::new("sub").join("deeper").join("a.yaml");
426        assert_eq!(slash_path(&nested), "sub/deeper/a.yaml");
427        assert_eq!(slash_path(Path::new("a.yaml")), "a.yaml");
428    }
429}