feat: add custom errors

This commit is contained in:
Kristofers Solo 2025-09-28 17:44:21 +03:00
parent 8f1bbc88cf
commit 51f07beb4d
Signed by: kristoferssolo
GPG Key ID: 8687F2D3EEE6F0ED
7 changed files with 560 additions and 155 deletions

22
Cargo.lock generated
View File

@ -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"

View File

@ -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"] }

View File

@ -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,
}
}
/// 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<P: AsRef<Path>>(&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<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);
}
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() {

View File

@ -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);
}
}

View File

@ -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<P: AsRef<Path>>(path: P) -> Result<Self, ManifestError> {
let json_str = read_to_string(path)?;
let manifest = serde_json::from_str::<Self>(&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<CapabilityManifest> {
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<P: AsRef<Path>>(path: P) -> Result<CapabilityManifest, ManifestError> {
CapabilityManifest::load(path)
}

View File

@ -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
View 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);
}