From 51f07beb4d27a1e7da780ddb32002db6bf2929e1 Mon Sep 17 00:00:00 2001 From: Kristofers Solo Date: Sun, 28 Sep 2025 17:44:21 +0300 Subject: [PATCH] feat: add custom errors --- Cargo.lock | 22 ++++++ Cargo.toml | 2 + src/host.rs | 197 ++++++++++++++++++++++++++++++++++++++++-------- src/lib.rs | 108 -------------------------- src/manifest.rs | 84 +++++++++++++++++++-- src/trace.rs | 126 ++++++++++++++++++++++++++++--- tests/captra.rs | 176 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 560 insertions(+), 155 deletions(-) create mode 100644 tests/captra.rs diff --git a/Cargo.lock b/Cargo.lock index f45dabf..fd6346b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index b8abf5e..00fb7a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/src/host.rs b/src/host.rs index ef9cfce..131a852 100644 --- a/src/host.rs +++ b/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, - pub seed: u64, - pub keypair: SigningKey, - pub pubkey: [u8; PUBLIC_KEY_LENGTH], + manifest: CapabilityManifest, + trace: Vec, + 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, } } - /// Simulate "plugin execution": check if path is allowed via FS read cap. - /// Returns `true` if allowed, `false` if denied. + /// Get `pubkey` #[must_use] - pub fn execute_plugin>(&mut self, path: P) -> bool { - let Some(fs_cap) = &self.manifest.capabilities.fs else { - return false; - }; + pub const fn pubkey(&self) -> &[u8; PUBLIC_KEY_LENGTH] { + &self.pubkey + } - let Some(read_patterns) = &fs_cap.read else { - return false; + /// 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. + /// 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>(&mut self, path: P) -> Result { + let path_str = path.as_ref().to_string_lossy(); + if path_str.is_empty() { + return Err(CapError::InvalidPath); + } + + 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 { + 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>(&self, path: P) -> Result<()> { + pub fn save_current_trace>(&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() { diff --git a/src/lib.rs b/src/lib.rs index f359d2a..3bc6077 100644 --- a/src/lib.rs +++ b/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::>(&trace1)); - let parsed2 = assert_ok!(serde_json::from_str::>(&trace2)); - assert_eq!(parsed1, parsed2); - assert_eq!(parsed1[0].ts_seed, parsed2[0].ts_seed); - } -} diff --git a/src/manifest.rs b/src/manifest.rs index 0144184..8a6ccce 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -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 + /// + /// [`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>(path: P) -> Result { + let json_str = read_to_string(path)?; + let manifest = serde_json::from_str::(&json_str)?; + manifest.validate()?; + Ok(manifest) + } +} + +/// A think wrapper around `CapabilityManifest::load()` /// /// # Errors /// -/// - bubbles up `std::fs::read_to_string` and `serde_json::from_str` errors; -pub fn load_manifest(path: &str) -> Result { - let json_str = read_to_string(path)?; - let manifest = serde_json::from_str(&json_str)?; - Ok(manifest) +/// [`ManifestError`] (IO, JSON, or validation failures). +pub fn load_manifest>(path: P) -> Result { + CapabilityManifest::load(path) } diff --git a/src/trace.rs b/src/trace.rs index d31eb95..f98801f 100644 --- a/src/trace.rs +++ b/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, + ) -> 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>(trace: &[TraceEvent], path: P) -> Result<()> { +/// [`TraceError`] (JSON or IO). +pub fn save_trace>(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>(trace: &[TraceEvent], path: P) -> Result<()> { /// /// # Errors /// -/// If file read fails (e.g., I/O error) or JSON parsing fails. -pub fn load_trace>(path: P) -> Result> { +/// [`TraceError`] (JSON or IO). +pub fn load_trace>(path: P) -> Result, 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 { + 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 { + 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 for EventType { + fn from(subtype: CapEventSubtype) -> Self { + match subtype { + CapEventSubtype::GlobMismatch => Self::CapCall, + _ => Self::CapError, + } + } +} diff --git a/tests/captra.rs b/tests/captra.rs new file mode 100644 index 0000000..49059d9 --- /dev/null +++ b/tests/captra.rs @@ -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::>(&trace1)); + let parsed2 = assert_ok!(serde_json::from_str::>(&trace2)); + assert_eq!(parsed1, parsed2); + assert_eq!(parsed1[0].ts_seed, parsed2[0].ts_seed); +}