capability_core/vocabulary.rs
1//! Capability vocabulary types: `alloc`/`io`/`ptr` levels and the combined
2//! [`CapabilitySet`].
3//!
4//! Extracted from `capability-attr`'s own `parser.rs` (see
5//! `docs/adr/ADR-0005-generate-and-refactor.md` for why) — the parsing of
6//! `#[capability(...)]`'s attribute-argument *syntax* into these types
7//! stays in `capability-attr` itself (it's specific to that macro's surface
8//! syntax); only the vocabulary and the risk-ordering it encodes lives
9//! here, since `taint-generate` needs to construct a [`CapabilitySet`] from
10//! real body-usage detection without going through any attribute-parsing
11//! at all.
12//!
13//! # Vocabulary — reduced from the RFC, grounded in `git.git`'s needs
14//!
15//! `docs/aisecurity/capability-rfc-updated.md` proposes five categories:
16//! `alloc`, `io`, `register`, `ptr`, `interrupt`. This crate implements
17//! three of them:
18//!
19//! - [`AllocLevel`] — kept, but collapsed from the RFC's six embedded-
20//! specific tiers (`none`/`static`/`bump`/`pool`/`global`/`any` — `bump`
21//! and `pool` describe allocator strategies with no equivalent in
22//! userspace Rust, which always uses the global allocator) down to three:
23//! [`AllocLevel::None`], [`AllocLevel::Heap`], [`AllocLevel::Any`].
24//! - [`IoLevel`] — kept, but reshaped: the RFC's `spi`/`i2c`/`uart`/`dma`
25//! tiers describe hardware buses `git.git` never touches, so they're
26//! dropped. In their place, [`IoLevel::Process`] is *added* — the RFC has
27//! no equivalent, but subprocess spawning (credential helpers, pagers,
28//! diff/merge tools, hooks) is one of `git.git`'s largest and most
29//! security-relevant real capability dimensions (see the module doc's
30//! risk-ordering note below).
31//! - [`PtrLevel`] — kept close to the RFC's own `ptr(write, bounded)` /
32//! `ptr(write, any)` shape (same `#[capability(ptr(write, bounded))]`
33//! surface syntax), since raw-pointer capability is exactly what matters
34//! at an FFI boundary.
35//! - `register(...)` and `interrupt(...)` — **dropped entirely**, not
36//! stubbed. Both describe hardware register/interrupt-controller access
37//! that has no meaning for a userspace CLI tool; a stub type with no real
38//! enforcement behind it would be worse than not shipping the category at
39//! all (dead API surface implying a guarantee this crate doesn't provide).
40//!
41//! # Risk ordering — `io(process)` ranked above `io(network)`
42//!
43//! The RFC orders IO risk as `display < uart < filesystem < network < dma`.
44//! This crate's reordering (`none < display < filesystem < network <
45//! process < any`) is a deliberate departure, not an oversight: arbitrary
46//! local subprocess execution is effectively arbitrary code execution, and
47//! `git.git` has a real history of command-injection classes of bugs
48//! reaching exactly this surface (credential-helper invocation, submodule
49//! hook/URL handling). Ranking it above `network` reflects that a
50//! compromised subprocess capability is a strictly larger blast radius than
51//! an outbound network connection in this specific target's threat model.
52
53/// Allocation-capability tier, from least to most risk.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum AllocLevel {
56 /// No allocation of any kind — stack/static only.
57 None,
58 /// The global heap allocator (`Vec`, `Box`, `String`, `HashMap`, ...).
59 Heap,
60 /// Any allocation strategy, including custom allocators.
61 Any,
62}
63
64impl AllocLevel {
65 /// A total ordering over risk — higher means riskier / less restricted.
66 #[must_use]
67 pub const fn risk_level(self) -> u8 {
68 match self {
69 Self::None => 0,
70 Self::Heap => 1,
71 Self::Any => 2,
72 }
73 }
74}
75
76/// I/O-capability tier, from least to most risk. See this module's doc
77/// comment for why `Process` is ranked above `Network` in this crate.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum IoLevel {
80 /// Pure computation — no I/O of any kind.
81 None,
82 /// Write-only console/log output (`println!`, `eprintln!`, `write!`).
83 Display,
84 /// Filesystem read/write (repo objects, refs, config, `.git/` files).
85 Filesystem,
86 /// Outbound network I/O (fetch/push transports).
87 Network,
88 /// Subprocess spawning (`std::process::Command`) — credential helpers,
89 /// hooks, pagers, diff/merge tools. See module doc for why this ranks
90 /// above `Network` in this crate's ordering.
91 Process,
92 /// Unrestricted I/O.
93 Any,
94}
95
96impl IoLevel {
97 /// A total ordering over risk — higher means riskier / less restricted.
98 #[must_use]
99 pub const fn risk_level(self) -> u8 {
100 match self {
101 Self::None => 0,
102 Self::Display => 1,
103 Self::Filesystem => 2,
104 Self::Network => 3,
105 Self::Process => 4,
106 Self::Any => 5,
107 }
108 }
109}
110
111/// Whether a detected/declared raw-pointer write is provably within a
112/// statically declared bound.
113///
114/// Phase 1 (no PAC-style address verification) can never *prove* `Bounded`
115/// from body inspection alone — any detected raw write is conservatively
116/// classified `Any` (see [`crate::inspector`]). `Bounded` exists in the
117/// vocabulary so a function can still *declare* it once a future phase can
118/// verify it.
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub enum PtrBound {
121 /// Write is within a statically declared/verified bound.
122 Bounded,
123 /// Write is unbounded/unverified.
124 Any,
125}
126
127/// Raw-pointer-capability tier, from least to most risk.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum PtrLevel {
130 /// No raw pointer operations.
131 None,
132 /// Raw pointer reads only.
133 Read,
134 /// Raw pointer writes, bounded or unbounded per [`PtrBound`].
135 Write(PtrBound),
136 /// All raw pointer operations.
137 Any,
138}
139
140impl PtrLevel {
141 /// A total ordering over risk — higher means riskier / less restricted.
142 #[must_use]
143 pub const fn risk_level(self) -> u8 {
144 match self {
145 Self::None => 0,
146 Self::Read => 1,
147 Self::Write(PtrBound::Bounded) => 2,
148 Self::Write(PtrBound::Any) => 3,
149 Self::Any => 4,
150 }
151 }
152}
153
154/// The full set of capabilities a function/module may declare or exhibit.
155///
156/// A category left `None` means "not declared" —
157/// [`CapabilitySet::alloc_or_none`] and friends treat an undeclared
158/// category as the most restrictive level, matching the RFC's "undeclared
159/// = not permitted" model.
160#[derive(Debug, Clone, Default)]
161pub struct CapabilitySet {
162 /// Declared/detected allocation capability, if any.
163 pub alloc: Option<AllocLevel>,
164 /// Declared/detected I/O capability, if any.
165 pub io: Option<IoLevel>,
166 /// Declared/detected raw-pointer capability, if any.
167 pub ptr: Option<PtrLevel>,
168}
169
170impl CapabilitySet {
171 /// The declared/detected [`AllocLevel`], defaulting to [`AllocLevel::None`].
172 #[must_use]
173 pub fn alloc_or_none(&self) -> AllocLevel {
174 self.alloc.unwrap_or(AllocLevel::None)
175 }
176
177 /// The declared/detected [`IoLevel`], defaulting to [`IoLevel::None`].
178 #[must_use]
179 pub fn io_or_none(&self) -> IoLevel {
180 self.io.unwrap_or(IoLevel::None)
181 }
182
183 /// The declared/detected [`PtrLevel`], defaulting to [`PtrLevel::None`].
184 #[must_use]
185 pub fn ptr_or_none(&self) -> PtrLevel {
186 self.ptr.unwrap_or(PtrLevel::None)
187 }
188
189 /// Merge `other` into `self`, keeping the higher-risk level per
190 /// category. Used by the body inspector to accumulate the maximum
191 /// capability observed across an entire function body.
192 pub(crate) fn merge_max(&mut self, other: &Self) {
193 if let Some(o) = other.alloc {
194 if o.risk_level() > self.alloc_or_none().risk_level() {
195 self.alloc = Some(o);
196 }
197 }
198 if let Some(o) = other.io {
199 if o.risk_level() > self.io_or_none().risk_level() {
200 self.io = Some(o);
201 }
202 }
203 if let Some(o) = other.ptr {
204 if o.risk_level() > self.ptr_or_none().risk_level() {
205 self.ptr = Some(o);
206 }
207 }
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 #[test]
216 fn risk_levels_are_strictly_ordered() {
217 assert!(AllocLevel::None.risk_level() < AllocLevel::Heap.risk_level());
218 assert!(AllocLevel::Heap.risk_level() < AllocLevel::Any.risk_level());
219 assert!(IoLevel::Display.risk_level() < IoLevel::Filesystem.risk_level());
220 assert!(IoLevel::Network.risk_level() < IoLevel::Process.risk_level());
221 assert!(IoLevel::Process.risk_level() < IoLevel::Any.risk_level());
222 assert!(PtrLevel::Read.risk_level() < PtrLevel::Write(PtrBound::Bounded).risk_level());
223 assert!(
224 PtrLevel::Write(PtrBound::Bounded).risk_level()
225 < PtrLevel::Write(PtrBound::Any).risk_level()
226 );
227 }
228
229 #[test]
230 fn merge_max_keeps_the_higher_risk_level() {
231 let mut set = CapabilitySet {
232 alloc: Some(AllocLevel::None),
233 io: Some(IoLevel::Display),
234 ptr: None,
235 };
236 set.merge_max(&CapabilitySet {
237 alloc: Some(AllocLevel::Heap),
238 io: Some(IoLevel::None),
239 ptr: Some(PtrLevel::Read),
240 });
241 assert_eq!(set.alloc_or_none(), AllocLevel::Heap);
242 assert_eq!(set.io_or_none(), IoLevel::Display);
243 assert_eq!(set.ptr_or_none(), PtrLevel::Read);
244 }
245}