use std::error::Error;
use std::fmt;
use vsh_commit::RecoveryReport;
use vsh_monty::ExecutionStats;
use vsh_policy::{PolicyProfile, PolicyThresholds, RiskFlag, RiskMetrics};
use vsh_store::TransactionRecord;
use vsh_types::{
BlobId, DiffDigest, DiffEntry, HookId, IntentDigest, PolicyDigest, PrincipalId, ProgramDigest,
ReadSetDigest, RequestEventId, RuntimeConfigDigest, SnapshotId, TransactionId,
TransactionState, VPath, WriteSetDigest,
};
use vsh_vfs::EffectEvent;
use crate::runtime::{Receipt, RunMode, RunRequest, Runtime, RuntimeConfig, VshError};
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum HookScope {
#[default]
ReviewRequired,
AllRequests,
}
impl HookScope {
pub(crate) const fn tag(self) -> u8 {
match self {
Self::ReviewRequired => 1,
Self::AllRequests => 2,
}
}
pub(crate) const fn applies_to(self, state: TransactionState) -> bool {
match self {
Self::ReviewRequired => matches!(state, TransactionState::PendingApproval),
Self::AllRequests => matches!(
state,
TransactionState::AutoApproved | TransactionState::PendingApproval
),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct HookConfig {
id: HookId,
scope: HookScope,
approval_ttl_ms: u64,
max_reason_bytes: usize,
max_content_bytes: usize,
}
impl HookConfig {
#[must_use]
pub fn new(label: &str) -> Self {
Self {
id: HookId::digest_label(label),
scope: HookScope::ReviewRequired,
approval_ttl_ms: 5 * 60 * 1_000,
max_reason_bytes: 16 * 1_024,
max_content_bytes: 0,
}
}
#[must_use]
pub const fn with_scope(mut self, scope: HookScope) -> Self {
self.scope = scope;
self
}
#[must_use]
pub const fn with_approval_ttl_ms(mut self, approval_ttl_ms: u64) -> Self {
self.approval_ttl_ms = approval_ttl_ms;
self
}
#[must_use]
pub const fn with_max_reason_bytes(mut self, max_reason_bytes: usize) -> Self {
self.max_reason_bytes = max_reason_bytes;
self
}
#[must_use]
pub const fn with_max_content_bytes(mut self, maximum: usize) -> Self {
self.max_content_bytes = maximum;
self
}
#[must_use]
pub const fn max_content_bytes(self) -> usize {
self.max_content_bytes
}
#[must_use]
pub const fn id(self) -> HookId {
self.id
}
#[must_use]
pub const fn scope(self) -> HookScope {
self.scope
}
#[must_use]
pub const fn approval_ttl_ms(self) -> u64 {
self.approval_ttl_ms
}
#[must_use]
pub const fn max_reason_bytes(self) -> usize {
self.max_reason_bytes
}
pub(crate) fn principal(self) -> PrincipalId {
PrincipalId::from_bytes(*self.id.as_bytes())
}
}
impl Default for HookConfig {
fn default() -> Self {
Self::new("vsh.commit-hook")
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum HookBaseline {
AutoApproved,
ReviewRequired,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ReviewContent {
pub path: VPath,
pub blob: BlobId,
pub bytes: Vec<u8>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RequestEvent {
pub schema_version: u16,
pub event_id: RequestEventId,
pub hook_id: HookId,
pub transaction: TransactionId,
pub state: TransactionState,
pub baseline: HookBaseline,
pub base_snapshot: SnapshotId,
pub diff: DiffDigest,
pub read_set: ReadSetDigest,
pub write_set: WriteSetDigest,
pub program: ProgramDigest,
pub policy: PolicyDigest,
pub runtime_config: RuntimeConfigDigest,
pub intent_digest: Option<IntentDigest>,
pub intent: Option<String>,
pub policy_profile: PolicyProfile,
pub policy_thresholds: PolicyThresholds,
pub risk_metrics: RiskMetrics,
pub risk_flags: Vec<RiskFlag>,
pub canonical_diff: Vec<DiffEntry>,
pub effects: Vec<EffectEvent>,
pub execution: ExecutionStats,
pub evidence_complete: bool,
pub evidence_truncated: bool,
pub contents: Vec<ReviewContent>,
pub content_complete: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum HookDecision {
FollowPolicy,
Approve {
reason: String,
},
Review {
feedback: String,
},
Reject {
reason: String,
},
}
impl HookDecision {
#[must_use]
pub fn approve(reason: impl Into<String>) -> Self {
Self::Approve {
reason: reason.into(),
}
}
#[must_use]
pub fn review(feedback: impl Into<String>) -> Self {
Self::Review {
feedback: feedback.into(),
}
}
#[must_use]
pub fn reject(reason: impl Into<String>) -> Self {
Self::Reject {
reason: reason.into(),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum HookVerdict {
FollowPolicy,
Approve,
Review,
Reject,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HookDecisionRecord {
pub event_id: RequestEventId,
pub hook_id: HookId,
pub verdict: HookVerdict,
pub reason: String,
pub principal: Option<PrincipalId>,
}
#[derive(Clone, Debug)]
pub enum CommitPreparation {
#[doc(hidden)]
Ready {
transaction: TransactionId,
state: TransactionState,
},
Review(Box<RequestEvent>),
}
impl CommitPreparation {
#[must_use]
pub const fn transaction(&self) -> TransactionId {
match self {
Self::Ready { transaction, .. } => *transaction,
Self::Review(event) => event.transaction,
}
}
#[must_use]
pub fn event(&self) -> Option<&RequestEvent> {
match self {
Self::Ready { .. } => None,
Self::Review(event) => Some(event.as_ref()),
}
}
pub(crate) const fn prepared_state(&self) -> TransactionState {
match self {
Self::Ready { state, .. } => *state,
Self::Review(event) => event.state,
}
}
}
#[derive(Clone, Debug)]
pub struct CommitResolution {
pub receipt: Receipt,
pub hook: Option<HookDecisionRecord>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HookHandlerError {
detail: String,
}
impl HookHandlerError {
#[must_use]
pub fn new(detail: impl Into<String>) -> Self {
Self {
detail: detail.into(),
}
}
}
impl fmt::Display for HookHandlerError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.detail)
}
}
impl Error for HookHandlerError {}
pub trait CommitHook: Send + Sync {
fn handle(&self, event: &RequestEvent) -> Result<HookDecision, HookHandlerError>;
}
impl<F> CommitHook for F
where
F: Fn(&RequestEvent) -> Result<HookDecision, HookHandlerError> + Send + Sync,
{
fn handle(&self, event: &RequestEvent) -> Result<HookDecision, HookHandlerError> {
self(event)
}
}
pub struct HookedRuntime<H> {
runtime: Runtime,
handler: H,
}
impl<H: CommitHook> HookedRuntime<H> {
pub fn open(config: RuntimeConfig, hook: HookConfig, handler: H) -> Result<Self, VshError> {
Ok(Self {
runtime: Runtime::open(config.with_commit_hook(hook))?,
handler,
})
}
pub fn run(&self, mut request: RunRequest<'_>, now_unix_ms: u64) -> Result<Receipt, VshError> {
let mode = request.mode;
request.mode = RunMode::Preview;
let receipt = self.runtime.preview(request)?;
if mode == RunMode::Auto
&& matches!(
receipt.state,
TransactionState::AutoApproved | TransactionState::PendingApproval
)
{
return self
.commit(receipt.transaction, now_unix_ms)
.map(|resolution| resolution.receipt);
}
Ok(receipt)
}
pub fn preview(&self, request: RunRequest<'_>) -> Result<Receipt, VshError> {
self.runtime.preview(request)
}
pub fn commit(
&self,
transaction: TransactionId,
now_unix_ms: u64,
) -> Result<CommitResolution, VshError> {
let preparation = self.runtime.prepare_commit(transaction)?;
let decision = match preparation.event() {
Some(event) => match self.handler.handle(event) {
Ok(decision) => decision,
Err(source) => {
self.runtime.fail_hook(&preparation)?;
return Err(VshError::HookHandler(source));
}
},
None => HookDecision::FollowPolicy,
};
self.runtime
.resolve_commit(&preparation, &decision, now_unix_ms)
}
pub fn approve(
&self,
transaction: TransactionId,
principal: PrincipalId,
issued_at_unix_ms: u64,
expires_at_unix_ms: u64,
) -> Result<TransactionRecord, VshError> {
self.runtime.approve(
transaction,
principal,
issued_at_unix_ms,
expires_at_unix_ms,
)
}
pub fn discard_preview(&self, transaction: TransactionId) -> Result<bool, VshError> {
self.runtime.discard_preview(transaction)
}
pub fn recover(&self) -> Result<RecoveryReport, VshError> {
self.runtime.recover()
}
pub fn transaction(&self, transaction: TransactionId) -> Result<TransactionRecord, VshError> {
self.runtime.transaction(transaction)
}
}