mirror of
https://github.com/kristoferssolo/captra.git
synced 2025-12-20 11:04:39 +00:00
feat: add custom errors
This commit is contained in:
parent
8f1bbc88cf
commit
51f07beb4d
22
Cargo.lock
generated
22
Cargo.lock
generated
@ -80,7 +80,9 @@ dependencies = [
|
||||
"rand",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
@ -642,6 +644,26 @@ dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.9"
|
||||
|
||||
@ -17,6 +17,8 @@ glob = "0.3"
|
||||
rand = "0.8" # ed25519-dalek depends on rand_core v0.6
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10.9"
|
||||
thiserror = "2.0"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
|
||||
|
||||
197
src/host.rs
197
src/host.rs
@ -1,80 +1,191 @@
|
||||
use color_eyre::Result;
|
||||
use ed25519_dalek::{PUBLIC_KEY_LENGTH, SigningKey};
|
||||
use glob::Pattern;
|
||||
use rand::{Rng, SeedableRng, rngs::StdRng};
|
||||
use std::path::Path;
|
||||
use tracing::Level;
|
||||
|
||||
use crate::{
|
||||
manifest::{CapabilityManifest, PRIME_MULTIPLIER},
|
||||
trace::{TraceEvent, finalize_trace, log_trace_event, save_trace},
|
||||
trace::{
|
||||
CapEventSubtype, EventType, SignedTrace, TraceError, TraceEvent, finalize_trace,
|
||||
log_trace_event, save_trace,
|
||||
},
|
||||
};
|
||||
use ed25519_dalek::{PUBLIC_KEY_LENGTH, SigningKey, ed25519::signature::SignerMut};
|
||||
use glob::Pattern;
|
||||
use rand::{Rng, SeedableRng, rngs::StdRng};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::Path;
|
||||
use thiserror::Error;
|
||||
use tracing::Level;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct HostState {
|
||||
pub manifest: CapabilityManifest,
|
||||
pub trace: Vec<TraceEvent>,
|
||||
pub seed: u64,
|
||||
pub keypair: SigningKey,
|
||||
pub pubkey: [u8; PUBLIC_KEY_LENGTH],
|
||||
manifest: CapabilityManifest,
|
||||
trace: Vec<TraceEvent>,
|
||||
seed: u64,
|
||||
keypair: SigningKey,
|
||||
pubkey: [u8; PUBLIC_KEY_LENGTH],
|
||||
run_id: String,
|
||||
manifest_hash: String,
|
||||
}
|
||||
|
||||
/// Errors from capability enforcement.
|
||||
#[derive(Debug, Error, PartialEq, Eq)]
|
||||
pub enum CapError {
|
||||
#[error("No FS capability declared")]
|
||||
NoFsCapability,
|
||||
|
||||
#[error("No read patterns defined")]
|
||||
NoReadPatterns,
|
||||
|
||||
#[error("Path does not match any glob pattern")]
|
||||
GlobMismatch,
|
||||
|
||||
#[error("Invalid path provided (empty or invalid UTF-8)")]
|
||||
InvalidPath,
|
||||
}
|
||||
|
||||
impl HostState {
|
||||
#[inline]
|
||||
#[must_use]
|
||||
/// # Panics
|
||||
///
|
||||
/// Should not panic
|
||||
pub fn new(manifest: CapabilityManifest, seed: u64, keypair: SigningKey) -> Self {
|
||||
let pubkey = keypair.verifying_key().to_bytes();
|
||||
let run_id = format!("captra-run-{seed}");
|
||||
let manifest_json = serde_json::to_string(&manifest).expect("Manifest serializes"); // Safe: validated earlier
|
||||
let mut hashser = Sha256::default();
|
||||
hashser.update(manifest_json.as_bytes());
|
||||
let manifest_hash = format!("{:x}", hashser.finalize());
|
||||
|
||||
Self {
|
||||
manifest,
|
||||
trace: Vec::new(),
|
||||
seed,
|
||||
keypair,
|
||||
pubkey,
|
||||
run_id,
|
||||
manifest_hash,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get `pubkey`
|
||||
#[must_use]
|
||||
pub const fn pubkey(&self) -> &[u8; PUBLIC_KEY_LENGTH] {
|
||||
&self.pubkey
|
||||
}
|
||||
|
||||
/// Get `run_id`
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn run_id(&self) -> &str {
|
||||
&self.run_id
|
||||
}
|
||||
|
||||
/// Get `trace`
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn trace(&self) -> &[TraceEvent] {
|
||||
&self.trace
|
||||
}
|
||||
|
||||
/// Simulate "plugin execution": check if path is allowed via FS read cap.
|
||||
/// Returns `true` if allowed, `false` if denied.
|
||||
#[must_use]
|
||||
pub fn execute_plugin<P: AsRef<Path>>(&mut self, path: P) -> bool {
|
||||
let Some(fs_cap) = &self.manifest.capabilities.fs else {
|
||||
return false;
|
||||
};
|
||||
/// Logs to trace on success/error (outcome=false for errors).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`CapError`] if enforcement fails (e.g., no caps or mismatch).
|
||||
pub fn execute_plugin<P: AsRef<Path>>(&mut self, path: P) -> Result<bool, CapError> {
|
||||
let path_str = path.as_ref().to_string_lossy();
|
||||
if path_str.is_empty() {
|
||||
return Err(CapError::InvalidPath);
|
||||
}
|
||||
|
||||
let Some(read_patterns) = &fs_cap.read else {
|
||||
return false;
|
||||
if self.manifest.capabilities.fs.is_none() {
|
||||
self.log_cap_error(CapEventSubtype::NoFsCapability, "missing fs cap", &path_str);
|
||||
return Err(CapError::NoFsCapability);
|
||||
}
|
||||
|
||||
let read_patterns_ops = self
|
||||
.manifest
|
||||
.capabilities
|
||||
.fs
|
||||
.as_ref()
|
||||
.and_then(|fs| fs.read.clone());
|
||||
|
||||
let read_patterns = match read_patterns_ops {
|
||||
Some(v) if !v.is_empty() => v,
|
||||
_ => {
|
||||
self.log_cap_error(
|
||||
CapEventSubtype::NoReadPatterns,
|
||||
"empty read patterns",
|
||||
&path_str,
|
||||
);
|
||||
return Err(CapError::NoReadPatterns);
|
||||
}
|
||||
};
|
||||
|
||||
let seq = u64::try_from(self.trace.len()).map_or(1, |len| len + 1);
|
||||
let path_str = path.as_ref().to_string_lossy();
|
||||
|
||||
let mut rng = StdRng::seed_from_u64(self.seed.wrapping_mul(PRIME_MULTIPLIER + seq));
|
||||
let ts_seed = rng.r#gen();
|
||||
|
||||
let is_allowed = read_patterns.iter().any(|pattern| {
|
||||
Pattern::new(pattern)
|
||||
.map(|p| p.matches(&path_str))
|
||||
.unwrap_or(false)
|
||||
Pattern::new(pattern).map_or_else(
|
||||
|_| {
|
||||
self.log_cap_error(CapEventSubtype::InvalidGlob, pattern, &path_str);
|
||||
false
|
||||
},
|
||||
|p| p.matches(&path_str),
|
||||
)
|
||||
});
|
||||
|
||||
if !is_allowed {
|
||||
self.log_cap_error(
|
||||
CapEventSubtype::GlobMismatch,
|
||||
"no matching pattern",
|
||||
&path_str,
|
||||
);
|
||||
return Err(CapError::GlobMismatch);
|
||||
}
|
||||
|
||||
log_trace_event(
|
||||
seq,
|
||||
"cap.call",
|
||||
EventType::CapCall,
|
||||
&path_str,
|
||||
is_allowed,
|
||||
true,
|
||||
ts_seed,
|
||||
&self.manifest.plugin,
|
||||
);
|
||||
|
||||
self.trace.push(TraceEvent {
|
||||
run_id: self.run_id.clone(),
|
||||
seq,
|
||||
event_type: "cap.call".into(),
|
||||
event_type: EventType::CapCall,
|
||||
input: path_str.into(),
|
||||
outcome: is_allowed,
|
||||
ts_seed,
|
||||
});
|
||||
|
||||
is_allowed
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Signs the current trace JSON with the host keypair.
|
||||
/// Computes SHA256 hash of trace for integrity.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`TraceError`] (serialization).
|
||||
pub fn sign_current_trace(&mut self) -> Result<SignedTrace, TraceError> {
|
||||
let trace_json = finalize_trace(&self.trace);
|
||||
let mut hasher = Sha256::default();
|
||||
hasher.update(trace_json.as_bytes());
|
||||
let trace_hash = format!("{:x}", hasher.finalize());
|
||||
|
||||
let signature = self.keypair.sign(trace_hash.as_bytes()).to_bytes().to_vec();
|
||||
|
||||
Ok(SignedTrace::new(
|
||||
self.run_id.clone(),
|
||||
self.manifest_hash.clone(),
|
||||
trace_json,
|
||||
signature,
|
||||
))
|
||||
}
|
||||
|
||||
/// Serialize trace to pretty JSON string
|
||||
@ -89,9 +200,35 @@ impl HostState {
|
||||
/// # Errors
|
||||
///
|
||||
/// If file write fails (e.g., I/O error) or JSON serialization fails.
|
||||
pub fn save_current_trace<P: AsRef<Path>>(&self, path: P) -> Result<()> {
|
||||
pub fn save_current_trace<P: AsRef<Path>>(&self, path: P) -> Result<(), TraceError> {
|
||||
save_trace(&self.trace, path)
|
||||
}
|
||||
|
||||
fn log_cap_error(&mut self, event_subtype: CapEventSubtype, reason: &str, path_str: &str) {
|
||||
let seq = u64::try_from(self.trace.len()).map_or(1, |len| len + 1);
|
||||
let mut rng = StdRng::seed_from_u64(self.seed.wrapping_mul(PRIME_MULTIPLIER + seq));
|
||||
let ts_seed = rng.r#gen();
|
||||
|
||||
let event_type = EventType::from(event_subtype);
|
||||
|
||||
log_trace_event(
|
||||
seq,
|
||||
event_type,
|
||||
path_str,
|
||||
false,
|
||||
ts_seed,
|
||||
&self.manifest.plugin,
|
||||
);
|
||||
|
||||
self.trace.push(TraceEvent {
|
||||
run_id: self.run_id.clone(),
|
||||
seq,
|
||||
event_type,
|
||||
input: format!("{event_subtype}: {reason}"),
|
||||
outcome: false,
|
||||
ts_seed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init_tracing() {
|
||||
|
||||
108
src/lib.rs
108
src/lib.rs
@ -1,111 +1,3 @@
|
||||
pub mod host;
|
||||
pub mod manifest;
|
||||
pub mod trace;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{
|
||||
host::{HostState, init_tracing},
|
||||
manifest::load_manifest,
|
||||
trace::{TraceEvent, load_trace},
|
||||
};
|
||||
use claims::{assert_ok, assert_some};
|
||||
use ed25519_dalek::SigningKey;
|
||||
use rand::rngs::OsRng;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn tracing_init() {
|
||||
init_tracing();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_loader() {
|
||||
let manifest = assert_ok!(load_manifest("examples/manifest.json"), "Load failed");
|
||||
assert_eq!(manifest.plugin, "formatter-v1");
|
||||
assert_eq!(manifest.version, "0.1");
|
||||
assert_eq!(manifest.issued_by, "dev-team");
|
||||
|
||||
let caps = manifest.capabilities;
|
||||
let fs_cap = assert_some!(caps.fs);
|
||||
let read_patterns = assert_some!(fs_cap.read);
|
||||
assert_eq!(read_patterns, vec!["./workspace/*"]);
|
||||
let write_patterns = assert_some!(fs_cap.write);
|
||||
assert!(write_patterns.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_enforcement_with_trace() {
|
||||
init_tracing();
|
||||
|
||||
let manifest = assert_ok!(load_manifest("examples/manifest.json"), "Load failed");
|
||||
let fixed_seed = 12345;
|
||||
let mut csprng = OsRng;
|
||||
let keypair = SigningKey::generate(&mut csprng);
|
||||
let mut host = HostState::new(manifest, fixed_seed, keypair);
|
||||
|
||||
// allowed - expect a tracing event
|
||||
let out1 = host.execute_plugin("./workspace/config.toml");
|
||||
assert!(out1);
|
||||
// denied - event + entry
|
||||
let out2 = host.execute_plugin("/etc/passwd");
|
||||
assert!(!out2);
|
||||
|
||||
assert_eq!(host.trace.len(), 2);
|
||||
|
||||
let trace1 = assert_some!(host.trace.first());
|
||||
assert_eq!(trace1.seq, 1);
|
||||
assert_eq!(trace1.event_type, "cap.call");
|
||||
assert_eq!(trace1.input, "./workspace/config.toml");
|
||||
assert!(trace1.outcome);
|
||||
assert_eq!(trace1.ts_seed, 8_166_419_713_379_829_776);
|
||||
assert_ne!(trace1.ts_seed, 0);
|
||||
|
||||
let trace2 = assert_some!(host.trace.get(1));
|
||||
assert_eq!(trace2.seq, 2);
|
||||
assert_eq!(trace2.input, "/etc/passwd");
|
||||
assert!(!trace2.outcome);
|
||||
assert_eq!(trace2.ts_seed, 10_553_447_931_939_622_718);
|
||||
assert_ne!(trace1.ts_seed, trace2.ts_seed);
|
||||
|
||||
let tmp_dir = tempdir().expect("Temp dir failed");
|
||||
let tmp_path = tmp_dir.as_ref().join("trace.json");
|
||||
|
||||
assert_ok!(host.save_current_trace(&tmp_path), "Save failed");
|
||||
|
||||
let loaded_trace = assert_ok!(load_trace(tmp_path), "Load failed");
|
||||
assert_eq!(loaded_trace.len(), 2);
|
||||
|
||||
let loaded_trace1 = assert_some!(loaded_trace.first());
|
||||
assert_eq!(trace1, loaded_trace1);
|
||||
|
||||
let loaded_trace2 = assert_some!(loaded_trace.get(1));
|
||||
assert_eq!(trace2, loaded_trace2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trace_reproducibility() {
|
||||
init_tracing();
|
||||
|
||||
let manifest = assert_ok!(load_manifest("examples/manifest.json"), "Load failed");
|
||||
|
||||
let fixed_seed = 12345;
|
||||
let mut csprng1 = OsRng;
|
||||
let keypair1 = SigningKey::generate(&mut csprng1);
|
||||
|
||||
let mut host1 = HostState::new(manifest.clone(), fixed_seed, keypair1);
|
||||
assert!(!host1.execute_plugin("./allowed.txt"));
|
||||
let trace1 = host1.get_trace_json();
|
||||
|
||||
let mut csprng2 = OsRng;
|
||||
let keypair2 = SigningKey::generate(&mut csprng2);
|
||||
let mut host2 = HostState::new(manifest, fixed_seed, keypair2);
|
||||
assert!(!host2.execute_plugin("./allowed.txt"));
|
||||
let trace2 = host2.get_trace_json();
|
||||
|
||||
let parsed1 = assert_ok!(serde_json::from_str::<Vec<TraceEvent>>(&trace1));
|
||||
let parsed2 = assert_ok!(serde_json::from_str::<Vec<TraceEvent>>(&trace2));
|
||||
assert_eq!(parsed1, parsed2);
|
||||
assert_eq!(parsed1[0].ts_seed, parsed2[0].ts_seed);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
use color_eyre::eyre::Result;
|
||||
use glob::Pattern;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs::read_to_string;
|
||||
use std::{fs::read_to_string, path::Path};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Prime for seq hashing to derive per-event RNG state
|
||||
pub const PRIME_MULTIPLIER: u64 = 314_159;
|
||||
@ -31,13 +32,80 @@ pub struct CapabilityManifest {
|
||||
// TODO: add signature
|
||||
}
|
||||
|
||||
/// Loads a capability manifest from a JSON file.
|
||||
/// Errors from manifest loading/validation.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ManifestError {
|
||||
#[error("IO error reading manifest: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("JSON deserialization failed: {0}")]
|
||||
Deserialize(#[from] serde_json::Error),
|
||||
|
||||
#[error("Invalid plugin name: must be non-empty")]
|
||||
InvalidPlugin,
|
||||
|
||||
#[error("Invalid version: must be non-empty")]
|
||||
InvalidVersion,
|
||||
|
||||
#[error("Invalid issuer: must be non-empty")]
|
||||
InvalidIssuer,
|
||||
|
||||
#[error("Invalid glob pattern at index {idx}: {pattern} - {err}")]
|
||||
InvalidGlob {
|
||||
idx: usize,
|
||||
pattern: String,
|
||||
err: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl CapabilityManifest {
|
||||
/// Validates the manifest: non-empty fields and compilable glob patterns.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - bubbles up `std::fs::read_to_string` and `serde_json::from_str` errors;
|
||||
pub fn load_manifest(path: &str) -> Result<CapabilityManifest> {
|
||||
/// [`ManifestError`] if invalid.
|
||||
pub fn validate(&self) -> Result<(), ManifestError> {
|
||||
if self.plugin.is_empty() {
|
||||
return Err(ManifestError::InvalidPlugin);
|
||||
}
|
||||
if self.version.is_empty() {
|
||||
return Err(ManifestError::InvalidVersion);
|
||||
}
|
||||
if self.issued_by.is_empty() {
|
||||
return Err(ManifestError::InvalidIssuer);
|
||||
}
|
||||
if let Some(fs_cap) = &self.capabilities.fs
|
||||
&& let Some(read_patterns) = &fs_cap.read
|
||||
{
|
||||
for (idx, pattern) in read_patterns.iter().enumerate() {
|
||||
Pattern::new(pattern).map_err(|err| ManifestError::InvalidGlob {
|
||||
idx,
|
||||
pattern: pattern.clone(),
|
||||
err: err.to_string(),
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Loads a capability manifest from a JSON file and validates it.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`ManifestError`] (IO, JSON, or validation failures).
|
||||
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, ManifestError> {
|
||||
let json_str = read_to_string(path)?;
|
||||
let manifest = serde_json::from_str(&json_str)?;
|
||||
let manifest = serde_json::from_str::<Self>(&json_str)?;
|
||||
manifest.validate()?;
|
||||
Ok(manifest)
|
||||
}
|
||||
}
|
||||
|
||||
/// A think wrapper around `CapabilityManifest::load()`
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`ManifestError`] (IO, JSON, or validation failures).
|
||||
pub fn load_manifest<P: AsRef<Path>>(path: P) -> Result<CapabilityManifest, ManifestError> {
|
||||
CapabilityManifest::load(path)
|
||||
}
|
||||
|
||||
126
src/trace.rs
126
src/trace.rs
@ -1,12 +1,14 @@
|
||||
use color_eyre::eyre::Result;
|
||||
use base64::{Engine, engine::general_purpose};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{fs, path::Path};
|
||||
use std::{fmt::Display, fs, path::Path, str::FromStr};
|
||||
use thiserror::Error;
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct TraceEvent {
|
||||
pub run_id: String,
|
||||
pub seq: u64,
|
||||
pub event_type: String,
|
||||
pub event_type: EventType,
|
||||
pub input: String,
|
||||
pub outcome: bool,
|
||||
pub ts_seed: u64,
|
||||
@ -20,12 +22,61 @@ pub struct SignedTrace {
|
||||
pub signature: String,
|
||||
}
|
||||
|
||||
/// Errors from trace serialization/IO.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TraceError {
|
||||
#[error("JSON serialization failed: {0}")]
|
||||
Serialize(#[from] serde_json::Error),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("Base64 encoding failed: {0}")]
|
||||
Base64(#[from] base64::DecodeError),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EventType {
|
||||
CapCall,
|
||||
CapError,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CapEventSubtype {
|
||||
InvalidPath,
|
||||
NoFsCapability,
|
||||
NoReadPatterns,
|
||||
GlobMismatch,
|
||||
InvalidGlob,
|
||||
// TODO: NetConnect, NetDeny, CpuQuotaExceeded
|
||||
}
|
||||
|
||||
impl SignedTrace {
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
run_id: String,
|
||||
manifest_hash: String,
|
||||
trace_json: String,
|
||||
signature: Vec<u8>,
|
||||
) -> Self {
|
||||
Self {
|
||||
run_id,
|
||||
manifest_hash,
|
||||
trace_json,
|
||||
signature: general_purpose::STANDARD.encode(signature),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Save the current trace to a file as pretty JSON.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// If file write fails (e.g., I/O error) or JSON serialization fails.
|
||||
pub fn save_trace<P: AsRef<Path>>(trace: &[TraceEvent], path: P) -> Result<()> {
|
||||
/// [`TraceError`] (JSON or IO).
|
||||
pub fn save_trace<P: AsRef<Path>>(trace: &[TraceEvent], path: P) -> Result<(), TraceError> {
|
||||
let json_str = serde_json::to_string_pretty(trace)?;
|
||||
fs::write(path, json_str)?;
|
||||
Ok(())
|
||||
@ -35,8 +86,8 @@ pub fn save_trace<P: AsRef<Path>>(trace: &[TraceEvent], path: P) -> Result<()> {
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// If file read fails (e.g., I/O error) or JSON parsing fails.
|
||||
pub fn load_trace<P: AsRef<Path>>(path: P) -> Result<Vec<TraceEvent>> {
|
||||
/// [`TraceError`] (JSON or IO).
|
||||
pub fn load_trace<P: AsRef<Path>>(path: P) -> Result<Vec<TraceEvent>, TraceError> {
|
||||
let json_str = fs::read_to_string(path)?;
|
||||
let trace = serde_json::from_str(&json_str)?;
|
||||
Ok(trace)
|
||||
@ -52,7 +103,7 @@ pub fn finalize_trace(trace: &[TraceEvent]) -> String {
|
||||
/// Log a trace event
|
||||
pub fn log_trace_event(
|
||||
seq: u64,
|
||||
event_type: &str,
|
||||
event_type: EventType,
|
||||
input: &str,
|
||||
outcome: bool,
|
||||
ts_seed: u64,
|
||||
@ -61,9 +112,66 @@ pub fn log_trace_event(
|
||||
info!(
|
||||
seq = seq,
|
||||
ts_seed = ts_seed,
|
||||
event_type = event_type,
|
||||
event_type = %event_type,
|
||||
input = %input,
|
||||
outcome = outcome,
|
||||
plugin = plugin,
|
||||
);
|
||||
}
|
||||
|
||||
impl FromStr for EventType {
|
||||
type Err = &'static str;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"cap.call" => Ok(Self::CapCall),
|
||||
"cap.error" => Ok(Self::CapError),
|
||||
_ => Err("Unknown event type"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for EventType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let s = match self {
|
||||
Self::CapCall => "cap.call",
|
||||
Self::CapError => "cap.error",
|
||||
};
|
||||
f.write_str(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for CapEventSubtype {
|
||||
type Err = &'static str;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"invalid_path" => Ok(Self::InvalidPath),
|
||||
"no_fs_capability" => Ok(Self::NoFsCapability),
|
||||
"no_read_patterns" => Ok(Self::NoReadPatterns),
|
||||
"glob_mismatch" => Ok(Self::GlobMismatch),
|
||||
"invalid_glob" => Ok(Self::InvalidGlob),
|
||||
_ => Err("Unknown cap event subtype"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for CapEventSubtype {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let s = match self {
|
||||
Self::InvalidPath => "invalid_path",
|
||||
Self::NoFsCapability => "no_fs_capability",
|
||||
Self::NoReadPatterns => "no_read_patterns",
|
||||
Self::GlobMismatch => "glob_mismatch",
|
||||
Self::InvalidGlob => "invalid_glob",
|
||||
};
|
||||
f.write_str(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CapEventSubtype> for EventType {
|
||||
fn from(subtype: CapEventSubtype) -> Self {
|
||||
match subtype {
|
||||
CapEventSubtype::GlobMismatch => Self::CapCall,
|
||||
_ => Self::CapError,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
176
tests/captra.rs
Normal file
176
tests/captra.rs
Normal file
@ -0,0 +1,176 @@
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use captra::{
|
||||
host::{CapError, HostState, init_tracing},
|
||||
manifest::{ManifestError, load_manifest},
|
||||
trace::{EventType, TraceEvent, load_trace},
|
||||
};
|
||||
use claims::{assert_err, assert_matches, assert_ok, assert_some};
|
||||
use ed25519_dalek::SigningKey;
|
||||
use rand::rngs::OsRng;
|
||||
use std::{fs::File, io::Write};
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn tracing_init() {
|
||||
init_tracing();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_loader() {
|
||||
let manifest = assert_ok!(load_manifest("examples/manifest.json"), "Load failed");
|
||||
assert_eq!(manifest.plugin, "formatter-v1");
|
||||
assert_eq!(manifest.version, "0.1");
|
||||
assert_eq!(manifest.issued_by, "dev-team");
|
||||
|
||||
let caps = manifest.capabilities;
|
||||
let fs_cap = assert_some!(caps.fs);
|
||||
let read_patterns = assert_some!(fs_cap.read);
|
||||
assert_eq!(read_patterns, vec!["./workspace/*"]);
|
||||
let write_patterns = assert_some!(fs_cap.write);
|
||||
assert!(write_patterns.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_validation_invalid() {
|
||||
// Mock invalid manifest (empty plugin).
|
||||
let invalid_json = r#"
|
||||
{
|
||||
"plugin": "",
|
||||
"version": "0.1",
|
||||
"capabilities": {
|
||||
"fs": {
|
||||
"read": []
|
||||
}
|
||||
},
|
||||
"issued_by": "dev"
|
||||
}
|
||||
"#;
|
||||
let tmp_dir = tempdir().expect("Temp dir failed");
|
||||
let tmp_path = tmp_dir.as_ref().join("invalid.json");
|
||||
{
|
||||
let mut file = File::create(&tmp_path).expect("File creation failed");
|
||||
file.write_all(invalid_json.as_bytes())
|
||||
.expect("Write failed");
|
||||
}
|
||||
|
||||
assert_err!(load_manifest(tmp_path), "Expected validation error");
|
||||
|
||||
let glob_invalid_json = r#"
|
||||
{
|
||||
"plugin": "test",
|
||||
"version": "0.1",
|
||||
"capabilities": {
|
||||
"fs": {
|
||||
"read": [
|
||||
"["
|
||||
]
|
||||
}
|
||||
},
|
||||
"issued_by": "dev"
|
||||
}
|
||||
"#;
|
||||
let tmp_path2 = tmp_dir.as_ref().join("invalid.json");
|
||||
{
|
||||
let mut file = File::create(&tmp_path2).expect("File creation failed");
|
||||
file.write_all(glob_invalid_json.as_bytes())
|
||||
.expect("Write failed");
|
||||
}
|
||||
let err = assert_err!(load_manifest(tmp_path2), "Expected validation error");
|
||||
assert_matches!(
|
||||
&err,
|
||||
ManifestError::InvalidGlob { .. },
|
||||
"Expected InvalidGlob, got: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_enforcement_with_trace() {
|
||||
init_tracing();
|
||||
|
||||
let manifest = assert_ok!(load_manifest("examples/manifest.json"), "Load failed");
|
||||
let fixed_seed = 12345;
|
||||
let mut csprng = OsRng;
|
||||
let keypair = SigningKey::generate(&mut csprng);
|
||||
let mut host = HostState::new(manifest, fixed_seed, keypair);
|
||||
|
||||
assert_eq!(host.run_id(), "captra-run-12345");
|
||||
|
||||
// allowed - expect a tracing event
|
||||
let out1 = assert_ok!(host.execute_plugin("./workspace/config.toml"));
|
||||
assert!(out1);
|
||||
// denied - event + entry
|
||||
let out2 = assert_err!(host.execute_plugin("/etc/passwd"));
|
||||
assert_eq!(out2, CapError::GlobMismatch);
|
||||
|
||||
{
|
||||
assert_eq!(host.trace().len(), 2); // Both allowed/denied log to trace.
|
||||
|
||||
let trace1 = assert_some!(host.trace().get(0));
|
||||
assert_eq!(trace1.seq, 1);
|
||||
assert_eq!(trace1.event_type, EventType::CapCall);
|
||||
assert_eq!(trace1.input, "./workspace/config.toml");
|
||||
assert!(trace1.outcome);
|
||||
assert_eq!(trace1.ts_seed, 8_166_419_713_379_829_776);
|
||||
assert_ne!(trace1.ts_seed, 0);
|
||||
|
||||
let trace2 = assert_some!(host.trace().get(1));
|
||||
assert_eq!(trace2.run_id, "captra-run-12345");
|
||||
assert_eq!(trace2.seq, 2);
|
||||
assert_eq!(trace2.event_type, EventType::CapCall);
|
||||
assert!(!trace2.outcome);
|
||||
assert!(trace2.input.starts_with("glob_mismatch: "));
|
||||
assert_eq!(trace2.ts_seed, 10_553_447_931_939_622_718);
|
||||
assert_ne!(trace1.ts_seed, trace2.ts_seed);
|
||||
}
|
||||
|
||||
let signed = assert_ok!(host.sign_current_trace());
|
||||
assert_eq!(signed.run_id, "captra-run-12345");
|
||||
|
||||
let sig_bytes = STANDARD.decode(&signed.signature).expect("Base64 decode");
|
||||
assert_eq!(sig_bytes.len(), 64);
|
||||
assert!(!signed.trace_json.is_empty()); // Nonempty JSON
|
||||
|
||||
// Save/load roundtrip
|
||||
let tmp_dir = tempdir().expect("Temp dir failed");
|
||||
let tmp_path = tmp_dir.as_ref().join("trace.json");
|
||||
assert_ok!(host.save_current_trace(&tmp_path), "Save failed");
|
||||
|
||||
let loaded_trace = assert_ok!(load_trace(tmp_path), "Load failed");
|
||||
assert_eq!(loaded_trace.len(), 2);
|
||||
|
||||
let trace1_refetched = assert_some!(host.trace().get(0));
|
||||
let trace2_refetched = assert_some!(host.trace().get(1));
|
||||
let loaded_trace1 = assert_some!(loaded_trace.get(0));
|
||||
let loaded_trace2 = assert_some!(loaded_trace.get(1));
|
||||
|
||||
assert_eq!(trace1_refetched, loaded_trace1);
|
||||
assert_eq!(trace2_refetched, loaded_trace2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trace_reproducibility() {
|
||||
init_tracing();
|
||||
|
||||
let manifest = assert_ok!(load_manifest("examples/manifest.json"), "Load failed");
|
||||
|
||||
let fixed_seed = 12345;
|
||||
let mut csprng1 = OsRng;
|
||||
let keypair1 = SigningKey::generate(&mut csprng1);
|
||||
|
||||
let mut host1 = HostState::new(manifest.clone(), fixed_seed, keypair1);
|
||||
let out1 = assert_err!(host1.execute_plugin("./allowed.txt"));
|
||||
assert_eq!(out1, CapError::GlobMismatch);
|
||||
let trace1 = host1.get_trace_json();
|
||||
|
||||
let mut csprng2 = OsRng;
|
||||
let keypair2 = SigningKey::generate(&mut csprng2);
|
||||
let mut host2 = HostState::new(manifest, fixed_seed, keypair2);
|
||||
let out2 = assert_err!(host2.execute_plugin("./allowed.txt"));
|
||||
assert_eq!(out2, CapError::GlobMismatch);
|
||||
let trace2 = host2.get_trace_json();
|
||||
|
||||
let parsed1 = assert_ok!(serde_json::from_str::<Vec<TraceEvent>>(&trace1));
|
||||
let parsed2 = assert_ok!(serde_json::from_str::<Vec<TraceEvent>>(&trace2));
|
||||
assert_eq!(parsed1, parsed2);
|
||||
assert_eq!(parsed1[0].ts_seed, parsed2[0].ts_seed);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user