chio-listing 0.1.2

Chio generic listing and trust-activation contracts
Documentation
use chio_core_types::canonical::{canonical_json_bytes, canonical_json_bytes_from_str};
use chio_core_types::capability::scope::MonetaryAmount;
use chio_core_types::crypto::{sha256_hex, PublicKey};
use serde::de::DeserializeOwned;
use serde::Serialize;

mod contract;
mod delivery;
mod evidence;
mod predicate;
mod verdict;

pub use contract::*;
pub use delivery::*;
pub use evidence::*;
pub use predicate::*;
pub use verdict::*;

pub const OUTCOME_ARTIFACT_SCHEMAS: &[(&str, &str)] = &[
    (
        "request.schema.json",
        chio_core_types::capability::governance::VERIFIED_OUTCOME_REQUEST_SCHEMA,
    ),
    ("predicate.schema.json", OUTCOME_PREDICATE_SCHEMA),
    ("pricing.schema.json", OUTCOME_PRICING_SCHEMA),
    ("sla.schema.json", OUTCOME_SLA_SCHEMA),
    ("eligibility.schema.json", OUTCOME_ELIGIBILITY_SCHEMA),
    (
        "delivery-checkpoint.schema.json",
        OUTCOME_DELIVERY_CHECKPOINT_SCHEMA,
    ),
    (
        "delivery-acknowledgement.schema.json",
        OUTCOME_DELIVERY_ACKNOWLEDGEMENT_SCHEMA,
    ),
    (
        "delivery-nonacceptance.schema.json",
        OUTCOME_DELIVERY_NONACCEPTANCE_SCHEMA,
    ),
    (
        "output-provenance.schema.json",
        OUTCOME_OUTPUT_PROVENANCE_SCHEMA,
    ),
    (
        "contractual-zero.schema.json",
        OUTCOME_CONTRACTUAL_ZERO_SCHEMA,
    ),
    ("verdict.schema.json", OUTCOME_VERDICT_SCHEMA),
];

const MAX_TEXT_CHARS: usize = 2_048;
const I_JSON_MAX_SAFE_INTEGER: u64 = (1_u64 << 53) - 1;

#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum OutcomeError {
    #[error("invalid outcome field `{0}`")]
    InvalidField(&'static str),
    #[error("outcome artifact binding does not match")]
    BindingMismatch,
    #[error("outcome artifact authority verification failed")]
    AuthorityVerification,
    #[error("outcome artifact is not current")]
    NotCurrent,
    #[error("illegal outcome lifecycle transition")]
    IllegalTransition,
    #[error("outcome arithmetic overflow")]
    ArithmeticOverflow,
    #[error("outcome JSON is invalid: {0}")]
    InvalidJson(String),
    #[error("outcome artifact is not canonical: {0}")]
    Canonicalization(String),
}

#[derive(Debug, Clone)]
pub struct OutcomeSignerTrustV1 {
    principal_id: String,
    key: PublicKey,
    key_epoch: u64,
    max_lifetime_ms: u64,
}

impl OutcomeSignerTrustV1 {
    pub fn new(
        principal_id: String,
        key: PublicKey,
        key_epoch: u64,
        max_lifetime_ms: u64,
    ) -> Result<Self, OutcomeError> {
        validate_text("trusted_principal_id", &principal_id)?;
        validate_time("trusted_key_epoch", key_epoch)?;
        validate_time("max_lifetime_ms", max_lifetime_ms)?;
        Ok(Self {
            principal_id,
            key,
            key_epoch,
            max_lifetime_ms,
        })
    }

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

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

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

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

pub fn canonical_outcome_bytes(value: &impl Serialize) -> Result<Vec<u8>, OutcomeError> {
    let encoded = canonical_json_bytes(value)
        .map_err(|error| OutcomeError::Canonicalization(error.to_string()))?;
    let input = std::str::from_utf8(&encoded)
        .map_err(|error| OutcomeError::Canonicalization(error.to_string()))?;
    canonical_json_bytes_from_str(input)
        .map_err(|error| OutcomeError::Canonicalization(error.to_string()))
}

pub fn load_canonical_outcome_json<T>(bytes: &[u8]) -> Result<T, OutcomeError>
where
    T: DeserializeOwned,
{
    let input =
        std::str::from_utf8(bytes).map_err(|error| OutcomeError::InvalidJson(error.to_string()))?;
    let canonical = canonical_json_bytes_from_str(input)
        .map_err(|error| OutcomeError::Canonicalization(error.to_string()))?;
    if canonical.as_slice() != bytes {
        return Err(OutcomeError::Canonicalization(
            "input bytes differ from RFC 8785 form".to_owned(),
        ));
    }
    let value = serde_json::from_slice(bytes)
        .map_err(|error| OutcomeError::InvalidJson(error.to_string()))?;
    Ok(value)
}

pub(super) fn domain_digest(domain: &[u8], value: &impl Serialize) -> Result<String, OutcomeError> {
    let bytes = canonical_outcome_bytes(value)?;
    let mut preimage = Vec::with_capacity(domain.len() + bytes.len());
    preimage.extend_from_slice(domain);
    preimage.extend_from_slice(&bytes);
    Ok(sha256_hex(&preimage))
}

pub(super) fn domain_digest_without_field(
    domain: &[u8],
    value: &impl Serialize,
    field: &'static str,
) -> Result<String, OutcomeError> {
    let mut value = serde_json::to_value(value)
        .map_err(|error| OutcomeError::Canonicalization(error.to_string()))?;
    let object = value
        .as_object_mut()
        .ok_or(OutcomeError::InvalidField("artifact_body"))?;
    if object.remove(field).is_none() {
        return Err(OutcomeError::InvalidField(field));
    }
    domain_digest(domain, &value)
}

pub(super) fn envelope_digest(value: &impl Serialize) -> Result<String, OutcomeError> {
    canonical_outcome_bytes(value).map(|bytes| sha256_hex(&bytes))
}

pub(super) fn validate_text(field: &'static str, value: &str) -> Result<(), OutcomeError> {
    if value.is_empty()
        || value.trim() != value
        || value.chars().count() > MAX_TEXT_CHARS
        || value.chars().any(char::is_control)
    {
        Err(OutcomeError::InvalidField(field))
    } else {
        Ok(())
    }
}

pub(super) fn validate_digest(field: &'static str, value: &str) -> Result<(), OutcomeError> {
    if value.len() == 64
        && value
            .bytes()
            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
    {
        Ok(())
    } else {
        Err(OutcomeError::InvalidField(field))
    }
}

pub(super) fn validate_time(field: &'static str, value: u64) -> Result<(), OutcomeError> {
    if value == 0 || value > I_JSON_MAX_SAFE_INTEGER {
        Err(OutcomeError::InvalidField(field))
    } else {
        Ok(())
    }
}

pub(super) fn validate_window(issued_at: u64, expires_at: u64) -> Result<(), OutcomeError> {
    validate_time("issued_at_unix_ms", issued_at)?;
    validate_time("expires_at_unix_ms", expires_at)?;
    if expires_at <= issued_at {
        return Err(OutcomeError::InvalidField("validity_window"));
    }
    Ok(())
}

pub(super) fn validate_current_window(
    issued_at: u64,
    expires_at: u64,
    max_lifetime_ms: u64,
    trusted_now_unix_ms: u64,
) -> Result<(), OutcomeError> {
    validate_window(issued_at, expires_at)?;
    let lifetime = expires_at
        .checked_sub(issued_at)
        .ok_or(OutcomeError::NotCurrent)?;
    if lifetime > max_lifetime_ms
        || trusted_now_unix_ms < issued_at
        || trusted_now_unix_ms >= expires_at
    {
        return Err(OutcomeError::NotCurrent);
    }
    Ok(())
}

pub(super) fn validate_money(
    amount: &MonetaryAmount,
    allow_zero: bool,
) -> Result<(), OutcomeError> {
    if (!allow_zero && amount.units == 0)
        || amount.units > I_JSON_MAX_SAFE_INTEGER
        || amount.currency.len() != 3
        || !amount
            .currency
            .bytes()
            .all(|byte| byte.is_ascii_uppercase())
    {
        return Err(OutcomeError::InvalidField("amount"));
    }
    Ok(())
}