Skip to main content

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}