Skip to main content

frame_capability/
lib.rs

1//! Enforcer-agnostic declarations and verdicts for host-mediated authority.
2//!
3//! This crate deliberately has no runtime dependency. Native and future WASM
4//! enforcers share these values; neither gains mutation authority by using them.
5
6use std::fmt;
7use std::path::{Component, Path, PathBuf};
8use std::str::FromStr;
9use std::time::SystemTime;
10
11use serde::{Deserialize, Serialize};
12use thiserror::Error;
13
14/// Stable, fixed-width identity for a component and its durable namespace.
15#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
16#[serde(try_from = "String", into = "String")]
17pub struct ComponentId([u8; 32]);
18
19impl ComponentId {
20    /// Derives an identity from exact publisher/namespace and component name text.
21    #[must_use]
22    pub fn derive(publisher_namespace: &str, component_name: &str) -> Self {
23        let mut hasher = blake3::Hasher::new();
24        hasher.update(b"frame/component-id/v1");
25        hash_region(&mut hasher, publisher_namespace.as_bytes());
26        hash_region(&mut hasher, component_name.as_bytes());
27        Self(*hasher.finalize().as_bytes())
28    }
29
30    /// Parses exactly 64 hexadecimal characters into a component identity.
31    ///
32    /// # Errors
33    ///
34    /// Returns a typed parse error for the wrong width or a non-hexadecimal byte.
35    pub fn parse_hex(value: &str) -> Result<Self, ComponentIdParseError> {
36        if value.len() != 64 {
37            return Err(ComponentIdParseError::Width {
38                actual: value.len(),
39            });
40        }
41        let mut bytes = [0_u8; 32];
42        for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
43            let high = decode_hex(pair[0])
44                .ok_or(ComponentIdParseError::InvalidHex { index: index * 2 })?;
45            let low = decode_hex(pair[1]).ok_or(ComponentIdParseError::InvalidHex {
46                index: index * 2 + 1,
47            })?;
48            bytes[index] = (high << 4) | low;
49        }
50        Ok(Self(bytes))
51    }
52
53    /// Returns the fixed-width identity bytes used by storage key regions.
54    #[must_use]
55    pub const fn as_bytes(&self) -> &[u8; 32] {
56        &self.0
57    }
58}
59
60impl fmt::Display for ComponentId {
61    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
62        for byte in self.0 {
63            write!(formatter, "{byte:02x}")?;
64        }
65        Ok(())
66    }
67}
68
69impl FromStr for ComponentId {
70    type Err = ComponentIdParseError;
71
72    fn from_str(value: &str) -> Result<Self, Self::Err> {
73        Self::parse_hex(value)
74    }
75}
76
77impl From<ComponentId> for String {
78    fn from(value: ComponentId) -> Self {
79        value.to_string()
80    }
81}
82
83impl TryFrom<String> for ComponentId {
84    type Error = ComponentIdParseError;
85
86    fn try_from(value: String) -> Result<Self, Self::Error> {
87        Self::parse_hex(&value)
88    }
89}
90
91/// Why a textual component identity could not be constructed.
92#[derive(Clone, Debug, Error, Eq, PartialEq)]
93pub enum ComponentIdParseError {
94    /// The display form was not the required 64 ASCII bytes.
95    #[error("component id must contain 64 hexadecimal characters, got {actual}")]
96    Width {
97        /// Width supplied by the caller.
98        actual: usize,
99    },
100    /// A byte at the named display position was not hexadecimal.
101    #[error("component id contains a non-hexadecimal character at byte {index}")]
102    InvalidHex {
103        /// Zero-based byte position in the display string.
104        index: usize,
105    },
106}
107
108/// An absolute, lexical filesystem root used by read and write authority.
109#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
110pub struct PathRoot(pub PathBuf);
111
112/// An exact network host. It is never interpreted as a pattern.
113#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
114pub struct NetworkHost(pub String);
115
116/// The closed set of host-mediated authority kinds in v1.
117#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
118pub enum CapabilityKind {
119    /// Read beneath a declared filesystem root.
120    FsRead,
121    /// Write beneath a declared filesystem root.
122    FsWrite,
123    /// Communicate with one exact host.
124    Network,
125    /// Resolve a read-only view of one exact component.
126    CrossComponentRead,
127}
128
129/// A typed scope carried by a verdict for inspection without string parsing.
130#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
131pub enum CapabilityScope {
132    /// A filesystem path root or requested path.
133    Path(PathBuf),
134    /// One exact network host.
135    Host(String),
136    /// One exact component identity.
137    Component(ComponentId),
138}
139
140/// One kind paired with its only valid scope type.
141#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
142pub enum Capability {
143    /// Filesystem read authority rooted at `root`.
144    FsRead {
145        /// Absolute lexical root, or path being consumed during a check.
146        root: PathRoot,
147    },
148    /// Filesystem write authority rooted at `root`.
149    FsWrite {
150        /// Absolute lexical root, or path being consumed during a check.
151        root: PathRoot,
152    },
153    /// Network authority for exactly `host`.
154    Network {
155        /// Exact host; no glob or pattern syntax is recognized.
156        host: NetworkHost,
157    },
158    /// Read authority for exactly one foreign component.
159    CrossComponentRead {
160        /// Component whose ephemeral read-only view may be resolved.
161        target: ComponentId,
162    },
163}
164
165impl Capability {
166    /// Returns the closed kind represented by this scoped capability.
167    #[must_use]
168    pub const fn kind(&self) -> CapabilityKind {
169        match self {
170            Self::FsRead { .. } => CapabilityKind::FsRead,
171            Self::FsWrite { .. } => CapabilityKind::FsWrite,
172            Self::Network { .. } => CapabilityKind::Network,
173            Self::CrossComponentRead { .. } => CapabilityKind::CrossComponentRead,
174        }
175    }
176
177    /// Returns a typed copy of this capability's scope.
178    #[must_use]
179    pub fn scope(&self) -> CapabilityScope {
180        match self {
181            Self::FsRead { root } | Self::FsWrite { root } => CapabilityScope::Path(root.0.clone()),
182            Self::Network { host } => CapabilityScope::Host(host.0.clone()),
183            Self::CrossComponentRead { target } => CapabilityScope::Component(*target),
184        }
185    }
186
187    /// Validates that this scope can be safely matched by the v1 exact rules.
188    ///
189    /// # Errors
190    ///
191    /// Returns why a path root or exact host is malformed.
192    pub fn validate(&self) -> Result<(), ScopeValidationError> {
193        match self {
194            Self::FsRead { root } | Self::FsWrite { root } => validate_path(&root.0),
195            Self::Network { host } => validate_host(&host.0),
196            Self::CrossComponentRead { .. } => Ok(()),
197        }
198    }
199
200    /// Reports whether this declaration/grant covers one consuming act.
201    ///
202    /// Filesystem roots use lexical path containment. Network and component
203    /// scopes use exact equality; there is no glob or pattern interpretation.
204    #[must_use]
205    pub fn covers(&self, consumed: &Self) -> bool {
206        match (self, consumed) {
207            (Self::FsRead { root }, Self::FsRead { root: path })
208            | (Self::FsWrite { root }, Self::FsWrite { root: path }) => path.0.starts_with(&root.0),
209            (Self::Network { host }, Self::Network { host: requested }) => host == requested,
210            (
211                Self::CrossComponentRead { target },
212                Self::CrossComponentRead { target: requested },
213            ) => target == requested,
214            _ => false,
215        }
216    }
217}
218
219/// A manifest declaration of authority a component may be granted.
220#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
221pub struct CapabilityRequest {
222    /// Kind and typed scope being declared.
223    pub capability: Capability,
224}
225
226impl CapabilityRequest {
227    /// Creates a declaration from a scoped capability.
228    #[must_use]
229    pub const fn new(capability: Capability) -> Self {
230        Self { capability }
231    }
232}
233
234/// Host-supplied audit provenance retained on an active grant row.
235#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
236pub struct GrantProvenance {
237    /// Identity or composition source that applied the grant.
238    pub granted_by: String,
239    /// Host time supplied by the composition layer when it applied the grant.
240    pub granted_at: SystemTime,
241}
242
243/// One active row in the host grant table.
244#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
245pub struct Grant {
246    /// Component receiving authority.
247    pub component_id: ComponentId,
248    /// Exact manifest declaration covered by this row.
249    pub request: CapabilityRequest,
250    /// Who applied the row and when.
251    pub provenance: GrantProvenance,
252}
253
254/// A typed capability denial returned at the consuming act.
255#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
256pub struct CapabilityDenied {
257    /// Component whose operation was refused.
258    pub component_id: ComponentId,
259    /// Closed capability kind requested by the operation.
260    pub kind: CapabilityKind,
261    /// Exact scope requested by the operation.
262    pub scope: CapabilityScope,
263    /// Whether any manifest declaration covered the requested scope.
264    pub declared: bool,
265}
266
267/// Fresh result of checking one consuming act.
268#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
269pub enum CheckVerdict {
270    /// An active grant covered the act at check time.
271    Allowed,
272    /// No active grant covered the act at check time.
273    Denied(CapabilityDenied),
274}
275
276/// Why a manifest scope is not well formed for v1 matching.
277#[derive(Clone, Debug, Error, Eq, PartialEq)]
278pub enum ScopeValidationError {
279    /// Filesystem roots and requested paths must be absolute.
280    #[error("filesystem scope must be an absolute path")]
281    PathNotAbsolute,
282    /// Lexical parent/current segments would make containment ambiguous.
283    #[error("filesystem scope must not contain parent or current-directory segments")]
284    PathTraversal,
285    /// A host must contain non-whitespace text.
286    #[error("network host must not be empty or padded with whitespace")]
287    EmptyHost,
288    /// Hosts are exact values, not URLs, paths, or pattern expressions.
289    #[error("network host contains URL, path, or pattern syntax")]
290    HostSyntax,
291}
292
293fn validate_path(path: &Path) -> Result<(), ScopeValidationError> {
294    if !path.is_absolute() {
295        return Err(ScopeValidationError::PathNotAbsolute);
296    }
297    if path
298        .components()
299        .any(|part| matches!(part, Component::ParentDir | Component::CurDir))
300    {
301        return Err(ScopeValidationError::PathTraversal);
302    }
303    Ok(())
304}
305
306fn validate_host(host: &str) -> Result<(), ScopeValidationError> {
307    if host.is_empty() || host.trim() != host {
308        return Err(ScopeValidationError::EmptyHost);
309    }
310    if host
311        .chars()
312        .any(|character| character.is_whitespace() || matches!(character, '/' | '\\' | '*' | '?'))
313    {
314        return Err(ScopeValidationError::HostSyntax);
315    }
316    Ok(())
317}
318
319fn hash_region(hasher: &mut blake3::Hasher, bytes: &[u8]) {
320    hasher.update(&(bytes.len() as u64).to_be_bytes());
321    hasher.update(bytes);
322}
323
324const fn decode_hex(byte: u8) -> Option<u8> {
325    match byte {
326        b'0'..=b'9' => Some(byte - b'0'),
327        b'a'..=b'f' => Some(byte - b'a' + 10),
328        b'A'..=b'F' => Some(byte - b'A' + 10),
329        _ => None,
330    }
331}