use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
use std::path::PathBuf;
use std::str::FromStr;
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub struct ContentHash([u8; 32]);
impl ContentHash {
#[must_use]
pub fn from_bytes(bytes: [u8; 32]) -> Self {
Self(bytes)
}
#[must_use]
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
#[must_use]
pub fn prefix(&self) -> String {
format!("{:02x}", self.0[0])
}
#[must_use]
pub fn suffix(&self) -> String {
let mut s = String::with_capacity(62);
for byte in &self.0[1..] {
s.push_str(&format!("{byte:02x}"));
}
s
}
}
impl fmt::Display for ContentHash {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for byte in &self.0 {
write!(f, "{byte:02x}")?;
}
Ok(())
}
}
impl fmt::Debug for ContentHash {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "ContentHash({})", self)
}
}
impl FromStr for ContentHash {
type Err = ContentHashParseError;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
if s.len() != 64 {
return Err(ContentHashParseError::InvalidLength(s.len()));
}
let mut bytes = [0u8; 32];
for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
let hex_str =
std::str::from_utf8(chunk).map_err(|_| ContentHashParseError::InvalidHex)?;
bytes[i] =
u8::from_str_radix(hex_str, 16).map_err(|_| ContentHashParseError::InvalidHex)?;
}
Ok(Self(bytes))
}
}
#[derive(Debug, Clone)]
pub enum ContentHashParseError {
InvalidLength(usize),
InvalidHex,
}
impl fmt::Display for ContentHashParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidLength(len) => {
write!(f, "expected 64 hex characters, got {len}")
}
Self::InvalidHex => write!(f, "invalid hex character"),
}
}
}
impl std::error::Error for ContentHashParseError {}
impl Serialize for ContentHash {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for ContentHash {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Self::from_str(&s).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileState {
pub hash: ContentHash,
pub size: u64,
pub mtime: i64,
pub permissions: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ChangeType {
Created,
Modified,
Deleted,
PermissionsChanged,
}
impl fmt::Display for ChangeType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Created => write!(f, "+"),
Self::Modified => write!(f, "~"),
Self::Deleted => write!(f, "-"),
Self::PermissionsChanged => write!(f, "p"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Change {
pub path: PathBuf,
pub change_type: ChangeType,
pub size_delta: Option<i64>,
pub old_hash: Option<ContentHash>,
pub new_hash: Option<ContentHash>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NetworkAuditMode {
Connect,
Reverse,
External,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NetworkAuditDecision {
Allow,
Deny,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetworkAuditEvent {
pub timestamp_unix_ms: u64,
pub mode: NetworkAuditMode,
pub decision: NetworkAuditDecision,
pub target: String,
pub port: Option<u16>,
pub method: Option<String>,
pub path: Option<String>,
pub status: Option<u16>,
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditIntegritySummary {
pub hash_algorithm: String,
pub event_count: u64,
pub chain_head: ContentHash,
pub merkle_root: ContentHash,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditAttestationSummary {
pub predicate_type: String,
pub key_id: String,
pub public_key: String,
pub bundle_filename: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutableIdentity {
pub resolved_path: PathBuf,
pub sha256: ContentHash,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionMetadata {
pub session_id: String,
pub started: String,
pub ended: Option<String>,
pub command: Vec<String>,
#[serde(default)]
pub executable_identity: Option<ExecutableIdentity>,
pub tracked_paths: Vec<PathBuf>,
pub snapshot_count: u32,
pub exit_code: Option<i32>,
pub merkle_roots: Vec<ContentHash>,
#[serde(default)]
pub network_events: Vec<NetworkAuditEvent>,
#[serde(default)]
pub audit_event_count: u64,
#[serde(default)]
pub audit_integrity: Option<AuditIntegritySummary>,
#[serde(default)]
pub audit_attestation: Option<AuditAttestationSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotManifest {
pub number: u32,
pub timestamp: String,
pub parent: Option<u32>,
pub files: HashMap<PathBuf, FileState>,
pub merkle_root: ContentHash,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn content_hash_hex_roundtrip() {
let bytes = [
0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45,
0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01,
0x23, 0x45, 0x67, 0x89,
];
let hash = ContentHash::from_bytes(bytes);
let hex = hash.to_string();
let parsed: ContentHash = hex.parse().expect("should parse");
assert_eq!(hash, parsed);
}
#[test]
fn content_hash_prefix_suffix() {
let bytes = [
0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45,
0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01,
0x23, 0x45, 0x67, 0x89,
];
let hash = ContentHash::from_bytes(bytes);
assert_eq!(hash.prefix(), "ab");
assert!(hash.suffix().starts_with("cdef"));
assert_eq!(hash.prefix().len() + hash.suffix().len(), 64);
}
#[test]
fn content_hash_invalid_length() {
let result = "abc".parse::<ContentHash>();
assert!(result.is_err());
}
#[test]
fn content_hash_invalid_hex() {
let result = "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"
.parse::<ContentHash>();
assert!(result.is_err());
}
#[test]
fn content_hash_serde_roundtrip() {
let bytes = [42u8; 32];
let hash = ContentHash::from_bytes(bytes);
let json = serde_json::to_string(&hash).expect("should serialize");
let parsed: ContentHash = serde_json::from_str(&json).expect("should deserialize");
assert_eq!(hash, parsed);
}
#[test]
fn change_type_display() {
assert_eq!(ChangeType::Created.to_string(), "+");
assert_eq!(ChangeType::Modified.to_string(), "~");
assert_eq!(ChangeType::Deleted.to_string(), "-");
assert_eq!(ChangeType::PermissionsChanged.to_string(), "p");
}
#[test]
fn snapshot_manifest_serde_roundtrip() {
let manifest = SnapshotManifest {
number: 0,
timestamp: "2025-01-01T00:00:00Z".to_string(),
parent: None,
files: HashMap::new(),
merkle_root: ContentHash::from_bytes([0u8; 32]),
};
let json = serde_json::to_string(&manifest).expect("should serialize");
let parsed: SnapshotManifest = serde_json::from_str(&json).expect("should deserialize");
assert_eq!(parsed.number, 0);
assert!(parsed.parent.is_none());
assert!(parsed.files.is_empty());
}
}