aion_context/audit.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Audit trail structures for AION v2
3//!
4//! This module implements the embedded audit trail as specified in RFC-0002 and RFC-0019.
5//! All audit operations are logged with cryptographic hash chaining to prevent tampering.
6//!
7//! # Structure
8//!
9//! The audit trail is a hash-chained sequence of 80-byte entries. Each entry records:
10//! - **Timestamp** - Nanosecond-precision Unix timestamp
11//! - **Author** - Who performed the action
12//! - **Action** - What operation was performed
13//! - **Details** - Human-readable description (stored in string table)
14//! - **Chain Link** - BLAKE3 hash of previous entry
15//!
16//! # Hash Chain Integrity
17//!
18//! Each audit entry contains the BLAKE3 hash of the previous entry, forming an
19//! immutable chain. The genesis entry (first entry) has an all-zero previous hash.
20//! Any modification to an entry breaks the chain, making tampering evident.
21//!
22//! # Compliance
23//!
24//! The audit trail satisfies requirements for:
25//! - **SOX**: Comprehensive change tracking with non-repudiation
26//! - **HIPAA**: Access control and information system activity logging
27//! - **GDPR Article 30**: Records of processing activities
28//!
29//! # Usage Example
30//!
31//! ```
32//! use aion_context::audit::{AuditEntry, ActionCode};
33//! use aion_context::types::AuthorId;
34//!
35//! // Create genesis audit entry
36//! let entry = AuditEntry::new(
37//! 1_700_000_000_000_000_000, // timestamp in nanoseconds
38//! AuthorId(1001),
39//! ActionCode::CreateGenesis,
40//! 42, // details_offset in string table
41//! 15, // details_length
42//! [0u8; 32], // previous_hash (all zeros for genesis)
43//! );
44//!
45//! // Entry is exactly 80 bytes
46//! assert_eq!(std::mem::size_of_val(&entry), 80);
47//! ```
48//!
49//! # Serialization
50//!
51//! Audit entries use deterministic binary serialization with `#[repr(C)]` layout.
52//! All multi-byte integers are little-endian. The format is zero-copy compatible
53//! for efficient parsing.
54
55use crate::crypto::hash;
56use crate::types::AuthorId;
57use crate::{AionError, Result};
58
59/// Audit trail entry with hash chain integrity
60///
61/// Fixed 80-byte structure as specified in RFC-0002 Section 5.4.
62/// All integers are little-endian. Layout is `#[repr(C)]` for deterministic serialization.
63///
64/// # Memory Layout
65///
66/// ```text
67/// Offset Size Field
68/// ------ ---- -----
69/// 0 8 timestamp
70/// 8 8 author_id
71/// 16 2 action_code
72/// 18 6 reserved1
73/// 24 8 details_offset
74/// 32 4 details_length
75/// 36 4 reserved2
76/// 40 32 previous_hash
77/// 72 8 reserved3
78/// ------ ----
79/// Total: 80 bytes
80/// ```
81///
82/// # Examples
83///
84/// ```
85/// use aion_context::audit::{AuditEntry, ActionCode};
86/// use aion_context::types::AuthorId;
87///
88/// let entry = AuditEntry::new(
89/// 1_700_000_000_000_000_000,
90/// AuthorId(1001),
91/// ActionCode::CommitVersion,
92/// 100,
93/// 27,
94/// [0u8; 32],
95/// );
96///
97/// // Verify size
98/// assert_eq!(std::mem::size_of_val(&entry), 80);
99///
100/// // Access fields
101/// assert_eq!(entry.action_code().unwrap(), ActionCode::CommitVersion);
102/// assert_eq!(entry.author_id(), AuthorId(1001));
103/// ```
104#[repr(C)]
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub struct AuditEntry {
107 /// Timestamp in nanoseconds since Unix epoch
108 timestamp: u64,
109
110 /// Author who performed the action
111 author_id: u64,
112
113 /// Action code (see [`ActionCode`])
114 action_code: u16,
115
116 /// Reserved for future use (must be zero)
117 reserved1: [u8; 6],
118
119 /// Offset of details string in string table
120 details_offset: u64,
121
122 /// Length of details string (bytes, excluding null terminator)
123 details_length: u32,
124
125 /// Reserved for future use (must be zero)
126 reserved2: [u8; 4],
127
128 /// BLAKE3 hash of previous audit entry (all zeros for genesis)
129 previous_hash: [u8; 32],
130
131 /// Reserved for future use (must be zero)
132 reserved3: [u8; 8],
133}
134
135// Compile-time size verification
136const _: () = assert!(std::mem::size_of::<AuditEntry>() == 80);
137
138impl AuditEntry {
139 /// Create a new audit entry
140 ///
141 /// # Arguments
142 ///
143 /// * `timestamp` - Nanoseconds since Unix epoch (use `SystemTime::now()`)
144 /// * `author_id` - Author performing the action
145 /// * `action_code` - Type of operation (see [`ActionCode`])
146 /// * `details_offset` - Byte offset in string table
147 /// * `details_length` - Length of details string (excluding null)
148 /// * `previous_hash` - BLAKE3 hash of previous entry (all zeros for genesis)
149 ///
150 /// # Examples
151 ///
152 /// ```
153 /// use aion_context::audit::{AuditEntry, ActionCode};
154 /// use aion_context::types::AuthorId;
155 ///
156 /// let entry = AuditEntry::new(
157 /// 1_700_000_000_000_000_000,
158 /// AuthorId(1001),
159 /// ActionCode::Verify,
160 /// 200,
161 /// 42,
162 /// [0xAB; 32],
163 /// );
164 /// ```
165 #[must_use]
166 pub const fn new(
167 timestamp: u64,
168 author_id: AuthorId,
169 action_code: ActionCode,
170 details_offset: u64,
171 details_length: u32,
172 previous_hash: [u8; 32],
173 ) -> Self {
174 Self {
175 timestamp,
176 author_id: author_id.0,
177 action_code: action_code as u16,
178 reserved1: [0; 6],
179 details_offset,
180 details_length,
181 reserved2: [0; 4],
182 previous_hash,
183 reserved3: [0; 8],
184 }
185 }
186
187 /// Get the timestamp in nanoseconds
188 #[must_use]
189 pub const fn timestamp(&self) -> u64 {
190 self.timestamp
191 }
192
193 /// Get the author ID
194 #[must_use]
195 pub const fn author_id(&self) -> AuthorId {
196 AuthorId(self.author_id)
197 }
198
199 /// Get the action code
200 ///
201 /// # Errors
202 ///
203 /// Returns an error if the action code is not a valid enum variant
204 pub const fn action_code(&self) -> Result<ActionCode> {
205 ActionCode::from_u16(self.action_code)
206 }
207
208 /// Get the action code as raw u16 (no validation)
209 #[must_use]
210 pub const fn action_code_raw(&self) -> u16 {
211 self.action_code
212 }
213
214 /// Get the details offset in string table
215 #[must_use]
216 pub const fn details_offset(&self) -> u64 {
217 self.details_offset
218 }
219
220 /// Get the details string length (bytes, excluding null terminator)
221 #[must_use]
222 pub const fn details_length(&self) -> u32 {
223 self.details_length
224 }
225
226 /// Get the previous entry hash
227 #[must_use]
228 pub const fn previous_hash(&self) -> &[u8; 32] {
229 &self.previous_hash
230 }
231
232 /// Check if this is a genesis entry (first in chain)
233 ///
234 /// Genesis entries have an all-zero previous hash.
235 #[must_use]
236 pub fn is_genesis(&self) -> bool {
237 self.previous_hash == [0u8; 32]
238 }
239
240 /// Compute BLAKE3 hash of this entry
241 ///
242 /// The hash includes all 80 bytes of the entry. This hash becomes the
243 /// `previous_hash` value for the next entry in the chain.
244 ///
245 /// # Examples
246 ///
247 /// ```
248 /// use aion_context::audit::{AuditEntry, ActionCode};
249 /// use aion_context::types::AuthorId;
250 ///
251 /// let entry = AuditEntry::new(
252 /// 1_700_000_000_000_000_000,
253 /// AuthorId(1001),
254 /// ActionCode::CreateGenesis,
255 /// 0,
256 /// 10,
257 /// [0u8; 32],
258 /// );
259 ///
260 /// let entry_hash = entry.compute_hash();
261 /// assert_eq!(entry_hash.len(), 32);
262 /// ```
263 #[must_use]
264 pub fn compute_hash(&self) -> [u8; 32] {
265 hash(self.as_bytes())
266 }
267
268 /// Serialize entry to bytes (little-endian)
269 ///
270 /// Returns exactly 80 bytes in RFC-0002 specified format.
271 ///
272 /// # Safety
273 ///
274 /// This function is safe because:
275 /// 1. `AuditEntry` has `#[repr(C)]` for deterministic layout
276 /// 2. All fields are plain-old-data (POD) types
277 /// 3. The lifetime of the returned slice is tied to `self`
278 /// 4. The size is compile-time verified to be 80 bytes
279 #[must_use]
280 #[allow(unsafe_code)] // Necessary for zero-copy serialization
281 pub const fn as_bytes(&self) -> &[u8] {
282 // SAFETY: AuditEntry is repr(C) with POD fields, properly aligned
283 unsafe {
284 std::slice::from_raw_parts(
285 (self as *const Self).cast::<u8>(),
286 std::mem::size_of::<Self>(),
287 )
288 }
289 }
290
291 /// Deserialize entry from bytes
292 ///
293 /// # Errors
294 ///
295 /// Returns an error if the input is not exactly 80 bytes.
296 ///
297 /// # Safety
298 ///
299 /// This function is safe because:
300 /// 1. Length is validated to be exactly 80 bytes
301 /// 2. `AuditEntry` is `#[repr(C)]` with POD fields
302 /// 3. All bit patterns are valid for the field types
303 /// 4. No references or complex types that need initialization
304 ///
305 /// Note: Callers should validate field values (e.g., `action_code`) after deserialization.
306 #[allow(unsafe_code)] // Necessary for zero-copy deserialization
307 pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
308 if bytes.len() != 80 {
309 return Err(AionError::InvalidFormat {
310 reason: format!("AuditEntry must be exactly 80 bytes, got {}", bytes.len()),
311 });
312 }
313
314 // SAFETY: Length validated, repr(C) layout, all bit patterns valid for POD types
315 // Cast is safe: input slice is 80 bytes (verified above), struct size is 80 bytes
316 #[allow(clippy::cast_ptr_alignment)]
317 let entry = unsafe { std::ptr::read(bytes.as_ptr().cast::<Self>()) };
318
319 Ok(entry)
320 }
321
322 /// Validate this entry against the previous entry
323 ///
324 /// Checks that:
325 /// 1. The `previous_hash` matches the hash of `previous_entry`
326 /// 2. The timestamp is not before the previous entry
327 /// 3. Reserved fields are zero
328 ///
329 /// # Errors
330 ///
331 /// Returns an error if validation fails.
332 ///
333 /// # Examples
334 ///
335 /// ```
336 /// use aion_context::audit::{AuditEntry, ActionCode};
337 /// use aion_context::types::AuthorId;
338 ///
339 /// let genesis = AuditEntry::new(
340 /// 1_700_000_000_000_000_000,
341 /// AuthorId(1001),
342 /// ActionCode::CreateGenesis,
343 /// 0,
344 /// 10,
345 /// [0u8; 32],
346 /// );
347 ///
348 /// let genesis_hash = genesis.compute_hash();
349 ///
350 /// let entry2 = AuditEntry::new(
351 /// 1_700_000_001_000_000_000,
352 /// AuthorId(1002),
353 /// ActionCode::CommitVersion,
354 /// 10,
355 /// 15,
356 /// genesis_hash,
357 /// );
358 ///
359 /// assert!(entry2.validate_chain(&genesis).is_ok());
360 /// ```
361 pub fn validate_chain(&self, previous_entry: &Self) -> Result<()> {
362 // Check hash chain
363 let expected_hash = previous_entry.compute_hash();
364 if self.previous_hash != expected_hash {
365 tracing::warn!(
366 event = "audit_chain_broken",
367 timestamp = self.timestamp,
368 reason = "prev_hash_mismatch",
369 );
370 return Err(AionError::BrokenAuditChain {
371 expected: expected_hash,
372 actual: self.previous_hash,
373 });
374 }
375
376 // Check timestamp ordering (allow equal for concurrent operations)
377 if self.timestamp < previous_entry.timestamp {
378 tracing::warn!(
379 event = "audit_chain_broken",
380 timestamp = self.timestamp,
381 reason = "timestamp_regression",
382 );
383 return Err(AionError::InvalidTimestamp {
384 reason: format!(
385 "Entry timestamp {} is before previous entry {}",
386 self.timestamp, previous_entry.timestamp
387 ),
388 });
389 }
390
391 // Validate reserved fields are zero
392 if self.reserved1 != [0; 6] || self.reserved2 != [0; 4] || self.reserved3 != [0; 8] {
393 tracing::warn!(
394 event = "audit_chain_broken",
395 timestamp = self.timestamp,
396 reason = "reserved_nonzero",
397 );
398 return Err(AionError::InvalidFormat {
399 reason: "Reserved fields must be zero".to_string(),
400 });
401 }
402
403 Ok(())
404 }
405}
406
407/// Action codes for audit trail entries
408///
409/// As specified in RFC-0002, action codes indicate the type of operation
410/// being audited. Codes 1-4 are currently defined, 5-99 are reserved for
411/// future standard actions, and 100+ are available for custom extensions.
412///
413/// # Examples
414///
415/// ```
416/// use aion_context::audit::ActionCode;
417///
418/// // Standard actions
419/// let action = ActionCode::CommitVersion;
420/// assert_eq!(action as u16, 2);
421///
422/// // Round-trip through u16
423/// let code = ActionCode::from_u16(3).unwrap();
424/// assert_eq!(code, ActionCode::Verify);
425/// ```
426#[repr(u16)]
427#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
428pub enum ActionCode {
429 /// File creation with genesis version
430 CreateGenesis = 1,
431
432 /// New version committed to file
433 CommitVersion = 2,
434
435 /// Signature verification performed
436 Verify = 3,
437
438 /// File inspection/audit operation
439 Inspect = 4,
440}
441
442impl ActionCode {
443 /// Convert from raw u16 value
444 ///
445 /// # Errors
446 ///
447 /// Returns an error if the value is not a valid action code.
448 ///
449 /// # Examples
450 ///
451 /// ```
452 /// use aion_context::audit::ActionCode;
453 ///
454 /// assert_eq!(ActionCode::from_u16(1).unwrap(), ActionCode::CreateGenesis);
455 /// assert_eq!(ActionCode::from_u16(2).unwrap(), ActionCode::CommitVersion);
456 /// assert!(ActionCode::from_u16(99).is_err());
457 /// ```
458 pub const fn from_u16(value: u16) -> Result<Self> {
459 match value {
460 1 => Ok(Self::CreateGenesis),
461 2 => Ok(Self::CommitVersion),
462 3 => Ok(Self::Verify),
463 4 => Ok(Self::Inspect),
464 _ => Err(AionError::InvalidActionCode { code: value }),
465 }
466 }
467
468 /// Get human-readable description
469 ///
470 /// # Examples
471 ///
472 /// ```
473 /// use aion_context::audit::ActionCode;
474 ///
475 /// assert_eq!(ActionCode::CreateGenesis.description(), "Create genesis version");
476 /// assert_eq!(ActionCode::CommitVersion.description(), "Commit new version");
477 /// ```
478 #[must_use]
479 pub const fn description(self) -> &'static str {
480 match self {
481 Self::CreateGenesis => "Create genesis version",
482 Self::CommitVersion => "Commit new version",
483 Self::Verify => "Verify signatures",
484 Self::Inspect => "Inspect file",
485 }
486 }
487}
488
489#[cfg(test)]
490#[allow(clippy::unwrap_used)] // Allow unwrap in test code
491mod tests {
492 use super::*;
493
494 mod audit_entry {
495 use super::*;
496
497 #[test]
498 fn should_have_correct_size() {
499 assert_eq!(std::mem::size_of::<AuditEntry>(), 80);
500 }
501
502 #[test]
503 fn should_create_new_entry() {
504 let entry = AuditEntry::new(
505 1_700_000_000_000_000_000,
506 AuthorId(1001),
507 ActionCode::CreateGenesis,
508 0,
509 10,
510 [0u8; 32],
511 );
512
513 assert_eq!(entry.timestamp(), 1_700_000_000_000_000_000);
514 assert_eq!(entry.author_id(), AuthorId(1001));
515 assert_eq!(entry.action_code().unwrap(), ActionCode::CreateGenesis);
516 assert_eq!(entry.details_offset(), 0);
517 assert_eq!(entry.details_length(), 10);
518 assert_eq!(entry.previous_hash(), &[0u8; 32]);
519 }
520
521 #[test]
522 fn should_identify_genesis_entry() {
523 let genesis = AuditEntry::new(
524 1_700_000_000_000_000_000,
525 AuthorId(1001),
526 ActionCode::CreateGenesis,
527 0,
528 10,
529 [0u8; 32],
530 );
531
532 assert!(genesis.is_genesis());
533
534 let non_genesis = AuditEntry::new(
535 1_700_000_001_000_000_000,
536 AuthorId(1002),
537 ActionCode::CommitVersion,
538 10,
539 15,
540 [0xAB; 32],
541 );
542
543 assert!(!non_genesis.is_genesis());
544 }
545
546 #[test]
547 fn should_compute_hash() {
548 let entry = AuditEntry::new(
549 1_700_000_000_000_000_000,
550 AuthorId(1001),
551 ActionCode::CreateGenesis,
552 0,
553 10,
554 [0u8; 32],
555 );
556
557 let hash = entry.compute_hash();
558 assert_eq!(hash.len(), 32);
559
560 // Same entry should produce same hash
561 let hash2 = entry.compute_hash();
562 assert_eq!(hash, hash2);
563 }
564
565 #[test]
566 fn should_serialize_and_deserialize() {
567 let original = AuditEntry::new(
568 1_700_000_000_000_000_000,
569 AuthorId(1001),
570 ActionCode::CommitVersion,
571 42,
572 27,
573 [0xCD; 32],
574 );
575
576 let bytes = original.as_bytes();
577 assert_eq!(bytes.len(), 80);
578
579 let deserialized = AuditEntry::from_bytes(bytes).unwrap();
580 assert_eq!(deserialized, original);
581 }
582
583 #[test]
584 fn should_reject_invalid_size() {
585 let bytes = [0u8; 79];
586 let result = AuditEntry::from_bytes(&bytes);
587 assert!(result.is_err());
588
589 let bytes = [0u8; 81];
590 let result = AuditEntry::from_bytes(&bytes);
591 assert!(result.is_err());
592 }
593
594 #[test]
595 fn should_validate_chain() {
596 let genesis = AuditEntry::new(
597 1_700_000_000_000_000_000,
598 AuthorId(1001),
599 ActionCode::CreateGenesis,
600 0,
601 10,
602 [0u8; 32],
603 );
604
605 let genesis_hash = genesis.compute_hash();
606
607 let entry2 = AuditEntry::new(
608 1_700_000_001_000_000_000,
609 AuthorId(1002),
610 ActionCode::CommitVersion,
611 10,
612 15,
613 genesis_hash,
614 );
615
616 assert!(entry2.validate_chain(&genesis).is_ok());
617 }
618
619 #[test]
620 fn should_reject_broken_chain() {
621 let genesis = AuditEntry::new(
622 1_700_000_000_000_000_000,
623 AuthorId(1001),
624 ActionCode::CreateGenesis,
625 0,
626 10,
627 [0u8; 32],
628 );
629
630 let wrong_hash = [0xFF; 32];
631 let entry2 = AuditEntry::new(
632 1_700_000_001_000_000_000,
633 AuthorId(1002),
634 ActionCode::CommitVersion,
635 10,
636 15,
637 wrong_hash,
638 );
639
640 assert!(entry2.validate_chain(&genesis).is_err());
641 }
642
643 #[test]
644 fn should_reject_timestamp_regression() {
645 let entry1 = AuditEntry::new(
646 1_700_000_001_000_000_000,
647 AuthorId(1001),
648 ActionCode::CreateGenesis,
649 0,
650 10,
651 [0u8; 32],
652 );
653
654 let entry1_hash = entry1.compute_hash();
655
656 let entry2 = AuditEntry::new(
657 1_700_000_000_000_000_000, // Earlier timestamp
658 AuthorId(1002),
659 ActionCode::CommitVersion,
660 10,
661 15,
662 entry1_hash,
663 );
664
665 assert!(entry2.validate_chain(&entry1).is_err());
666 }
667
668 #[test]
669 fn should_allow_equal_timestamps() {
670 let timestamp = 1_700_000_000_000_000_000;
671 let entry1 = AuditEntry::new(
672 timestamp,
673 AuthorId(1001),
674 ActionCode::CreateGenesis,
675 0,
676 10,
677 [0u8; 32],
678 );
679
680 let entry1_hash = entry1.compute_hash();
681
682 let entry2 = AuditEntry::new(
683 timestamp, // Same timestamp (concurrent operations)
684 AuthorId(1002),
685 ActionCode::Verify,
686 10,
687 15,
688 entry1_hash,
689 );
690
691 assert!(entry2.validate_chain(&entry1).is_ok());
692 }
693 }
694
695 mod action_code {
696 use super::*;
697
698 #[test]
699 fn should_convert_from_u16() {
700 assert_eq!(ActionCode::from_u16(1).unwrap(), ActionCode::CreateGenesis);
701 assert_eq!(ActionCode::from_u16(2).unwrap(), ActionCode::CommitVersion);
702 assert_eq!(ActionCode::from_u16(3).unwrap(), ActionCode::Verify);
703 assert_eq!(ActionCode::from_u16(4).unwrap(), ActionCode::Inspect);
704 }
705
706 #[test]
707 fn should_reject_invalid_codes() {
708 assert!(ActionCode::from_u16(0).is_err());
709 assert!(ActionCode::from_u16(5).is_err());
710 assert!(ActionCode::from_u16(99).is_err());
711 assert!(ActionCode::from_u16(100).is_err());
712 }
713
714 #[test]
715 fn should_have_descriptions() {
716 assert_eq!(
717 ActionCode::CreateGenesis.description(),
718 "Create genesis version"
719 );
720 assert_eq!(
721 ActionCode::CommitVersion.description(),
722 "Commit new version"
723 );
724 assert_eq!(ActionCode::Verify.description(), "Verify signatures");
725 assert_eq!(ActionCode::Inspect.description(), "Inspect file");
726 }
727
728 #[test]
729 fn should_roundtrip_through_u16() {
730 let codes = [
731 ActionCode::CreateGenesis,
732 ActionCode::CommitVersion,
733 ActionCode::Verify,
734 ActionCode::Inspect,
735 ];
736
737 for code in codes {
738 let value = code as u16;
739 let recovered = ActionCode::from_u16(value).unwrap();
740 assert_eq!(recovered, code);
741 }
742 }
743 }
744
745 mod hash_chain {
746 use super::*;
747
748 #[test]
749 fn should_build_valid_chain() {
750 // Genesis entry
751 let entry1 = AuditEntry::new(
752 1_700_000_000_000_000_000,
753 AuthorId(1001),
754 ActionCode::CreateGenesis,
755 0,
756 10,
757 [0u8; 32],
758 );
759
760 // Chain to entry 2
761 let hash1 = entry1.compute_hash();
762 let entry2 = AuditEntry::new(
763 1_700_000_001_000_000_000,
764 AuthorId(1002),
765 ActionCode::CommitVersion,
766 10,
767 15,
768 hash1,
769 );
770
771 // Chain to entry 3
772 let hash2 = entry2.compute_hash();
773 let entry3 = AuditEntry::new(
774 1_700_000_002_000_000_000,
775 AuthorId(1003),
776 ActionCode::Verify,
777 25,
778 20,
779 hash2,
780 );
781
782 // Validate chain
783 assert!(entry2.validate_chain(&entry1).is_ok());
784 assert!(entry3.validate_chain(&entry2).is_ok());
785 }
786
787 #[test]
788 fn should_detect_missing_entry() {
789 let entry1 = AuditEntry::new(
790 1_700_000_000_000_000_000,
791 AuthorId(1001),
792 ActionCode::CreateGenesis,
793 0,
794 10,
795 [0u8; 32],
796 );
797
798 let hash1 = entry1.compute_hash();
799 let _entry2 = AuditEntry::new(
800 1_700_000_001_000_000_000,
801 AuthorId(1002),
802 ActionCode::CommitVersion,
803 10,
804 15,
805 hash1,
806 );
807
808 // Entry 3 claims to follow entry 2, but we try to validate against entry 1
809 let hash2 = [0xAB; 32]; // Wrong hash
810 let entry3 = AuditEntry::new(
811 1_700_000_002_000_000_000,
812 AuthorId(1003),
813 ActionCode::Verify,
814 25,
815 20,
816 hash2,
817 );
818
819 assert!(entry3.validate_chain(&entry1).is_err());
820 }
821 }
822
823 mod properties {
824 use super::*;
825 use hegel::generators as gs;
826
827 fn draw_action(tc: &hegel::TestCase) -> ActionCode {
828 match tc.draw(gs::integers::<u8>().min_value(1).max_value(4)) {
829 1 => ActionCode::CreateGenesis,
830 2 => ActionCode::CommitVersion,
831 3 => ActionCode::Verify,
832 _ => ActionCode::Inspect,
833 }
834 }
835
836 fn build_chain(tc: &hegel::TestCase, n: usize) -> Vec<AuditEntry> {
837 let ts0 = tc.draw(gs::integers::<u64>().min_value(1).max_value(1u64 << 60));
838 let author_id0 = tc.draw(gs::integers::<u64>());
839 let genesis =
840 AuditEntry::new(ts0, AuthorId(author_id0), draw_action(tc), 0, 0, [0u8; 32]);
841 let mut chain = Vec::with_capacity(n);
842 chain.push(genesis);
843 for _ in 1..n {
844 let prev = chain
845 .last()
846 .copied()
847 .unwrap_or_else(|| std::process::abort());
848 let dt = tc.draw(gs::integers::<u64>().max_value(10_000_000_000));
849 let ts = prev.timestamp().saturating_add(dt);
850 let entry = AuditEntry::new(
851 ts,
852 AuthorId(tc.draw(gs::integers::<u64>())),
853 draw_action(tc),
854 0,
855 0,
856 prev.compute_hash(),
857 );
858 chain.push(entry);
859 }
860 chain
861 }
862
863 #[hegel::test]
864 fn prop_append_validate_ok_for_any_n(tc: hegel::TestCase) {
865 let n = tc.draw(gs::integers::<usize>().min_value(2).max_value(20));
866 let chain = build_chain(&tc, n);
867 for i in 1..chain.len() {
868 let prev = chain
869 .get(i.saturating_sub(1))
870 .unwrap_or_else(|| std::process::abort());
871 let curr = chain.get(i).unwrap_or_else(|| std::process::abort());
872 assert!(curr.validate_chain(prev).is_ok());
873 }
874 }
875
876 #[hegel::test]
877 fn prop_tamper_previous_hash_breaks_validate(tc: hegel::TestCase) {
878 let n = tc.draw(gs::integers::<usize>().min_value(2).max_value(20));
879 let chain = build_chain(&tc, n);
880 let idx_max = n.saturating_sub(1);
881 let idx = tc.draw(gs::integers::<usize>().min_value(1).max_value(idx_max));
882 let entry = chain.get(idx).unwrap_or_else(|| std::process::abort());
883 let prev = chain
884 .get(idx.saturating_sub(1))
885 .unwrap_or_else(|| std::process::abort());
886 let mut bad_prev_hash = *entry.previous_hash();
887 if let Some(b) = bad_prev_hash.get_mut(0) {
888 *b ^= 0x01;
889 }
890 let tampered = AuditEntry::new(
891 entry.timestamp(),
892 entry.author_id(),
893 entry
894 .action_code()
895 .unwrap_or_else(|_| std::process::abort()),
896 entry.details_offset(),
897 entry.details_length(),
898 bad_prev_hash,
899 );
900 assert!(tampered.validate_chain(prev).is_err());
901 }
902 }
903}