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}