Skip to main content

cuttlefish_host/
caps.rs

1//! What a job is permitted to reach.
2//!
3//! This is the security boundary. The compile-time check in `cuttlefish-core`
4//! exists to give spec authors good error messages; the check *here* is the one
5//! a malicious or malfunctioning block actually runs into, and it fails closed.
6
7use std::path::{Path, PathBuf};
8
9/// The capabilities a spec grants a job.
10///
11/// v1 has exactly one kind, filesystem reads under named roots. An empty set
12/// grants nothing — deny-by-default is the whole posture, so "no capabilities
13/// declared" must never read as "unrestricted".
14#[derive(Debug, Clone, Default)]
15pub struct Capabilities {
16    read_roots: Vec<PathBuf>,
17}
18
19impl Capabilities {
20    /// Grant read access beneath each of `read_roots`.
21    pub fn new(read_roots: Vec<PathBuf>) -> Self {
22        Self { read_roots }
23    }
24
25    /// Whether `path` may be read.
26    ///
27    /// Both sides are canonicalized before comparison, and that is the entire
28    /// substance of this function. The tempting implementation —
29    /// `path.starts_with(root)` on the raw strings — admits two escapes:
30    ///
31    /// - **Traversal.** `/granted/inner/../../secret` has `/granted/inner` as a
32    ///   string prefix while naming a file outside it.
33    /// - **Symlinks.** A path genuinely under the granted root can name a file
34    ///   anywhere on the system.
35    ///
36    /// `canonicalize` resolves `..` and follows symlinks, so the comparison is
37    /// between the real locations rather than the spellings. Comparing with
38    /// `Path::starts_with` rather than string prefixes additionally means
39    /// `/data-secret` is not treated as nested inside `/data`.
40    ///
41    /// A path that cannot be canonicalized — because it does not exist — is
42    /// denied. That is deliberate on two counts: a nonexistent path cannot be
43    /// *proven* inside the grant, and refusing it here means the decision cannot
44    /// be made against a file that only appears afterwards.
45    ///
46    /// Note the residual limitation: this resolves the path once, and the caller
47    /// opens it separately. A sufficiently determined attacker who can swap a
48    /// symlink between those two steps still has a window. Closing it properly
49    /// needs the caller to pass an already-open descriptor; see the transport
50    /// discussion in the `cuttlefishd` crate docs.
51    pub fn allows_read(&self, path: &Path) -> bool {
52        let Ok(target) = path.canonicalize() else {
53            return false;
54        };
55        self.read_roots.iter().any(|root| {
56            root.canonicalize()
57                .map(|root| target.starts_with(root))
58                .unwrap_or(false)
59        })
60    }
61
62    /// The granted read roots, as configured.
63    pub fn read_roots(&self) -> &[PathBuf] {
64        &self.read_roots
65    }
66}