frame-capability 0.1.0

Enforcer-agnostic capability contract for Frame
Documentation
//! Enforcer-agnostic declarations and verdicts for host-mediated authority.
//!
//! This crate deliberately has no runtime dependency. Native and future WASM
//! enforcers share these values; neither gains mutation authority by using them.

use std::fmt;
use std::path::{Component, Path, PathBuf};
use std::str::FromStr;
use std::time::SystemTime;

use serde::{Deserialize, Serialize};
use thiserror::Error;

/// Stable, fixed-width identity for a component and its durable namespace.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct ComponentId([u8; 32]);

impl ComponentId {
    /// Derives an identity from exact publisher/namespace and component name text.
    #[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())
    }

    /// Parses exactly 64 hexadecimal characters into a component identity.
    ///
    /// # Errors
    ///
    /// Returns a typed parse error for the wrong width or a non-hexadecimal byte.
    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))
    }

    /// Returns the fixed-width identity bytes used by storage key regions.
    #[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)
    }
}

/// Why a textual component identity could not be constructed.
#[derive(Clone, Debug, Error, Eq, PartialEq)]
pub enum ComponentIdParseError {
    /// The display form was not the required 64 ASCII bytes.
    #[error("component id must contain 64 hexadecimal characters, got {actual}")]
    Width {
        /// Width supplied by the caller.
        actual: usize,
    },
    /// A byte at the named display position was not hexadecimal.
    #[error("component id contains a non-hexadecimal character at byte {index}")]
    InvalidHex {
        /// Zero-based byte position in the display string.
        index: usize,
    },
}

/// An absolute, lexical filesystem root used by read and write authority.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
pub struct PathRoot(pub PathBuf);

/// An exact network host. It is never interpreted as a pattern.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
pub struct NetworkHost(pub String);

/// The closed set of host-mediated authority kinds in v1.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
pub enum CapabilityKind {
    /// Read beneath a declared filesystem root.
    FsRead,
    /// Write beneath a declared filesystem root.
    FsWrite,
    /// Communicate with one exact host.
    Network,
    /// Resolve a read-only view of one exact component.
    CrossComponentRead,
}

/// A typed scope carried by a verdict for inspection without string parsing.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
pub enum CapabilityScope {
    /// A filesystem path root or requested path.
    Path(PathBuf),
    /// One exact network host.
    Host(String),
    /// One exact component identity.
    Component(ComponentId),
}

/// One kind paired with its only valid scope type.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
pub enum Capability {
    /// Filesystem read authority rooted at `root`.
    FsRead {
        /// Absolute lexical root, or path being consumed during a check.
        root: PathRoot,
    },
    /// Filesystem write authority rooted at `root`.
    FsWrite {
        /// Absolute lexical root, or path being consumed during a check.
        root: PathRoot,
    },
    /// Network authority for exactly `host`.
    Network {
        /// Exact host; no glob or pattern syntax is recognized.
        host: NetworkHost,
    },
    /// Read authority for exactly one foreign component.
    CrossComponentRead {
        /// Component whose ephemeral read-only view may be resolved.
        target: ComponentId,
    },
}

impl Capability {
    /// Returns the closed kind represented by this scoped 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,
        }
    }

    /// Returns a typed copy of this capability's scope.
    #[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),
        }
    }

    /// Validates that this scope can be safely matched by the v1 exact rules.
    ///
    /// # Errors
    ///
    /// Returns why a path root or exact host is malformed.
    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(()),
        }
    }

    /// Reports whether this declaration/grant covers one consuming act.
    ///
    /// Filesystem roots use lexical path containment. Network and component
    /// scopes use exact equality; there is no glob or pattern interpretation.
    #[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,
        }
    }
}

/// A manifest declaration of authority a component may be granted.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
pub struct CapabilityRequest {
    /// Kind and typed scope being declared.
    pub capability: Capability,
}

impl CapabilityRequest {
    /// Creates a declaration from a scoped capability.
    #[must_use]
    pub const fn new(capability: Capability) -> Self {
        Self { capability }
    }
}

/// Host-supplied audit provenance retained on an active grant row.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct GrantProvenance {
    /// Identity or composition source that applied the grant.
    pub granted_by: String,
    /// Host time supplied by the composition layer when it applied the grant.
    pub granted_at: SystemTime,
}

/// One active row in the host grant table.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct Grant {
    /// Component receiving authority.
    pub component_id: ComponentId,
    /// Exact manifest declaration covered by this row.
    pub request: CapabilityRequest,
    /// Who applied the row and when.
    pub provenance: GrantProvenance,
}

/// A typed capability denial returned at the consuming act.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct CapabilityDenied {
    /// Component whose operation was refused.
    pub component_id: ComponentId,
    /// Closed capability kind requested by the operation.
    pub kind: CapabilityKind,
    /// Exact scope requested by the operation.
    pub scope: CapabilityScope,
    /// Whether any manifest declaration covered the requested scope.
    pub declared: bool,
}

/// Fresh result of checking one consuming act.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum CheckVerdict {
    /// An active grant covered the act at check time.
    Allowed,
    /// No active grant covered the act at check time.
    Denied(CapabilityDenied),
}

/// Why a manifest scope is not well formed for v1 matching.
#[derive(Clone, Debug, Error, Eq, PartialEq)]
pub enum ScopeValidationError {
    /// Filesystem roots and requested paths must be absolute.
    #[error("filesystem scope must be an absolute path")]
    PathNotAbsolute,
    /// Lexical parent/current segments would make containment ambiguous.
    #[error("filesystem scope must not contain parent or current-directory segments")]
    PathTraversal,
    /// A host must contain non-whitespace text.
    #[error("network host must not be empty or padded with whitespace")]
    EmptyHost,
    /// Hosts are exact values, not URLs, paths, or pattern expressions.
    #[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,
    }
}