Skip to main content

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}