Skip to main content

orbok_fs/
path_guard.rs

1//! Backend file-access boundary (RFC-003 §8).
2//!
3//! Before any backend code reads a file it must obtain a
4//! [`ValidatedPath`] from a [`PathGuard`]. Validation performs, in
5//! order: canonicalization, active-source membership, symlink-escape
6//! detection, hidden-file policy, and size limit. Requests for paths
7//! outside every active source fail with
8//! [`OrbokError::PathOutsideSources`] — the guard never trusts
9//! caller-provided paths (frontend or otherwise).
10
11use crate::policy::CompiledPolicy;
12use orbok_core::{HiddenFilePolicy, OrbokError, OrbokResult, SourceId, SymlinkPolicy};
13use orbok_db::repo::SourceRecord;
14use std::path::{Path, PathBuf};
15
16/// One active source root with its compiled policy.
17#[derive(Debug, Clone)]
18pub struct GuardedSource {
19    pub source_id: SourceId,
20    pub canonical_root: PathBuf,
21    pub policy: CompiledPolicy,
22}
23
24impl GuardedSource {
25    pub fn from_record(record: &SourceRecord) -> Self {
26        Self {
27            source_id: record.source_id.clone(),
28            canonical_root: PathBuf::from(&record.canonical_path),
29            policy: CompiledPolicy::from_source(record),
30        }
31    }
32}
33
34/// A path that passed every boundary check. Only this type may be
35/// handed to file readers.
36#[derive(Debug, Clone)]
37pub struct ValidatedPath {
38    pub source_id: SourceId,
39    pub canonical: PathBuf,
40}
41
42/// The access boundary over the currently active sources.
43pub struct PathGuard {
44    sources: Vec<GuardedSource>,
45}
46
47impl PathGuard {
48    /// Build a guard over active sources only (paused/missing/removed
49    /// sources grant no access).
50    pub fn new(sources: Vec<GuardedSource>) -> Self {
51        Self { sources }
52    }
53
54    /// Canonicalize a path the platform-aware way (RFC-003 §11):
55    /// resolves symlinks, `..`, and case differences where the platform
56    /// does.
57    pub fn canonicalize(path: &Path) -> OrbokResult<PathBuf> {
58        std::fs::canonicalize(path)
59            .map_err(|e| OrbokError::PathCanonicalization(format!("{}: {e}", path.display())))
60    }
61
62    /// RFC-003 §8 validation sequence. `requested` may be any path; the
63    /// canonical form decides membership, so symlinks escaping a source
64    /// are rejected regardless of how the request was spelled.
65    pub fn validate(&self, requested: &Path) -> OrbokResult<ValidatedPath> {
66        let canonical = Self::canonicalize(requested)?;
67
68        let source = self
69            .sources
70            .iter()
71            .find(|s| canonical.starts_with(&s.canonical_root))
72            .ok_or(OrbokError::PathOutsideSources)?;
73
74        // Symlink policy: when the request path itself differs from its
75        // canonical form below the root, a link was traversed.
76        if source.policy.symlink_policy == SymlinkPolicy::Ignore {
77            let requested_inside = requested.starts_with(&source.canonical_root);
78            if requested_inside && requested != canonical {
79                // A symlink inside the source resolved elsewhere (still
80                // inside, or membership above would have failed) — the
81                // Ignore policy does not follow it.
82                if is_symlinked_below(&source.canonical_root, requested)? {
83                    return Err(OrbokError::PolicyBlocked("symlink_policy_blocked"));
84                }
85            }
86        }
87
88        // Hidden-file policy applies to components below the root.
89        if source.policy.hidden_file_policy == HiddenFilePolicy::Exclude
90            && hidden_below_root(&source.canonical_root, &canonical)
91        {
92            return Err(OrbokError::PolicyBlocked("hidden_file_excluded"));
93        }
94
95        // Size limit for files.
96        if let Ok(metadata) = std::fs::metadata(&canonical) {
97            if metadata.is_file() && !source.policy.size_allowed(metadata.len()) {
98                return Err(OrbokError::PolicyBlocked("file_too_large"));
99            }
100        }
101
102        Ok(ValidatedPath {
103            source_id: source.source_id.clone(),
104            canonical,
105        })
106    }
107}
108
109/// True when any component strictly below `root` is hidden (dotted).
110fn hidden_below_root(root: &Path, canonical: &Path) -> bool {
111    let Ok(relative) = canonical.strip_prefix(root) else {
112        return false;
113    };
114    relative.components().any(|c| {
115        c.as_os_str()
116            .to_string_lossy()
117            .starts_with('.')
118    })
119}
120
121/// True when any component of `path` strictly below `root` is a symlink.
122fn is_symlinked_below(root: &Path, path: &Path) -> OrbokResult<bool> {
123    let Ok(relative) = path.strip_prefix(root) else {
124        return Ok(false);
125    };
126    let mut current = root.to_path_buf();
127    for component in relative.components() {
128        current.push(component);
129        let metadata = std::fs::symlink_metadata(&current)?;
130        if metadata.file_type().is_symlink() {
131            return Ok(true);
132        }
133    }
134    Ok(false)
135}