haematite-cli 0.21.7

The haematite command: serve a store, its door and Studio, answer MCP, query with jq, and host the store's assistant seat
Documentation
//! Mutation identity and strict wire contracts for a single stored-format cut.

use haematite::tree::Hash;
use serde::{Deserialize, Deserializer, Serialize};

use super::protocol::{Change, Version};
use super::storage::Failure;

pub mod attribution;
pub mod envelopes;

mod receipt;
pub use receipt::Receipt;

pub(super) const FINGERPRINT_FORMAT: u32 = 1;
const FINGERPRINT_DOMAIN: &[u8] = b"haem.mutation.fingerprint\0";

/// An asserted identity, with no stored credential.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "CallerWire")]
pub struct Caller {
    issuer: String,
    subject: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    responsible: Option<String>,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CallerWire {
    issuer: String,
    subject: String,
    responsible: Option<String>,
}

impl Caller {
    /// Construct an asserted caller identity.
    ///
    /// # Errors
    /// Returns `invalid_identifier` when the issuer, subject or responsible person has an invalid identifier.
    pub fn new(
        issuer: impl Into<String>,
        subject: impl Into<String>,
        responsible: Option<String>,
    ) -> Result<Self, Failure> {
        let caller = Self {
            issuer: issuer.into(),
            subject: subject.into(),
            responsible,
        };
        identifier(&caller.issuer)?;
        identifier(&caller.subject)?;
        if let Some(person) = &caller.responsible {
            identifier(person)?;
        }
        Ok(caller)
    }

    /// Construct the identity of a local socket owner.
    ///
    /// # Errors
    /// Returns `invalid_identifier` for an invalid subject.
    pub fn local(subject: impl Into<String>) -> Result<Self, Failure> {
        Self::new("local", subject, None)
    }

    #[must_use]
    pub fn issuer(&self) -> &str {
        &self.issuer
    }

    #[must_use]
    pub fn subject(&self) -> &str {
        &self.subject
    }

    #[must_use]
    pub fn responsible(&self) -> Option<&str> {
        self.responsible.as_deref()
    }
}

impl TryFrom<CallerWire> for Caller {
    type Error = Failure;

    fn try_from(wire: CallerWire) -> Result<Self, Self::Error> {
        Self::new(wire.issuer, wire.subject, wire.responsible)
    }
}

/// Facts read at the contract's origin, including absent rows and interval gaps.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum ReadDependency {
    Document {
        collection: String,
        document_id: String,
    },
    Collection {
        collection: String,
    },
    /// Document ids in a lower-inclusive, upper-exclusive interval; absent bounds
    /// cover the collection boundary. Body and revision rows belong to this read.
    SourceInterval {
        collection: String,
        lower: Option<String>,
        upper: Option<String>,
    },
}

impl ReadDependency {
    fn validate(&self) -> Result<(), Failure> {
        match self {
            Self::Document {
                collection,
                document_id,
            } => {
                identifier(collection)?;
                identifier(document_id)
            }
            Self::Collection { collection } => identifier(collection),
            Self::SourceInterval {
                collection,
                lower,
                upper,
            } => {
                identifier(collection)?;
                for bound in [lower, upper].into_iter().flatten() {
                    if bound.len() > 512 || bound.contains('\0') {
                        return Err(Failure::new(
                            "invalid_dependency",
                            "interval bounds must contain at most 512 UTF-8 bytes and no NUL",
                        ));
                    }
                }
                if matches!((lower, upper), (Some(lower), Some(upper)) if lower > upper) {
                    return Err(Failure::new(
                        "invalid_dependency",
                        "interval lower bound exceeds its exclusive upper bound",
                    ));
                }
                Ok(())
            }
        }
    }
}

/// Exact-version and read-dependency semantics remain distinct.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum Contract {
    ExactVersion {
        expected: Version,
    },
    Dependencies {
        origin: Version,
        reads: Vec<ReadDependency>,
    },
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ContractKind {
    ExactVersion,
    Dependencies,
}

impl Contract {
    #[must_use]
    pub const fn origin(&self) -> Version {
        match self {
            Self::ExactVersion { expected } => *expected,
            Self::Dependencies { origin, .. } => *origin,
        }
    }

    #[must_use]
    pub const fn kind(&self) -> ContractKind {
        match self {
            Self::ExactVersion { .. } => ContractKind::ExactVersion,
            Self::Dependencies { .. } => ContractKind::Dependencies,
        }
    }

    fn validate(&self) -> Result<(), Failure> {
        if let Self::Dependencies { reads, .. } = self {
            for read in reads {
                read.validate()?;
            }
        }
        Ok(())
    }
}

/// An omitted caller is resolved by the trusted socket context, never by storage.
#[derive(Debug, Serialize, Deserialize)]
#[serde(try_from = "MutationWire")]
pub struct MutationRequest {
    request_id: String,
    branch: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    caller: Option<Caller>,
    contract: Contract,
    change: Change,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct MutationWire {
    request_id: String,
    branch: String,
    #[serde(default, deserialize_with = "present_caller")]
    caller: Option<Caller>,
    contract: Contract,
    change: Change,
}

fn present_caller<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<Caller>, D::Error> {
    Caller::deserialize(deserializer).map(Some)
}

impl TryFrom<MutationWire> for MutationRequest {
    type Error = Failure;

    fn try_from(wire: MutationWire) -> Result<Self, Self::Error> {
        identifier(&wire.request_id)?;
        identifier(&wire.branch)?;
        wire.contract.validate()?;
        if let Change::Fork(fork) = &wire.change {
            identifier(&fork.name)?;
            placeable(&fork.name)?;
        }
        Ok(Self {
            request_id: wire.request_id,
            branch: wire.branch,
            caller: wire.caller,
            contract: wire.contract,
            change: wire.change,
        })
    }
}

impl MutationRequest {
    /// Decode and validate a mutation request.
    ///
    /// # Errors
    /// Returns `invalid_mutation` for malformed JSON, unknown members, invalid identifiers, invalid dependencies or an unplaceable fork name.
    pub fn decode(bytes: &[u8]) -> Result<Self, Failure> {
        serde_json::from_slice(bytes)
            .map_err(|error| Failure::new("invalid_mutation", error.to_string()))
    }

    /// Resolve an omitted caller from the trusted socket owner.
    ///
    /// # Errors
    /// Returns `invalid_identifier` if an omitted caller cannot be resolved from the supplied subject.
    pub fn resolve(self, socket_owner_subject: &str) -> Result<ResolvedMutation, Failure> {
        let caller = match self.caller {
            Some(caller) => caller,
            None => Caller::local(socket_owner_subject)?,
        };
        Ok(ResolvedMutation {
            request_id: self.request_id,
            branch: self.branch,
            caller,
            contract: self.contract,
            change: self.change,
        })
    }
}

/// Only a request with a resolved caller can be fingerprinted or receipted.
#[derive(Debug, Serialize)]
pub struct ResolvedMutation {
    request_id: String,
    branch: String,
    caller: Caller,
    contract: Contract,
    change: Change,
}

impl ResolvedMutation {
    #[must_use]
    pub fn request_id(&self) -> &str {
        &self.request_id
    }

    #[must_use]
    pub fn branch(&self) -> &str {
        &self.branch
    }

    #[must_use]
    pub const fn caller(&self) -> &Caller {
        &self.caller
    }

    #[must_use]
    pub const fn contract(&self) -> &Contract {
        &self.contract
    }

    #[must_use]
    pub const fn change(&self) -> &Change {
        &self.change
    }

    #[must_use]
    pub const fn origin(&self) -> Version {
        self.contract.origin()
    }

    /// Hash the versioned canonical request identity.
    ///
    /// # Errors
    /// Returns `storage_unavailable` if serializing the resolved request fails.
    pub fn fingerprint(&self) -> Result<String, Failure> {
        let mut bytes = FINGERPRINT_DOMAIN.to_vec();
        bytes.extend_from_slice(&FINGERPRINT_FORMAT.to_be_bytes());
        serde_json::to_writer(&mut bytes, self).map_err(Failure::storage)?;
        Ok(Hash::of(&bytes).to_string())
    }

    pub(super) fn destination(&self) -> &str {
        match &self.change {
            Change::Fork(fork) => &fork.name,
            _ => &self.branch,
        }
    }
}

/// A new branch is placed in Lys as `haematite.branch:<database>.<name>`, and a
/// Lys resource id holds only ASCII letters, digits, `_`, `-` and `.`. A name
/// outside that could be written but never placed, so no grant could ever
/// reach it: it is refused at the fork, by name.
pub(super) fn placeable(name: &str) -> Result<(), Failure> {
    if name
        .bytes()
        .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
    {
        return Ok(());
    }
    Err(Failure::new(
        "branch_name_unplaceable",
        "a new branch name may hold only ASCII letters, digits, `_`, `-` and `.`, so Lys can place it",
    ))
}

pub(super) fn identifier(value: &str) -> Result<(), Failure> {
    if value.is_empty() || value.len() > 512 || value.contains('\0') {
        return Err(Failure::new(
            "invalid_identifier",
            "names must contain 1 to 512 UTF-8 bytes and no NUL",
        ));
    }
    Ok(())
}

#[cfg(test)]
#[path = "contracts/placeable_tests.rs"]
mod placeable_tests;