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 /// Why a read was refused, when it was.
96 ///
97 /// [`Self::allows_read`] answers yes-or-no, which is the right shape for
98 /// the decision and the wrong shape for the message. Two very different
99 /// situations reach it as `false`: a path the spec never granted, and a
100 /// granted path that simply is not there. Reporting both as "read not
101 /// permitted" sends somebody to re-read their `capabilities` line when
102 /// what they actually have is a typo in a filename or a manifest listing
103 /// a file that was moved.
104 ///
105 /// Saying "does not exist" is only safe where the job could have found
106 /// out anyway. So it is said only when the nearest *existing* ancestor of
107 /// the path is itself inside a granted root — a directory the job may
108 /// already open and read. For anything else the answer stays the
109 /// undifferentiated refusal, because distinguishing "absent" from
110 /// "forbidden" outside the grant is exactly the probe a capability list
111 /// exists to prevent.
112 pub fn read_denial(&self, path: &Path) -> Option<ReadDenial> {
113 if self.allows_read(path) {
114 return None;
115 }
116
117 // Something is genuinely there — a symlink inside the grant pointing
118 // out of it, say. It was refused because of where it *resolves*, not
119 // because it is absent, and calling that "no such file" would send
120 // the reader looking for a typo in a path that is spelled correctly.
121 // `symlink_metadata` rather than `exists`, which follows the link and
122 // would report the escape as absent whenever the target is.
123 if path.symlink_metadata().is_ok() {
124 return Some(ReadDenial::NotGranted);
125 }
126
127 // The nearest ancestor that exists. `canonicalize` fails on the whole
128 // path when any component is missing, so walk up until it succeeds.
129 let mut ancestor = path.parent();
130 while let Some(dir) = ancestor {
131 if let Ok(real) = dir.canonicalize() {
132 let inside = self.read_roots.iter().any(|root| {
133 root.canonicalize()
134 .map(|root| real.starts_with(root))
135 .unwrap_or(false)
136 });
137 return Some(if inside {
138 ReadDenial::Missing
139 } else {
140 ReadDenial::NotGranted
141 });
142 }
143 ancestor = dir.parent();
144 }
145 Some(ReadDenial::NotGranted)
146 }
147
148 /// The granted read roots, as configured.
149 pub fn read_roots(&self) -> &[PathBuf] {
150 &self.read_roots
151 }
152}
153
154/// Why [`Capabilities::allows_read`] said no.
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub enum ReadDenial {
157 /// The path is not inside any granted root — or is somewhere the job may
158 /// not learn anything about, including whether it exists.
159 NotGranted,
160 /// The path would be readable, but there is nothing there. Only reported
161 /// where the job could have discovered that itself; see
162 /// [`Capabilities::read_denial`].
163 Missing,
164}