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 fetch_prefixes: Vec<String>,
18}
19
20impl Capabilities {
21 /// Grant read access beneath each of `read_roots`, and nothing else.
22 pub fn new(read_roots: Vec<PathBuf>) -> Self {
23 Self {
24 read_roots,
25 fetch_prefixes: Vec::new(),
26 }
27 }
28
29 /// Also grant fetching any URL beginning with one of `fetch_prefixes`.
30 pub fn with_fetch(mut self, fetch_prefixes: Vec<String>) -> Self {
31 self.fetch_prefixes = fetch_prefixes;
32 self
33 }
34
35 /// Whether `url` may be fetched.
36 ///
37 /// Prefix matching on the URL as written, deliberately: it is the rule a
38 /// spec author can predict from reading their own capability line. No
39 /// normalisation, no host-only matching — `Fetch "https://x.org/docs/"`
40 /// grants that subtree and not `https://x.org/other`, and not
41 /// `http://` either, since the scheme is part of the prefix.
42 ///
43 /// The one thing checked beyond the prefix is that the URL cannot climb
44 /// out with `..`, which would otherwise let a granted prefix reach
45 /// anywhere on the host.
46 pub fn allows_fetch(&self, url: &str) -> bool {
47 if url.contains("..") {
48 return false;
49 }
50 self.fetch_prefixes.iter().any(|p| url.starts_with(p))
51 }
52
53 /// The granted fetch prefixes, as configured.
54 pub fn fetch_prefixes(&self) -> &[String] {
55 &self.fetch_prefixes
56 }
57
58 /// Whether `path` may be read.
59 ///
60 /// Both sides are canonicalized before comparison, and that is the entire
61 /// substance of this function. The tempting implementation —
62 /// `path.starts_with(root)` on the raw strings — admits two escapes:
63 ///
64 /// - **Traversal.** `/granted/inner/../../secret` has `/granted/inner` as a
65 /// string prefix while naming a file outside it.
66 /// - **Symlinks.** A path genuinely under the granted root can name a file
67 /// anywhere on the system.
68 ///
69 /// `canonicalize` resolves `..` and follows symlinks, so the comparison is
70 /// between the real locations rather than the spellings. Comparing with
71 /// `Path::starts_with` rather than string prefixes additionally means
72 /// `/data-secret` is not treated as nested inside `/data`.
73 ///
74 /// A path that cannot be canonicalized — because it does not exist — is
75 /// denied. That is deliberate on two counts: a nonexistent path cannot be
76 /// *proven* inside the grant, and refusing it here means the decision cannot
77 /// be made against a file that only appears afterwards.
78 ///
79 /// Note the residual limitation: this resolves the path once, and the caller
80 /// opens it separately. A sufficiently determined attacker who can swap a
81 /// symlink between those two steps still has a window. Closing it properly
82 /// needs the caller to pass an already-open descriptor; see the transport
83 /// discussion in the `cuttlefishd` crate docs.
84 pub fn allows_read(&self, path: &Path) -> bool {
85 let Ok(target) = path.canonicalize() else {
86 return false;
87 };
88 self.read_roots.iter().any(|root| {
89 root.canonicalize()
90 .map(|root| target.starts_with(root))
91 .unwrap_or(false)
92 })
93 }
94
95 /// The granted read roots, as configured.
96 pub fn read_roots(&self) -> &[PathBuf] {
97 &self.read_roots
98 }
99}