use std::fmt;
use std::path::{Component, Path, PathBuf};
use std::str::FromStr;
use std::time::SystemTime;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct ComponentId([u8; 32]);
impl ComponentId {
#[must_use]
pub fn derive(publisher_namespace: &str, component_name: &str) -> Self {
let mut hasher = blake3::Hasher::new();
hasher.update(b"frame/component-id/v1");
hash_region(&mut hasher, publisher_namespace.as_bytes());
hash_region(&mut hasher, component_name.as_bytes());
Self(*hasher.finalize().as_bytes())
}
pub fn parse_hex(value: &str) -> Result<Self, ComponentIdParseError> {
if value.len() != 64 {
return Err(ComponentIdParseError::Width {
actual: value.len(),
});
}
let mut bytes = [0_u8; 32];
for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
let high = decode_hex(pair[0])
.ok_or(ComponentIdParseError::InvalidHex { index: index * 2 })?;
let low = decode_hex(pair[1]).ok_or(ComponentIdParseError::InvalidHex {
index: index * 2 + 1,
})?;
bytes[index] = (high << 4) | low;
}
Ok(Self(bytes))
}
#[must_use]
pub const fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
}
impl fmt::Display for ComponentId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
for byte in self.0 {
write!(formatter, "{byte:02x}")?;
}
Ok(())
}
}
impl FromStr for ComponentId {
type Err = ComponentIdParseError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::parse_hex(value)
}
}
impl From<ComponentId> for String {
fn from(value: ComponentId) -> Self {
value.to_string()
}
}
impl TryFrom<String> for ComponentId {
type Error = ComponentIdParseError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::parse_hex(&value)
}
}
#[derive(Clone, Debug, Error, Eq, PartialEq)]
pub enum ComponentIdParseError {
#[error("component id must contain 64 hexadecimal characters, got {actual}")]
Width {
actual: usize,
},
#[error("component id contains a non-hexadecimal character at byte {index}")]
InvalidHex {
index: usize,
},
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
pub struct PathRoot(pub PathBuf);
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
pub struct NetworkHost(pub String);
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
pub enum CapabilityKind {
FsRead,
FsWrite,
Network,
CrossComponentRead,
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
pub enum CapabilityScope {
Path(PathBuf),
Host(String),
Component(ComponentId),
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
pub enum Capability {
FsRead {
root: PathRoot,
},
FsWrite {
root: PathRoot,
},
Network {
host: NetworkHost,
},
CrossComponentRead {
target: ComponentId,
},
}
impl Capability {
#[must_use]
pub const fn kind(&self) -> CapabilityKind {
match self {
Self::FsRead { .. } => CapabilityKind::FsRead,
Self::FsWrite { .. } => CapabilityKind::FsWrite,
Self::Network { .. } => CapabilityKind::Network,
Self::CrossComponentRead { .. } => CapabilityKind::CrossComponentRead,
}
}
#[must_use]
pub fn scope(&self) -> CapabilityScope {
match self {
Self::FsRead { root } | Self::FsWrite { root } => CapabilityScope::Path(root.0.clone()),
Self::Network { host } => CapabilityScope::Host(host.0.clone()),
Self::CrossComponentRead { target } => CapabilityScope::Component(*target),
}
}
pub fn validate(&self) -> Result<(), ScopeValidationError> {
match self {
Self::FsRead { root } | Self::FsWrite { root } => validate_path(&root.0),
Self::Network { host } => validate_host(&host.0),
Self::CrossComponentRead { .. } => Ok(()),
}
}
#[must_use]
pub fn covers(&self, consumed: &Self) -> bool {
match (self, consumed) {
(Self::FsRead { root }, Self::FsRead { root: path })
| (Self::FsWrite { root }, Self::FsWrite { root: path }) => path.0.starts_with(&root.0),
(Self::Network { host }, Self::Network { host: requested }) => host == requested,
(
Self::CrossComponentRead { target },
Self::CrossComponentRead { target: requested },
) => target == requested,
_ => false,
}
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
pub struct CapabilityRequest {
pub capability: Capability,
}
impl CapabilityRequest {
#[must_use]
pub const fn new(capability: Capability) -> Self {
Self { capability }
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct GrantProvenance {
pub granted_by: String,
pub granted_at: SystemTime,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct Grant {
pub component_id: ComponentId,
pub request: CapabilityRequest,
pub provenance: GrantProvenance,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct CapabilityDenied {
pub component_id: ComponentId,
pub kind: CapabilityKind,
pub scope: CapabilityScope,
pub declared: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum CheckVerdict {
Allowed,
Denied(CapabilityDenied),
}
#[derive(Clone, Debug, Error, Eq, PartialEq)]
pub enum ScopeValidationError {
#[error("filesystem scope must be an absolute path")]
PathNotAbsolute,
#[error("filesystem scope must not contain parent or current-directory segments")]
PathTraversal,
#[error("network host must not be empty or padded with whitespace")]
EmptyHost,
#[error("network host contains URL, path, or pattern syntax")]
HostSyntax,
}
fn validate_path(path: &Path) -> Result<(), ScopeValidationError> {
if !path.is_absolute() {
return Err(ScopeValidationError::PathNotAbsolute);
}
if path
.components()
.any(|part| matches!(part, Component::ParentDir | Component::CurDir))
{
return Err(ScopeValidationError::PathTraversal);
}
Ok(())
}
fn validate_host(host: &str) -> Result<(), ScopeValidationError> {
if host.is_empty() || host.trim() != host {
return Err(ScopeValidationError::EmptyHost);
}
if host
.chars()
.any(|character| character.is_whitespace() || matches!(character, '/' | '\\' | '*' | '?'))
{
return Err(ScopeValidationError::HostSyntax);
}
Ok(())
}
fn hash_region(hasher: &mut blake3::Hasher, bytes: &[u8]) {
hasher.update(&(bytes.len() as u64).to_be_bytes());
hasher.update(bytes);
}
const fn decode_hex(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}