From c01b6af79e13aed78dd4795b334075249a7cf90e Mon Sep 17 00:00:00 2001 From: Cody Kickertz Date: Thu, 12 Mar 2026 22:39:26 +0000 Subject: [PATCH] feat(koinon): tamper-evident log with BLAKE3 hash chain Append-only log writer with BLAKE3 hash chaining for forensic integrity. Binary format: 4-byte LE length + CBOR payload + 32-byte BLAKE3 hash. LogEntryKind variants: SignalObserved, EntityCreated, ConfigChanged, AlertRaised, ActionTaken. Streaming O(n) chain verification detects single-byte tampering. File rotation with configurable size threshold and sequential numbering. Log recovery on reopen reads to end to restore prev_hash and sequence. 19 tamper_log tests including 4 corruption/tampering detection scenarios. Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 185 ++++++ Cargo.toml | 9 + crates/koinon/Cargo.toml | 4 + crates/koinon/src/lib.rs | 5 + crates/koinon/src/tamper_log.rs | 1027 +++++++++++++++++++++++++++++++ 5 files changed, 1230 insertions(+) create mode 100644 crates/koinon/src/tamper_log.rs diff --git a/Cargo.lock b/Cargo.lock index b9eba39..1595a2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -75,6 +75,18 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "atomic" version = "0.6.1" @@ -90,6 +102,20 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "blake3" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -108,12 +134,58 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "clap" version = "4.6.0" @@ -160,6 +232,42 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "serde", + "static_assertions", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "equivalent" version = "1.0.2" @@ -176,6 +284,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "figment" version = "0.10.19" @@ -190,6 +304,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "getrandom" version = "0.3.4" @@ -202,6 +322,17 @@ dependencies = [ "wasip2", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -297,10 +428,14 @@ dependencies = [ name = "koinon" version = "0.1.0" dependencies = [ + "blake3", + "ciborium", + "compact_str", "jiff", "serde", "serde_json", "snafu", + "tempfile", "tracing", "ulid", ] @@ -317,6 +452,12 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "lock_api" version = "0.4.14" @@ -547,12 +688,31 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "scopeguard" version = "1.2.0" @@ -620,6 +780,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -667,6 +833,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" @@ -684,6 +856,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thread_local" version = "1.1.9" diff --git a/Cargo.toml b/Cargo.toml index 8a5cdc9..67d218c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,3 +54,12 @@ clap = { version = "4", features = ["derive"] } # Configuration figment = { version = "0.10", features = ["toml", "env"] } + +# Hashing +blake3 = "1" + +# Binary serialization +ciborium = "0.2" + +# Testing +tempfile = "3" diff --git a/crates/koinon/Cargo.toml b/crates/koinon/Cargo.toml index 4ee9555..d11a5c3 100644 --- a/crates/koinon/Cargo.toml +++ b/crates/koinon/Cargo.toml @@ -13,9 +13,13 @@ jiff.workspace = true serde.workspace = true snafu.workspace = true tracing.workspace = true +compact_str.workspace = true +blake3.workspace = true +ciborium.workspace = true [dev-dependencies] serde_json.workspace = true +tempfile.workspace = true [lints] workspace = true diff --git a/crates/koinon/src/lib.rs b/crates/koinon/src/lib.rs index c1fc2ae..97680fb 100644 --- a/crates/koinon/src/lib.rs +++ b/crates/koinon/src/lib.rs @@ -4,10 +4,15 @@ pub mod coordinates; pub mod frequency; pub mod id; pub mod power; +pub mod tamper_log; pub mod timestamp; pub use coordinates::{Coordinates, CoordinatesError, Datum}; pub use frequency::Frequency; pub use id::{DeviceId, EntityId, FrequencyId, SignalId}; pub use power::Power; +pub use tamper_log::{ + ChainStatus, LogEntry, LogEntryKind, TamperLog, TamperLogError, VerificationResult, + verify_chain, +}; pub use timestamp::{Timestamp, TimestampError}; diff --git a/crates/koinon/src/tamper_log.rs b/crates/koinon/src/tamper_log.rs new file mode 100644 index 0000000..d2caecd --- /dev/null +++ b/crates/koinon/src/tamper_log.rs @@ -0,0 +1,1027 @@ +//! Tamper-evident append-only log with BLAKE3 hash chaining. +//! +//! # Binary format (per entry) +//! +//! ```text +//! [4 bytes: payload length (little-endian u32)] +//! [N bytes: CBOR-encoded LogEntry] +//! [32 bytes: BLAKE3 hash = BLAKE3(cbor_bytes || prev_hash)] +//! ``` +//! +//! The first entry uses `[0u8; 32]` as `prev_hash`. + +use std::{ + fs::{File, OpenOptions}, + io::{BufReader, BufWriter, Cursor, Read, Seek, SeekFrom, Write}, + path::{Path, PathBuf}, +}; + +use compact_str::CompactString; +use serde::{Deserialize, Serialize}; +use snafu::{ResultExt, Snafu}; + +use crate::{EntityId, SignalId}; + +/// Maximum allowed entry payload size (16 MiB). +const MAX_ENTRY_BYTES: u64 = 16 * 1024 * 1024; +/// Default rotation threshold (100 MiB). +const DEFAULT_MAX_FILE_BYTES: u64 = 100 * 1024 * 1024; + +// --------------------------------------------------------------------------- +// Error type +// --------------------------------------------------------------------------- + +/// Errors produced by [`TamperLog`] and [`verify_chain`]. +#[derive(Debug, Snafu)] +pub enum TamperLogError { + /// I/O error accessing the log file. + #[snafu(display("I/O error on {}: {source}", path.display()))] + Io { + /// Path of the file that triggered the error. + path: PathBuf, + /// Underlying I/O error. + source: std::io::Error, + }, + + /// CBOR serialization error. + #[snafu(display("CBOR encode error: {source}"))] + CborEncode { + /// Underlying ciborium serialization error. + source: ciborium::ser::Error, + }, + + /// CBOR deserialization error. + #[snafu(display("CBOR decode error: {source}"))] + CborDecode { + /// Underlying ciborium deserialization error. + source: ciborium::de::Error, + }, + + /// File is corrupted or truncated at the given byte offset. + #[snafu(display("log file corrupted at byte offset {offset}"))] + Corrupted { + /// Byte offset where corruption was detected. + offset: u64, + }, + + /// Entry payload exceeds the sanity limit. + #[snafu(display("entry too large: {size} bytes (max {max})"))] + EntryTooLarge { + /// Actual size of the entry. + size: u64, + /// Maximum allowed size. + max: u64, + }, +} + +// --------------------------------------------------------------------------- +// Log entry types +// --------------------------------------------------------------------------- + +/// The kind of event recorded in a [`LogEntry`]. +#[non_exhaustive] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum LogEntryKind { + /// A signal was observed by a collector. + SignalObserved { + /// Identifier of the observed signal. + signal_id: SignalId, + /// Short tag describing the signal kind. + kind_tag: CompactString, + }, + /// A new entity was created in the system. + EntityCreated { + /// Identifier of the created entity. + entity_id: EntityId, + /// Short tag describing the entity kind. + kind_tag: CompactString, + }, + /// A configuration parameter was changed. + ConfigChanged { + /// Configuration key that changed. + key: CompactString, + /// Previous value, if any. + old_value: Option, + /// New value after the change. + new_value: CompactString, + }, + /// An alert was raised by the analysis pipeline. + AlertRaised { + /// Unique identifier for this alert. + alert_id: CompactString, + /// Severity level (e.g. `"critical"`, `"warning"`). + severity: CompactString, + /// Human-readable alert message. + message: CompactString, + }, + /// An operator or automation took an action. + ActionTaken { + /// Identity of the actor (user or system). + actor: CompactString, + /// Description of the action performed. + action: CompactString, + /// Target of the action, if applicable. + target: Option, + }, +} + +/// A single record in the tamper-evident log. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct LogEntry { + /// Monotonically increasing sequence number within a log file (0-based). + pub sequence: u64, + /// Wall-clock time at which the entry was appended (Unix milliseconds). + pub timestamp_ms: i64, + /// Event payload. + pub kind: LogEntryKind, +} + +// --------------------------------------------------------------------------- +// Encode / decode +// --------------------------------------------------------------------------- + +/// Serializes `entry` to CBOR, computes its hash, and returns `(wire_bytes, entry_hash)`. +/// +/// `wire_bytes` is the complete on-disk representation: +/// `[4-byte LE length][CBOR payload][32-byte BLAKE3 hash]`. +/// +/// # Errors +/// +/// Returns [`TamperLogError::CborEncode`] if serialization fails. +pub fn encode_entry( + entry: &LogEntry, + prev_hash: &[u8; 32], +) -> Result<(Vec, [u8; 32]), TamperLogError> { + let mut cbor_bytes: Vec = Vec::new(); + ciborium::into_writer(entry, &mut cbor_bytes).context(CborEncodeSnafu)?; + + let mut hasher = blake3::Hasher::new(); + hasher.update(&cbor_bytes); + hasher.update(prev_hash); + let hash: [u8; 32] = hasher.finalize().into(); + + let len = cbor_bytes.len() as u32; + let mut wire = Vec::with_capacity(4 + cbor_bytes.len() + 32); + wire.extend_from_slice(&len.to_le_bytes()); + wire.extend_from_slice(&cbor_bytes); + wire.extend_from_slice(&hash); + + Ok((wire, hash)) +} + +/// Parses a wire-format entry from `bytes`, returning `(LogEntry, stored_hash)`. +/// +/// Does **not** verify the hash chain — use [`verify_chain`] for that. +/// +/// # Errors +/// +/// - [`TamperLogError::EntryTooLarge`] if the declared payload size exceeds 16 MiB. +/// - [`TamperLogError::Corrupted`] if `bytes` is too short for the declared payload. +/// - [`TamperLogError::CborDecode`] if the CBOR payload cannot be deserialized. +pub fn decode_entry(bytes: &[u8]) -> Result<(LogEntry, [u8; 32]), TamperLogError> { + let mut cursor = Cursor::new(bytes); + + let mut len_buf = [0u8; 4]; + cursor + .read_exact(&mut len_buf) + .map_err(|_| TamperLogError::Corrupted { offset: 0 })?; + let payload_len = u64::from(u32::from_le_bytes(len_buf)); + + if payload_len > MAX_ENTRY_BYTES { + return Err(TamperLogError::EntryTooLarge { + size: payload_len, + max: MAX_ENTRY_BYTES, + }); + } + + let mut cbor_bytes = vec![0u8; payload_len as usize]; + cursor + .read_exact(&mut cbor_bytes) + .map_err(|_| TamperLogError::Corrupted { offset: 4 })?; + + let entry: LogEntry = ciborium::from_reader(cbor_bytes.as_slice()).context(CborDecodeSnafu)?; + + let mut hash = [0u8; 32]; + cursor + .read_exact(&mut hash) + .map_err(|_| TamperLogError::Corrupted { + offset: 4 + payload_len, + })?; + + Ok((entry, hash)) +} + +// --------------------------------------------------------------------------- +// Chain verification +// --------------------------------------------------------------------------- + +/// Status of a chain verification pass. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ChainStatus { + /// All entries verified successfully. + Intact, + /// Chain is broken at the given sequence number. + Broken { + /// Sequence number of the first bad entry. + sequence: u64, + /// Hash that was expected (recomputed from content). + expected_hash: [u8; 32], + /// Hash that was stored on disk. + actual_hash: [u8; 32], + }, + /// File is empty (no entries). + Empty, + /// File is truncated or otherwise unreadable at `byte_offset`. + Corrupted { + /// Byte offset where the problem was detected. + byte_offset: u64, + }, +} + +/// Result of a chain verification pass. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerificationResult { + /// Number of entries that were successfully parsed and verified. + pub entries_verified: u64, + /// Overall chain status. + pub status: ChainStatus, +} + +/// Reads `path` from the beginning, recomputes every hash link, and returns +/// the first break found. +/// +/// This is O(n) in file size and streams the file; it never loads the whole +/// file into memory. +/// +/// # Errors +/// +/// Returns [`TamperLogError::Io`] if the file cannot be opened or read. +pub fn verify_chain(path: impl AsRef) -> Result { + let path = path.as_ref(); + let file = File::open(path).context(IoSnafu { path })?; + let mut reader = BufReader::new(file); + + let mut prev_hash = [0u8; 32]; + let mut entries_verified: u64 = 0; + let mut byte_offset: u64 = 0; + + loop { + // Read length prefix. + let mut len_buf = [0u8; 4]; + match reader.read_exact(&mut len_buf) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => { + if entries_verified == 0 { + return Ok(VerificationResult { + entries_verified: 0, + status: ChainStatus::Empty, + }); + } + return Ok(VerificationResult { + entries_verified, + status: ChainStatus::Intact, + }); + } + Err(e) => { + return Err(TamperLogError::Io { + path: path.to_owned(), + source: e, + }); + } + } + + let payload_len = u64::from(u32::from_le_bytes(len_buf)); + if payload_len > MAX_ENTRY_BYTES { + return Ok(VerificationResult { + entries_verified, + status: ChainStatus::Corrupted { byte_offset }, + }); + } + + // Read CBOR payload. + let mut cbor_bytes = vec![0u8; payload_len as usize]; + if reader.read_exact(&mut cbor_bytes).is_err() { + return Ok(VerificationResult { + entries_verified, + status: ChainStatus::Corrupted { + byte_offset: byte_offset + 4, + }, + }); + } + + // Read stored hash. + let mut stored_hash = [0u8; 32]; + if reader.read_exact(&mut stored_hash).is_err() { + return Ok(VerificationResult { + entries_verified, + status: ChainStatus::Corrupted { + byte_offset: byte_offset + 4 + payload_len, + }, + }); + } + + // Recompute hash. + let mut hasher = blake3::Hasher::new(); + hasher.update(&cbor_bytes); + hasher.update(&prev_hash); + let expected_hash: [u8; 32] = hasher.finalize().into(); + + if expected_hash != stored_hash { + let sequence = ciborium::from_reader::(cbor_bytes.as_slice()) + .map(|e| e.sequence) + .unwrap_or(entries_verified); + + return Ok(VerificationResult { + entries_verified, + status: ChainStatus::Broken { + sequence, + expected_hash, + actual_hash: stored_hash, + }, + }); + } + + prev_hash = expected_hash; + entries_verified += 1; + byte_offset += 4 + payload_len + 32; + } +} + +// --------------------------------------------------------------------------- +// TamperLog writer +// --------------------------------------------------------------------------- + +/// Append-only tamper-evident log writer. +/// +/// Each call to [`TamperLog::append`] writes a length-prefixed CBOR entry +/// followed by a BLAKE3 hash that chains from the previous entry. +pub struct TamperLog { + writer: BufWriter, + path: PathBuf, + prev_hash: [u8; 32], + sequence: u64, + bytes_written: u64, + max_file_bytes: u64, +} + +impl TamperLog { + /// Opens or creates a log file at `path`. + /// + /// If the file already exists and contains entries, reads to the end to + /// recover `prev_hash` and `sequence` so new appends continue the chain. + /// + /// # Errors + /// + /// Returns [`TamperLogError::Io`] on filesystem errors, or + /// [`TamperLogError::CborDecode`] / [`TamperLogError::Corrupted`] if an + /// existing file cannot be parsed. + pub fn open(path: impl AsRef) -> Result { + let path = path.as_ref().to_owned(); + + let (prev_hash, sequence, bytes_written) = Self::recover_state(&path)?; + + let file = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .context(IoSnafu { path: &path })?; + + Ok(Self { + writer: BufWriter::new(file), + path, + prev_hash, + sequence, + bytes_written, + max_file_bytes: DEFAULT_MAX_FILE_BYTES, + }) + } + + /// Sets the rotation threshold in bytes (default: 100 MiB). + #[must_use] + pub const fn with_max_file_bytes(mut self, max: u64) -> Self { + self.max_file_bytes = max; + self + } + + /// Returns the hash of the last written entry (or `[0u8; 32]` for a fresh log). + pub const fn last_hash(&self) -> &[u8; 32] { + &self.prev_hash + } + + /// Returns the number of entries written to the current file. + pub const fn entry_count(&self) -> u64 { + self.sequence + } + + /// Appends an event entry, flushes, and returns the sequence number assigned. + /// + /// Triggers file rotation if `bytes_written` exceeds `max_file_bytes`. + /// + /// # Errors + /// + /// Returns [`TamperLogError::Io`] on write failures or + /// [`TamperLogError::CborEncode`] on serialization failures. + pub fn append(&mut self, kind: LogEntryKind) -> Result { + let entry = LogEntry { + sequence: self.sequence, + timestamp_ms: jiff::Timestamp::now().as_millisecond(), + kind, + }; + + let (wire, hash) = encode_entry(&entry, &self.prev_hash)?; + self.writer + .write_all(&wire) + .context(IoSnafu { path: &self.path })?; + self.writer.flush().context(IoSnafu { path: &self.path })?; + + let seq = self.sequence; + self.prev_hash = hash; + self.sequence += 1; + self.bytes_written += wire.len() as u64; + + if self.bytes_written > self.max_file_bytes { + self.rotate()?; + } + + Ok(seq) + } + + /// Flushes any buffered data to the underlying file. + /// + /// # Errors + /// + /// Returns [`TamperLogError::Io`] if the flush fails. + pub fn flush(&mut self) -> Result<(), TamperLogError> { + self.writer.flush().context(IoSnafu { path: &self.path }) + } + + // ----------------------------------------------------------------------- + // Private helpers + // ----------------------------------------------------------------------- + + /// Reads an existing log file to recover `(prev_hash, next_sequence, bytes_written)`. + fn recover_state(path: &Path) -> Result<([u8; 32], u64, u64), TamperLogError> { + if !path.exists() { + return Ok(([0u8; 32], 0, 0)); + } + + let file = File::open(path).context(IoSnafu { path })?; + let file_len = file.metadata().context(IoSnafu { path })?.len(); + if file_len == 0 { + return Ok(([0u8; 32], 0, 0)); + } + + let mut reader = BufReader::new(file); + let mut prev_hash = [0u8; 32]; + let mut sequence: u64 = 0; + let mut bytes_read: u64 = 0; + + loop { + let mut len_buf = [0u8; 4]; + match reader.read_exact(&mut len_buf) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break, + Err(e) => { + return Err(TamperLogError::Io { + path: path.to_owned(), + source: e, + }); + } + } + + let payload_len = u64::from(u32::from_le_bytes(len_buf)); + if payload_len > MAX_ENTRY_BYTES { + break; + } + + let seek_offset = i64::try_from(payload_len).map_err(|e| TamperLogError::Io { + path: path.to_owned(), + source: std::io::Error::new(std::io::ErrorKind::InvalidData, e), + })?; + reader + .seek(SeekFrom::Current(seek_offset)) + .context(IoSnafu { path })?; + + let mut stored_hash = [0u8; 32]; + if reader.read_exact(&mut stored_hash).is_err() { + break; + } + + prev_hash = stored_hash; + sequence += 1; + bytes_read += 4 + payload_len + 32; + } + + Ok((prev_hash, sequence, bytes_read)) + } + + /// Renames the current log file to `{stem}.{n}.log` and opens a fresh one. + fn rotate(&mut self) -> Result<(), TamperLogError> { + self.writer.flush().context(IoSnafu { path: &self.path })?; + + let n = Self::next_rotation_number(&self.path); + let rotated = rotation_path(&self.path, n); + + std::fs::rename(&self.path, &rotated).context(IoSnafu { path: &self.path })?; + + let file = OpenOptions::new() + .create(true) + .append(true) + .open(&self.path) + .context(IoSnafu { path: &self.path })?; + + self.writer = BufWriter::new(file); + self.prev_hash = [0u8; 32]; + self.sequence = 0; + self.bytes_written = 0; + + Ok(()) + } + + /// Scans sibling files to find the next rotation number. + fn next_rotation_number(path: &Path) -> u32 { + let stem = log_stem(path); + let dir = path.parent().unwrap_or_else(|| Path::new(".")); + + let mut max_n: u32 = 0; + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if let Some(n) = parse_rotation_number(&name, &stem) { + if n > max_n { + max_n = n; + } + } + } + } + max_n + 1 + } +} + +// --------------------------------------------------------------------------- +// Rotation helpers +// --------------------------------------------------------------------------- + +/// Returns the stem of a log path, e.g. `"audit"` from `"audit.log"`. +fn log_stem(path: &Path) -> String { + path.file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default() +} + +/// Builds `{dir}/{stem}.{n}.log`. +fn rotation_path(path: &Path, n: u32) -> PathBuf { + let dir = path.parent().unwrap_or_else(|| Path::new(".")); + let stem = log_stem(path); + dir.join(format!("{stem}.{n}.log")) +} + +/// Parses `{stem}.{n}.log` → `Some(n)`, returns `None` otherwise. +fn parse_rotation_number(name: &str, stem: &str) -> Option { + let prefix = format!("{stem}."); + let suffix = ".log"; + let inner = name.strip_prefix(&prefix)?.strip_suffix(suffix)?; + inner.parse::().ok() +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + clippy::missing_docs_in_private_items +)] +mod tests { + use super::*; + use compact_str::CompactString; + use ulid::Ulid; + + fn signal_kind() -> LogEntryKind { + LogEntryKind::SignalObserved { + signal_id: SignalId::new(), + kind_tag: CompactString::from("rf"), + } + } + + fn entity_kind() -> LogEntryKind { + LogEntryKind::EntityCreated { + entity_id: EntityId::new(), + kind_tag: CompactString::from("drone"), + } + } + + fn config_kind() -> LogEntryKind { + LogEntryKind::ConfigChanged { + key: CompactString::from("threshold"), + old_value: Some(CompactString::from("10")), + new_value: CompactString::from("20"), + } + } + + fn alert_kind() -> LogEntryKind { + LogEntryKind::AlertRaised { + alert_id: CompactString::from("ALT-001"), + severity: CompactString::from("critical"), + message: CompactString::from("signal strength exceeded limit"), + } + } + + fn action_kind() -> LogEntryKind { + LogEntryKind::ActionTaken { + actor: CompactString::from("operator"), + action: CompactString::from("acknowledge"), + target: Some(CompactString::from("ALT-001")), + } + } + + // ----------------------------------------------------------------------- + // Core functionality + // ----------------------------------------------------------------------- + + #[test] + fn append_single_entry_and_verify_fields() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("test.log"); + + let mut log = TamperLog::open(&path).unwrap(); + let seq = log.append(signal_kind()).unwrap(); + assert_eq!(seq, 0); + assert_eq!(log.entry_count(), 1); + + let result = verify_chain(&path).unwrap(); + assert_eq!(result.entries_verified, 1); + assert_eq!(result.status, ChainStatus::Intact); + } + + #[test] + fn append_100_entries_chain_intact() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("test.log"); + + let mut log = TamperLog::open(&path).unwrap(); + for i in 0..100_u64 { + let seq = log.append(alert_kind()).unwrap(); + assert_eq!(seq, i); + } + + let result = verify_chain(&path).unwrap(); + assert_eq!(result.entries_verified, 100); + assert_eq!(result.status, ChainStatus::Intact); + } + + #[test] + fn empty_file_returns_empty_status() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("empty.log"); + File::create(&path).unwrap(); + + let result = verify_chain(&path).unwrap(); + assert_eq!(result.status, ChainStatus::Empty); + assert_eq!(result.entries_verified, 0); + } + + #[test] + fn recovery_continues_chain_correctly() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("recover.log"); + + { + let mut log = TamperLog::open(&path).unwrap(); + for _ in 0..5 { + log.append(action_kind()).unwrap(); + } + } + + { + let mut log = TamperLog::open(&path).unwrap(); + assert_eq!(log.entry_count(), 5); + for _ in 0..5 { + log.append(config_kind()).unwrap(); + } + } + + let result = verify_chain(&path).unwrap(); + assert_eq!(result.entries_verified, 10); + assert_eq!(result.status, ChainStatus::Intact); + } + + // ----------------------------------------------------------------------- + // Tampering detection + // ----------------------------------------------------------------------- + + /// Walks wire-format bytes and returns the byte offset of entry `target_idx`. + fn entry_offset(data: &[u8], target_idx: usize) -> usize { + let mut offset = 0usize; + for i in 0..target_idx { + let len = u32::from_le_bytes([ + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3], + ]) as usize; + // Only advance if not at target. + if i < target_idx { + offset += 4 + len + 32; + } + } + offset + } + + #[test] + fn flip_byte_in_cbor_payload_detected() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("tamper.log"); + + let mut log = TamperLog::open(&path).unwrap(); + for _ in 0..10 { + log.append(signal_kind()).unwrap(); + } + drop(log); + + let mut data = std::fs::read(&path).unwrap(); + let off = entry_offset(&data, 5); + // Flip a byte inside the CBOR payload (byte 4 = first CBOR byte). + data[off + 4] ^= 0xFF; + std::fs::write(&path, &data).unwrap(); + + let result = verify_chain(&path).unwrap(); + assert!(matches!( + result.status, + ChainStatus::Broken { sequence: 5, .. } + )); + } + + #[test] + fn flip_byte_in_hash_detected() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("tamper_hash.log"); + + let mut log = TamperLog::open(&path).unwrap(); + for _ in 0..10 { + log.append(entity_kind()).unwrap(); + } + drop(log); + + let mut data = std::fs::read(&path).unwrap(); + let off = entry_offset(&data, 3); + let payload_len = + u32::from_le_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]]) as usize; + // Flip the first byte of entry 3's stored hash. + data[off + 4 + payload_len] ^= 0x01; + std::fs::write(&path, &data).unwrap(); + + let result = verify_chain(&path).unwrap(); + // Entry 3's stored hash is wrong → broken at entry 3. + assert!(matches!(result.status, ChainStatus::Broken { .. })); + } + + #[test] + fn truncated_file_returns_corrupted() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("truncate.log"); + + let mut log = TamperLog::open(&path).unwrap(); + for _ in 0..10 { + log.append(config_kind()).unwrap(); + } + drop(log); + + let data = std::fs::read(&path).unwrap(); + let truncated = &data[..data.len() - 20]; + std::fs::write(&path, truncated).unwrap(); + + let result = verify_chain(&path).unwrap(); + assert!(matches!(result.status, ChainStatus::Corrupted { .. })); + } + + #[test] + fn zero_out_last_hash_detected() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("zerohash.log"); + + let mut log = TamperLog::open(&path).unwrap(); + for _ in 0..10 { + log.append(alert_kind()).unwrap(); + } + drop(log); + + let mut data = std::fs::read(&path).unwrap(); + let hash_start = data.len() - 32; + for b in &mut data[hash_start..] { + *b = 0; + } + std::fs::write(&path, &data).unwrap(); + + let result = verify_chain(&path).unwrap(); + assert!(matches!(result.status, ChainStatus::Broken { .. })); + } + + // ----------------------------------------------------------------------- + // Rotation + // ----------------------------------------------------------------------- + + #[test] + fn rotation_triggers_at_threshold() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("rotate.log"); + + let mut log = TamperLog::open(&path).unwrap().with_max_file_bytes(500); + for _ in 0..20 { + log.append(alert_kind()).unwrap(); + } + drop(log); + + let rotated = dir.path().join("rotate.1.log"); + assert!(rotated.exists(), "rotated file should exist"); + } + + #[test] + fn rotated_file_named_correctly() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("mylog.log"); + + let mut log = TamperLog::open(&path).unwrap().with_max_file_bytes(200); + for _ in 0..15 { + log.append(action_kind()).unwrap(); + } + drop(log); + + assert!(dir.path().join("mylog.1.log").exists()); + } + + #[test] + fn new_file_after_rotation_has_fresh_chain() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chain.log"); + + let mut log = TamperLog::open(&path).unwrap().with_max_file_bytes(300); + for _ in 0..20 { + log.append(signal_kind()).unwrap(); + } + drop(log); + + let result = verify_chain(&path).unwrap(); + assert!( + matches!(result.status, ChainStatus::Intact | ChainStatus::Empty), + "new file must be intact or empty" + ); + } + + #[test] + fn pre_rotation_file_verifies_independently() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("pre.log"); + + let mut log = TamperLog::open(&path).unwrap().with_max_file_bytes(300); + for _ in 0..20 { + log.append(entity_kind()).unwrap(); + } + drop(log); + + let rotated = dir.path().join("pre.1.log"); + if rotated.exists() { + let result = verify_chain(&rotated).unwrap(); + assert_eq!(result.status, ChainStatus::Intact); + } + } + + #[test] + fn multiple_rotations_sequential_numbering() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("multi.log"); + + let mut log = TamperLog::open(&path).unwrap().with_max_file_bytes(150); + for _ in 0..60 { + log.append(config_kind()).unwrap(); + } + drop(log); + + assert!( + dir.path().join("multi.1.log").exists(), + "multi.1.log missing" + ); + assert!( + dir.path().join("multi.2.log").exists(), + "multi.2.log missing" + ); + } + + // ----------------------------------------------------------------------- + // Edge cases + // ----------------------------------------------------------------------- + + #[test] + fn single_entry_chain_valid() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("single.log"); + + let mut log = TamperLog::open(&path).unwrap(); + log.append(signal_kind()).unwrap(); + drop(log); + + let result = verify_chain(&path).unwrap(); + assert_eq!(result.status, ChainStatus::Intact); + assert_eq!(result.entries_verified, 1); + } + + #[test] + fn cbor_roundtrip_signal_observed() { + let entry = LogEntry { + sequence: 0, + timestamp_ms: 1_000_000, + kind: signal_kind(), + }; + let prev = [0u8; 32]; + let (wire, _) = encode_entry(&entry, &prev).unwrap(); + let (decoded, _) = decode_entry(&wire).unwrap(); + assert_eq!(entry, decoded); + } + + #[test] + fn cbor_roundtrip_entity_created() { + let entry = LogEntry { + sequence: 1, + timestamp_ms: 2_000_000, + kind: entity_kind(), + }; + let (wire, _) = encode_entry(&entry, &[0u8; 32]).unwrap(); + let (decoded, _) = decode_entry(&wire).unwrap(); + assert_eq!(entry, decoded); + } + + #[test] + fn cbor_roundtrip_config_changed() { + let entry = LogEntry { + sequence: 2, + timestamp_ms: 3_000_000, + kind: config_kind(), + }; + let (wire, _) = encode_entry(&entry, &[0u8; 32]).unwrap(); + let (decoded, _) = decode_entry(&wire).unwrap(); + assert_eq!(entry, decoded); + } + + #[test] + fn cbor_roundtrip_alert_raised() { + let entry = LogEntry { + sequence: 3, + timestamp_ms: 4_000_000, + kind: alert_kind(), + }; + let (wire, _) = encode_entry(&entry, &[0u8; 32]).unwrap(); + let (decoded, _) = decode_entry(&wire).unwrap(); + assert_eq!(entry, decoded); + } + + #[test] + fn cbor_roundtrip_action_taken() { + let entry = LogEntry { + sequence: 4, + timestamp_ms: 5_000_000, + kind: action_kind(), + }; + let (wire, _) = encode_entry(&entry, &[0u8; 32]).unwrap(); + let (decoded, _) = decode_entry(&wire).unwrap(); + assert_eq!(entry, decoded); + } + + #[test] + fn large_metadata_no_truncation() { + // 512 bytes — well above compact_str's 24-byte inline capacity, tests + // that heap-allocated string content survives a CBOR encode/decode + // round-trip without truncation. + let big = "x".repeat(512); + let kind = LogEntryKind::AlertRaised { + alert_id: CompactString::from("BIG"), + severity: CompactString::from("info"), + message: CompactString::from(big.as_str()), + }; + let entry = LogEntry { + sequence: 0, + timestamp_ms: 0, + kind, + }; + let (wire, _) = encode_entry(&entry, &[0u8; 32]).unwrap(); + let (decoded, _) = decode_entry(&wire).unwrap(); + assert_eq!(entry, decoded); + } + + #[test] + fn id_types_usable_in_entry_kinds() { + let sid = SignalId::from_ulid(Ulid::new()); + let eid = EntityId::from_ulid(Ulid::new()); + let _ = LogEntryKind::SignalObserved { + signal_id: sid, + kind_tag: CompactString::from("t"), + }; + let _ = LogEntryKind::EntityCreated { + entity_id: eid, + kind_tag: CompactString::from("t"), + }; + } +}