1use 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#[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 #[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 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 #[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#[derive(Clone, Debug, Error, Eq, PartialEq)]
93pub enum ComponentIdParseError {
94 #[error("component id must contain 64 hexadecimal characters, got {actual}")]
96 Width {
97 actual: usize,
99 },
100 #[error("component id contains a non-hexadecimal character at byte {index}")]
102 InvalidHex {
103 index: usize,
105 },
106}
107
108#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
110pub struct PathRoot(pub PathBuf);
111
112#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
114pub struct NetworkHost(pub String);
115
116#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
118pub enum CapabilityKind {
119 FsRead,
121 FsWrite,
123 Network,
125 CrossComponentRead,
127}
128
129#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
131pub enum CapabilityScope {
132 Path(PathBuf),
134 Host(String),
136 Component(ComponentId),
138}
139
140#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
142pub enum Capability {
143 FsRead {
145 root: PathRoot,
147 },
148 FsWrite {
150 root: PathRoot,
152 },
153 Network {
155 host: NetworkHost,
157 },
158 CrossComponentRead {
160 target: ComponentId,
162 },
163}
164
165impl Capability {
166 #[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 #[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 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 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
221pub struct CapabilityRequest {
222 pub capability: Capability,
224}
225
226impl CapabilityRequest {
227 #[must_use]
229 pub const fn new(capability: Capability) -> Self {
230 Self { capability }
231 }
232}
233
234#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
236pub struct GrantProvenance {
237 pub granted_by: String,
239 pub granted_at: SystemTime,
241}
242
243#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
245pub struct Grant {
246 pub component_id: ComponentId,
248 pub request: CapabilityRequest,
250 pub provenance: GrantProvenance,
252}
253
254#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
256pub struct CapabilityDenied {
257 pub component_id: ComponentId,
259 pub kind: CapabilityKind,
261 pub scope: CapabilityScope,
263 pub declared: bool,
265}
266
267#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
269pub enum CheckVerdict {
270 Allowed,
272 Denied(CapabilityDenied),
274}
275
276#[derive(Clone, Debug, Error, Eq, PartialEq)]
278pub enum ScopeValidationError {
279 #[error("filesystem scope must be an absolute path")]
281 PathNotAbsolute,
282 #[error("filesystem scope must not contain parent or current-directory segments")]
284 PathTraversal,
285 #[error("network host must not be empty or padded with whitespace")]
287 EmptyHost,
288 #[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}