use crate::crypto::hash;
use crate::types::AuthorId;
use crate::{AionError, Result};
#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AuditEntry {
timestamp: u64,
author_id: u64,
action_code: u16,
reserved1: [u8; 6],
details_offset: u64,
details_length: u32,
reserved2: [u8; 4],
previous_hash: [u8; 32],
reserved3: [u8; 8],
}
const _: () = assert!(std::mem::size_of::<AuditEntry>() == 80);
impl AuditEntry {
#[must_use]
pub const fn new(
timestamp: u64,
author_id: AuthorId,
action_code: ActionCode,
details_offset: u64,
details_length: u32,
previous_hash: [u8; 32],
) -> Self {
Self {
timestamp,
author_id: author_id.0,
action_code: action_code as u16,
reserved1: [0; 6],
details_offset,
details_length,
reserved2: [0; 4],
previous_hash,
reserved3: [0; 8],
}
}
#[must_use]
pub const fn timestamp(&self) -> u64 {
self.timestamp
}
#[must_use]
pub const fn author_id(&self) -> AuthorId {
AuthorId(self.author_id)
}
pub const fn action_code(&self) -> Result<ActionCode> {
ActionCode::from_u16(self.action_code)
}
#[must_use]
pub const fn action_code_raw(&self) -> u16 {
self.action_code
}
#[must_use]
pub const fn details_offset(&self) -> u64 {
self.details_offset
}
#[must_use]
pub const fn details_length(&self) -> u32 {
self.details_length
}
#[must_use]
pub const fn previous_hash(&self) -> &[u8; 32] {
&self.previous_hash
}
#[must_use]
pub fn is_genesis(&self) -> bool {
self.previous_hash == [0u8; 32]
}
#[must_use]
pub fn compute_hash(&self) -> [u8; 32] {
hash(self.as_bytes())
}
#[must_use]
#[allow(unsafe_code)] pub const fn as_bytes(&self) -> &[u8] {
unsafe {
std::slice::from_raw_parts(
(self as *const Self).cast::<u8>(),
std::mem::size_of::<Self>(),
)
}
}
#[allow(unsafe_code)] pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
if bytes.len() != 80 {
return Err(AionError::InvalidFormat {
reason: format!("AuditEntry must be exactly 80 bytes, got {}", bytes.len()),
});
}
#[allow(clippy::cast_ptr_alignment)]
let entry = unsafe { std::ptr::read(bytes.as_ptr().cast::<Self>()) };
Ok(entry)
}
pub fn validate_chain(&self, previous_entry: &Self) -> Result<()> {
let expected_hash = previous_entry.compute_hash();
if self.previous_hash != expected_hash {
tracing::warn!(
event = "audit_chain_broken",
timestamp = self.timestamp,
reason = "prev_hash_mismatch",
);
return Err(AionError::BrokenAuditChain {
expected: expected_hash,
actual: self.previous_hash,
});
}
if self.timestamp < previous_entry.timestamp {
tracing::warn!(
event = "audit_chain_broken",
timestamp = self.timestamp,
reason = "timestamp_regression",
);
return Err(AionError::InvalidTimestamp {
reason: format!(
"Entry timestamp {} is before previous entry {}",
self.timestamp, previous_entry.timestamp
),
});
}
if self.reserved1 != [0; 6] || self.reserved2 != [0; 4] || self.reserved3 != [0; 8] {
tracing::warn!(
event = "audit_chain_broken",
timestamp = self.timestamp,
reason = "reserved_nonzero",
);
return Err(AionError::InvalidFormat {
reason: "Reserved fields must be zero".to_string(),
});
}
Ok(())
}
}
#[repr(u16)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ActionCode {
CreateGenesis = 1,
CommitVersion = 2,
Verify = 3,
Inspect = 4,
}
impl ActionCode {
pub const fn from_u16(value: u16) -> Result<Self> {
match value {
1 => Ok(Self::CreateGenesis),
2 => Ok(Self::CommitVersion),
3 => Ok(Self::Verify),
4 => Ok(Self::Inspect),
_ => Err(AionError::InvalidActionCode { code: value }),
}
}
#[must_use]
pub const fn description(self) -> &'static str {
match self {
Self::CreateGenesis => "Create genesis version",
Self::CommitVersion => "Commit new version",
Self::Verify => "Verify signatures",
Self::Inspect => "Inspect file",
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] mod tests {
use super::*;
mod audit_entry {
use super::*;
#[test]
fn should_have_correct_size() {
assert_eq!(std::mem::size_of::<AuditEntry>(), 80);
}
#[test]
fn should_create_new_entry() {
let entry = AuditEntry::new(
1_700_000_000_000_000_000,
AuthorId(1001),
ActionCode::CreateGenesis,
0,
10,
[0u8; 32],
);
assert_eq!(entry.timestamp(), 1_700_000_000_000_000_000);
assert_eq!(entry.author_id(), AuthorId(1001));
assert_eq!(entry.action_code().unwrap(), ActionCode::CreateGenesis);
assert_eq!(entry.details_offset(), 0);
assert_eq!(entry.details_length(), 10);
assert_eq!(entry.previous_hash(), &[0u8; 32]);
}
#[test]
fn should_identify_genesis_entry() {
let genesis = AuditEntry::new(
1_700_000_000_000_000_000,
AuthorId(1001),
ActionCode::CreateGenesis,
0,
10,
[0u8; 32],
);
assert!(genesis.is_genesis());
let non_genesis = AuditEntry::new(
1_700_000_001_000_000_000,
AuthorId(1002),
ActionCode::CommitVersion,
10,
15,
[0xAB; 32],
);
assert!(!non_genesis.is_genesis());
}
#[test]
fn should_compute_hash() {
let entry = AuditEntry::new(
1_700_000_000_000_000_000,
AuthorId(1001),
ActionCode::CreateGenesis,
0,
10,
[0u8; 32],
);
let hash = entry.compute_hash();
assert_eq!(hash.len(), 32);
let hash2 = entry.compute_hash();
assert_eq!(hash, hash2);
}
#[test]
fn should_serialize_and_deserialize() {
let original = AuditEntry::new(
1_700_000_000_000_000_000,
AuthorId(1001),
ActionCode::CommitVersion,
42,
27,
[0xCD; 32],
);
let bytes = original.as_bytes();
assert_eq!(bytes.len(), 80);
let deserialized = AuditEntry::from_bytes(bytes).unwrap();
assert_eq!(deserialized, original);
}
#[test]
fn should_reject_invalid_size() {
let bytes = [0u8; 79];
let result = AuditEntry::from_bytes(&bytes);
assert!(result.is_err());
let bytes = [0u8; 81];
let result = AuditEntry::from_bytes(&bytes);
assert!(result.is_err());
}
#[test]
fn should_validate_chain() {
let genesis = AuditEntry::new(
1_700_000_000_000_000_000,
AuthorId(1001),
ActionCode::CreateGenesis,
0,
10,
[0u8; 32],
);
let genesis_hash = genesis.compute_hash();
let entry2 = AuditEntry::new(
1_700_000_001_000_000_000,
AuthorId(1002),
ActionCode::CommitVersion,
10,
15,
genesis_hash,
);
assert!(entry2.validate_chain(&genesis).is_ok());
}
#[test]
fn should_reject_broken_chain() {
let genesis = AuditEntry::new(
1_700_000_000_000_000_000,
AuthorId(1001),
ActionCode::CreateGenesis,
0,
10,
[0u8; 32],
);
let wrong_hash = [0xFF; 32];
let entry2 = AuditEntry::new(
1_700_000_001_000_000_000,
AuthorId(1002),
ActionCode::CommitVersion,
10,
15,
wrong_hash,
);
assert!(entry2.validate_chain(&genesis).is_err());
}
#[test]
fn should_reject_timestamp_regression() {
let entry1 = AuditEntry::new(
1_700_000_001_000_000_000,
AuthorId(1001),
ActionCode::CreateGenesis,
0,
10,
[0u8; 32],
);
let entry1_hash = entry1.compute_hash();
let entry2 = AuditEntry::new(
1_700_000_000_000_000_000, AuthorId(1002),
ActionCode::CommitVersion,
10,
15,
entry1_hash,
);
assert!(entry2.validate_chain(&entry1).is_err());
}
#[test]
fn should_allow_equal_timestamps() {
let timestamp = 1_700_000_000_000_000_000;
let entry1 = AuditEntry::new(
timestamp,
AuthorId(1001),
ActionCode::CreateGenesis,
0,
10,
[0u8; 32],
);
let entry1_hash = entry1.compute_hash();
let entry2 = AuditEntry::new(
timestamp, AuthorId(1002),
ActionCode::Verify,
10,
15,
entry1_hash,
);
assert!(entry2.validate_chain(&entry1).is_ok());
}
}
mod action_code {
use super::*;
#[test]
fn should_convert_from_u16() {
assert_eq!(ActionCode::from_u16(1).unwrap(), ActionCode::CreateGenesis);
assert_eq!(ActionCode::from_u16(2).unwrap(), ActionCode::CommitVersion);
assert_eq!(ActionCode::from_u16(3).unwrap(), ActionCode::Verify);
assert_eq!(ActionCode::from_u16(4).unwrap(), ActionCode::Inspect);
}
#[test]
fn should_reject_invalid_codes() {
assert!(ActionCode::from_u16(0).is_err());
assert!(ActionCode::from_u16(5).is_err());
assert!(ActionCode::from_u16(99).is_err());
assert!(ActionCode::from_u16(100).is_err());
}
#[test]
fn should_have_descriptions() {
assert_eq!(
ActionCode::CreateGenesis.description(),
"Create genesis version"
);
assert_eq!(
ActionCode::CommitVersion.description(),
"Commit new version"
);
assert_eq!(ActionCode::Verify.description(), "Verify signatures");
assert_eq!(ActionCode::Inspect.description(), "Inspect file");
}
#[test]
fn should_roundtrip_through_u16() {
let codes = [
ActionCode::CreateGenesis,
ActionCode::CommitVersion,
ActionCode::Verify,
ActionCode::Inspect,
];
for code in codes {
let value = code as u16;
let recovered = ActionCode::from_u16(value).unwrap();
assert_eq!(recovered, code);
}
}
}
mod hash_chain {
use super::*;
#[test]
fn should_build_valid_chain() {
let entry1 = AuditEntry::new(
1_700_000_000_000_000_000,
AuthorId(1001),
ActionCode::CreateGenesis,
0,
10,
[0u8; 32],
);
let hash1 = entry1.compute_hash();
let entry2 = AuditEntry::new(
1_700_000_001_000_000_000,
AuthorId(1002),
ActionCode::CommitVersion,
10,
15,
hash1,
);
let hash2 = entry2.compute_hash();
let entry3 = AuditEntry::new(
1_700_000_002_000_000_000,
AuthorId(1003),
ActionCode::Verify,
25,
20,
hash2,
);
assert!(entry2.validate_chain(&entry1).is_ok());
assert!(entry3.validate_chain(&entry2).is_ok());
}
#[test]
fn should_detect_missing_entry() {
let entry1 = AuditEntry::new(
1_700_000_000_000_000_000,
AuthorId(1001),
ActionCode::CreateGenesis,
0,
10,
[0u8; 32],
);
let hash1 = entry1.compute_hash();
let _entry2 = AuditEntry::new(
1_700_000_001_000_000_000,
AuthorId(1002),
ActionCode::CommitVersion,
10,
15,
hash1,
);
let hash2 = [0xAB; 32]; let entry3 = AuditEntry::new(
1_700_000_002_000_000_000,
AuthorId(1003),
ActionCode::Verify,
25,
20,
hash2,
);
assert!(entry3.validate_chain(&entry1).is_err());
}
}
mod properties {
use super::*;
use hegel::generators as gs;
fn draw_action(tc: &hegel::TestCase) -> ActionCode {
match tc.draw(gs::integers::<u8>().min_value(1).max_value(4)) {
1 => ActionCode::CreateGenesis,
2 => ActionCode::CommitVersion,
3 => ActionCode::Verify,
_ => ActionCode::Inspect,
}
}
fn build_chain(tc: &hegel::TestCase, n: usize) -> Vec<AuditEntry> {
let ts0 = tc.draw(gs::integers::<u64>().min_value(1).max_value(1u64 << 60));
let author_id0 = tc.draw(gs::integers::<u64>());
let genesis =
AuditEntry::new(ts0, AuthorId(author_id0), draw_action(tc), 0, 0, [0u8; 32]);
let mut chain = Vec::with_capacity(n);
chain.push(genesis);
for _ in 1..n {
let prev = chain
.last()
.copied()
.unwrap_or_else(|| std::process::abort());
let dt = tc.draw(gs::integers::<u64>().max_value(10_000_000_000));
let ts = prev.timestamp().saturating_add(dt);
let entry = AuditEntry::new(
ts,
AuthorId(tc.draw(gs::integers::<u64>())),
draw_action(tc),
0,
0,
prev.compute_hash(),
);
chain.push(entry);
}
chain
}
#[hegel::test]
fn prop_append_validate_ok_for_any_n(tc: hegel::TestCase) {
let n = tc.draw(gs::integers::<usize>().min_value(2).max_value(20));
let chain = build_chain(&tc, n);
for i in 1..chain.len() {
let prev = chain
.get(i.saturating_sub(1))
.unwrap_or_else(|| std::process::abort());
let curr = chain.get(i).unwrap_or_else(|| std::process::abort());
assert!(curr.validate_chain(prev).is_ok());
}
}
#[hegel::test]
fn prop_tamper_previous_hash_breaks_validate(tc: hegel::TestCase) {
let n = tc.draw(gs::integers::<usize>().min_value(2).max_value(20));
let chain = build_chain(&tc, n);
let idx_max = n.saturating_sub(1);
let idx = tc.draw(gs::integers::<usize>().min_value(1).max_value(idx_max));
let entry = chain.get(idx).unwrap_or_else(|| std::process::abort());
let prev = chain
.get(idx.saturating_sub(1))
.unwrap_or_else(|| std::process::abort());
let mut bad_prev_hash = *entry.previous_hash();
if let Some(b) = bad_prev_hash.get_mut(0) {
*b ^= 0x01;
}
let tampered = AuditEntry::new(
entry.timestamp(),
entry.author_id(),
entry
.action_code()
.unwrap_or_else(|_| std::process::abort()),
entry.details_offset(),
entry.details_length(),
bad_prev_hash,
);
assert!(tampered.validate_chain(prev).is_err());
}
}
}