Skip to main content

aion_context/
parser.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Zero-copy parser for AION v2 file format
3//!
4//! This module provides efficient, allocation-free parsing of AION v2 binary files
5//! using the `zerocopy` crate. It supports both in-memory and memory-mapped file access.
6//!
7//! # Format Overview (RFC-0002)
8//!
9//! ```text
10//! ┌─────────────────────────────────────┐
11//! │  FILE HEADER (256 bytes)            │ ← Zero-copy parsed
12//! ├─────────────────────────────────────┤
13//! │  ENCRYPTED RULES (variable)         │ ← Slice reference
14//! ├─────────────────────────────────────┤
15//! │  VERSION CHAIN (152 bytes/entry)    │ ← Slice reference
16//! ├─────────────────────────────────────┤
17//! │  SIGNATURES (112 bytes/entry)       │ ← Slice reference
18//! ├─────────────────────────────────────┤
19//! │  AUDIT TRAIL (80+ bytes/entry)      │ ← Slice reference
20//! ├─────────────────────────────────────┤
21//! │  STRING TABLE (variable)            │ ← Slice reference
22//! ├─────────────────────────────────────┤
23//! │  FILE INTEGRITY HASH (32 bytes)     │ ← Slice reference
24//! └─────────────────────────────────────┘
25//! ```
26//!
27//! # Zero-Copy Benefits
28//!
29//! - **No allocations**: Direct byte slice references
30//! - **Memory-mapped I/O**: OS-level caching and lazy loading
31//! - **Fast random access**: Jump to any section instantly
32//! - **Minimal overhead**: ~100ns to parse header vs ~10µs with serde
33//!
34//! # Usage
35//!
36//! ## In-Memory Parsing
37//!
38//! ```no_run
39//! use aion_context::parser::AionParser;
40//!
41//! # fn example() -> aion_context::Result<()> {
42//! let data = std::fs::read("file.aion").map_err(|e| aion_context::AionError::FileReadError {
43//!     path: "file.aion".into(),
44//!     source: e,
45//! })?;
46//! let parser = AionParser::new(&data)?;
47//!
48//! // Zero-copy header access
49//! let header = parser.header();
50//! println!("File version: {}", header.current_version());
51//!
52//! // Zero-copy section access
53//! let string_table_bytes = parser.string_table_bytes()?;
54//! # Ok(())
55//! # }
56//! ```
57//!
58//! ## Memory-Mapped Parsing
59//!
60//! ```no_run
61//! use aion_context::parser::MmapParser;
62//!
63//! # fn example() -> aion_context::Result<()> {
64//! let parser = MmapParser::open("large_file.aion")?;
65//!
66//! // OS handles memory management
67//! let header = parser.header();
68//! let versions = parser.version_chain_bytes()?;
69//! # Ok(())
70//! # }
71//! ```
72
73use crate::crypto::hash;
74use crate::{AionError, Result};
75use std::path::Path;
76use zerocopy::{AsBytes, FromBytes, FromZeroes};
77
78/// Magic number for AION v2 files: "AION" (0x41494F4E)
79pub const MAGIC: [u8; 4] = [0x41, 0x49, 0x4F, 0x4E];
80
81/// Current file format version
82pub const FORMAT_VERSION: u16 = 2;
83
84/// Header size in bytes (fixed)
85pub const HEADER_SIZE: usize = 256;
86
87/// Version chain entry size in bytes (fixed)
88pub const VERSION_ENTRY_SIZE: usize = 152;
89
90/// Signature entry size in bytes (fixed)
91pub const SIGNATURE_ENTRY_SIZE: usize = 112;
92
93/// File integrity hash size (BLAKE3)
94pub const HASH_SIZE: usize = 32;
95
96/// File header structure (256 bytes, RFC-0002 Section 3.1)
97///
98/// This struct uses `zerocopy` for zero-copy parsing from byte slices.
99/// All integers are little-endian.
100///
101/// # Examples
102///
103/// ```
104/// use aion_context::parser::FileHeader;
105/// use zerocopy::FromBytes;
106///
107/// # fn example() -> Option<()> {
108/// let data = vec![0u8; 256];
109/// let header = FileHeader::read_from_prefix(&data)?;
110/// # Some(())
111/// # }
112/// ```
113#[derive(Debug, Clone, Copy, AsBytes, FromBytes, FromZeroes)]
114#[repr(C)]
115pub struct FileHeader {
116    /// Magic number: "AION" (0x41494F4E)
117    pub magic: [u8; 4],
118
119    /// Format version (current = 2)
120    pub version: u16,
121
122    /// Feature flags
123    /// - Bit 0: Encrypted (1 = encrypted, 0 = plaintext)
124    /// - Bit 1-15: Reserved (must be 0)
125    pub flags: u16,
126
127    /// Unique file identifier
128    pub file_id: u64,
129
130    /// Current version number (monotonically increasing)
131    pub current_version: u64,
132
133    /// Root hash (BLAKE3, genesis version)
134    pub root_hash: [u8; 32],
135
136    /// Current hash (BLAKE3, latest version)
137    pub current_hash: [u8; 32],
138
139    /// Creation timestamp (nanoseconds since Unix epoch)
140    pub created_at: u64,
141
142    /// Last modification timestamp
143    pub modified_at: u64,
144
145    /// Encrypted rules section offset
146    pub encrypted_rules_offset: u64,
147
148    /// Encrypted rules section length (bytes)
149    pub encrypted_rules_length: u64,
150
151    /// Version chain section offset
152    pub version_chain_offset: u64,
153
154    /// Version chain count (number of entries)
155    pub version_chain_count: u64,
156
157    /// Signatures section offset
158    pub signatures_offset: u64,
159
160    /// Signatures count
161    pub signatures_count: u64,
162
163    /// Audit trail section offset
164    pub audit_trail_offset: u64,
165
166    /// Audit trail count
167    pub audit_trail_count: u64,
168
169    /// String table offset
170    pub string_table_offset: u64,
171
172    /// String table length
173    pub string_table_length: u64,
174
175    /// Reserved bytes (must be zero)
176    pub reserved: [u8; 72],
177}
178
179// Compile-time size check
180const _: () = assert!(std::mem::size_of::<FileHeader>() == HEADER_SIZE);
181
182impl FileHeader {
183    /// Validate header magic number
184    ///
185    /// # Examples
186    ///
187    /// ```
188    /// use aion_context::parser::FileHeader;
189    ///
190    /// let mut header = FileHeader::default();
191    /// assert!(header.is_valid_magic());
192    ///
193    /// header.magic = *b"XXXX";
194    /// assert!(!header.is_valid_magic());
195    ///
196    /// header.magic = *b"AION";
197    /// assert!(header.is_valid_magic());
198    /// ```
199    #[must_use]
200    pub const fn is_valid_magic(&self) -> bool {
201        self.magic[0] == MAGIC[0]
202            && self.magic[1] == MAGIC[1]
203            && self.magic[2] == MAGIC[2]
204            && self.magic[3] == MAGIC[3]
205    }
206
207    /// Check if file is encrypted
208    ///
209    /// # Examples
210    ///
211    /// ```
212    /// use aion_context::parser::FileHeader;
213    ///
214    /// let mut header = FileHeader::default();
215    /// assert!(!header.is_encrypted());
216    ///
217    /// header.flags = 0x0001; // Set encrypted bit
218    /// assert!(header.is_encrypted());
219    /// ```
220    #[must_use]
221    pub const fn is_encrypted(&self) -> bool {
222        (self.flags & 0x0001) != 0
223    }
224
225    /// Get file ID as `FileId` type
226    ///
227    /// # Examples
228    ///
229    /// ```
230    /// use aion_context::parser::FileHeader;
231    /// use aion_context::types::FileId;
232    ///
233    /// let mut header = FileHeader::default();
234    /// header.file_id = 42;
235    /// assert_eq!(header.file_id(), FileId(42));
236    /// ```
237    #[must_use]
238    pub const fn file_id(&self) -> crate::types::FileId {
239        crate::types::FileId(self.file_id)
240    }
241
242    /// Get current version as `VersionNumber` type
243    #[must_use]
244    pub const fn current_version(&self) -> crate::types::VersionNumber {
245        crate::types::VersionNumber(self.current_version)
246    }
247
248    /// Validate header structure
249    ///
250    /// Checks:
251    /// - Magic number is correct
252    /// - Version is supported
253    /// - Reserved bytes are zero
254    ///
255    /// # Errors
256    ///
257    /// Returns error if validation fails
258    pub fn validate(&self) -> Result<()> {
259        // Check magic number
260        if !self.is_valid_magic() {
261            return Err(AionError::InvalidFormat {
262                reason: format!(
263                    "Invalid magic number: expected {:?}, got {:?}",
264                    MAGIC, self.magic
265                ),
266            });
267        }
268
269        // Check version
270        if self.version != FORMAT_VERSION {
271            return Err(AionError::UnsupportedVersion {
272                version: self.version,
273                supported: FORMAT_VERSION.to_string(),
274            });
275        }
276
277        // Check reserved bits in flags (bits 1-15 must be 0)
278        if (self.flags & !0x0001) != 0 {
279            return Err(AionError::InvalidFormat {
280                reason: format!("Invalid flags: reserved bits set (0x{:04x})", self.flags),
281            });
282        }
283
284        // Check reserved bytes are zero
285        if self.reserved.iter().any(|&b| b != 0) {
286            return Err(AionError::InvalidFormat {
287                reason: "Reserved bytes must be zero".to_string(),
288            });
289        }
290
291        Ok(())
292    }
293}
294
295impl Default for FileHeader {
296    fn default() -> Self {
297        Self {
298            magic: MAGIC,
299            version: FORMAT_VERSION,
300            flags: 0,
301            file_id: 0,
302            current_version: 0,
303            root_hash: [0; 32],
304            current_hash: [0; 32],
305            created_at: 0,
306            modified_at: 0,
307            encrypted_rules_offset: 0,
308            encrypted_rules_length: 0,
309            version_chain_offset: 0,
310            version_chain_count: 0,
311            signatures_offset: 0,
312            signatures_count: 0,
313            audit_trail_offset: 0,
314            audit_trail_count: 0,
315            string_table_offset: 0,
316            string_table_length: 0,
317            reserved: [0; 72],
318        }
319    }
320}
321
322/// Zero-copy parser for AION v2 files
323///
324/// Provides efficient, allocation-free access to file sections using
325/// direct byte slice references.
326///
327/// # Examples
328///
329/// ```
330/// use aion_context::parser::AionParser;
331///
332/// # fn example() -> aion_context::Result<()> {
333/// let data = vec![0u8; 1024]; // Mock file data
334/// let parser = AionParser::new(&data)?;
335///
336/// let header = parser.header();
337/// println!("File ID: {}", header.file_id);
338/// # Ok(())
339/// # }
340/// ```
341#[derive(Debug)]
342pub struct AionParser<'a> {
343    /// Complete file data
344    data: &'a [u8],
345    /// Header reference, parsed once by `new` and cached. Binding
346    /// the reference at construction time lets `header()` stay
347    /// infallible without an `.expect()` — the Tiger Style fix for
348    /// RFC-0033 C2.
349    header: &'a FileHeader,
350}
351
352impl<'a> AionParser<'a> {
353    /// Create a new parser from byte data
354    ///
355    /// # Errors
356    ///
357    /// Returns error if:
358    /// - Data is too small for header
359    /// - Header validation fails
360    ///
361    /// # Examples
362    ///
363    /// ```
364    /// use aion_context::parser::AionParser;
365    ///
366    /// # fn example() -> aion_context::Result<()> {
367    /// let data = vec![0u8; 256];
368    /// let result = AionParser::new(&data);
369    /// assert!(result.is_err()); // Invalid magic
370    /// # Ok(())
371    /// # }
372    /// ```
373    pub fn new(data: &'a [u8]) -> Result<Self> {
374        // Check minimum size
375        if data.len() < HEADER_SIZE {
376            tracing::warn!(
377                event = "parser_rejected",
378                bytes = data.len(),
379                reason = "truncated_input",
380            );
381            return Err(AionError::InvalidFormat {
382                reason: format!(
383                    "File too small: {} bytes (minimum: {} bytes)",
384                    data.len(),
385                    HEADER_SIZE
386                ),
387            });
388        }
389
390        // Parse and validate header (zero-copy). The reference is
391        // cached on `self` so `header()` can stay infallible without
392        // re-running `ref_from_prefix` on every call.
393        let header = FileHeader::ref_from_prefix(data).ok_or_else(|| {
394            tracing::warn!(
395                event = "parser_rejected",
396                bytes = data.len(),
397                reason = "header_unparseable",
398            );
399            AionError::InvalidFormat {
400                reason: "Failed to parse header".to_string(),
401            }
402        })?;
403        header.validate().map_err(|e| {
404            tracing::warn!(
405                event = "parser_rejected",
406                bytes = data.len(),
407                reason = "header_invalid",
408            );
409            e
410        })?;
411
412        Ok(Self { data, header })
413    }
414
415    /// Get reference to the file header.
416    ///
417    /// Zero-copy: the reference was bound to the underlying data
418    /// slice by `new` and cached on the parser, so this accessor is
419    /// a simple field read and cannot fail.
420    #[must_use]
421    pub const fn header(&self) -> &'a FileHeader {
422        self.header
423    }
424
425    /// Get encrypted rules section as byte slice
426    ///
427    /// # Errors
428    ///
429    /// Returns error if section bounds are invalid
430    #[allow(clippy::cast_possible_truncation)] // File offsets fit in usize
431    pub fn encrypted_rules_bytes(&self) -> Result<&'a [u8]> {
432        let header = self.header();
433        self.get_section(
434            header.encrypted_rules_offset as usize,
435            header.encrypted_rules_length as usize,
436            "encrypted rules",
437        )
438    }
439
440    /// Get version chain section as byte slice
441    ///
442    /// # Errors
443    ///
444    /// Returns error if section bounds are invalid
445    #[allow(clippy::cast_possible_truncation)] // File offsets fit in usize
446    pub fn version_chain_bytes(&self) -> Result<&'a [u8]> {
447        let header = self.header();
448        let size = header
449            .version_chain_count
450            .checked_mul(VERSION_ENTRY_SIZE as u64)
451            .ok_or_else(|| AionError::InvalidFormat {
452                reason: "Version chain size overflow".to_string(),
453            })?;
454
455        self.get_section(
456            header.version_chain_offset as usize,
457            size as usize,
458            "version chain",
459        )
460    }
461
462    /// Get signatures section as byte slice
463    ///
464    /// # Errors
465    ///
466    /// Returns error if section bounds are invalid
467    #[allow(clippy::cast_possible_truncation)] // File offsets fit in usize
468    pub fn signatures_bytes(&self) -> Result<&'a [u8]> {
469        let header = self.header();
470        let size = header
471            .signatures_count
472            .checked_mul(SIGNATURE_ENTRY_SIZE as u64)
473            .ok_or_else(|| AionError::InvalidFormat {
474                reason: "Signatures size overflow".to_string(),
475            })?;
476
477        self.get_section(
478            header.signatures_offset as usize,
479            size as usize,
480            "signatures",
481        )
482    }
483
484    /// Get audit trail section as byte slice
485    ///
486    /// Note: Audit entries are variable-length, so this returns raw bytes.
487    /// Use audit module to parse individual entries.
488    ///
489    /// # Errors
490    ///
491    /// Returns error if section bounds are invalid
492    #[allow(clippy::cast_possible_truncation)] // File offsets fit in usize
493    #[allow(clippy::arithmetic_side_effects)] // Checked above
494    pub fn audit_trail_bytes(&self) -> Result<&'a [u8]> {
495        // Audit trail extends to string table
496        let header = self.header();
497        let start = header.audit_trail_offset as usize;
498        let end = header.string_table_offset as usize;
499
500        if end < start {
501            return Err(AionError::InvalidFormat {
502                reason: "Audit trail end before start".to_string(),
503            });
504        }
505
506        self.get_section(start, end - start, "audit trail")
507    }
508
509    /// Get string table section as byte slice
510    ///
511    /// # Errors
512    ///
513    /// Returns error if section bounds are invalid
514    #[allow(clippy::cast_possible_truncation)] // File offsets fit in usize
515    pub fn string_table_bytes(&self) -> Result<&'a [u8]> {
516        let header = self.header();
517        self.get_section(
518            header.string_table_offset as usize,
519            header.string_table_length as usize,
520            "string table",
521        )
522    }
523
524    /// Get file integrity hash (last 32 bytes)
525    ///
526    /// # Errors
527    ///
528    /// Returns error if file is too small
529    #[allow(clippy::arithmetic_side_effects)] // Checked above
530    #[allow(clippy::indexing_slicing)] // Bounds checked
531    pub fn integrity_hash(&self) -> Result<&'a [u8; HASH_SIZE]> {
532        if self.data.len() < HASH_SIZE {
533            return Err(AionError::InvalidFormat {
534                reason: format!(
535                    "File too small for integrity hash: {} bytes",
536                    self.data.len()
537                ),
538            });
539        }
540
541        let start = self.data.len() - HASH_SIZE;
542        self.data[start..]
543            .try_into()
544            .map_err(|_| AionError::InvalidFormat {
545                reason: "Failed to extract integrity hash".to_string(),
546            })
547    }
548
549    /// Verify file integrity by computing BLAKE3 hash and comparing
550    ///
551    /// Computes the hash of all bytes except the final 32-byte hash,
552    /// then compares with the stored hash.
553    ///
554    /// # Errors
555    ///
556    /// Returns `AionError::CorruptedFile` if the hash doesn't match,
557    /// indicating the file has been corrupted or tampered with.
558    ///
559    /// # Examples
560    ///
561    /// ```
562    /// use aion_context::parser::AionParser;
563    ///
564    /// # fn example() -> aion_context::Result<()> {
565    /// # let data = vec![0u8; 288]; // Mock - would need valid file
566    /// # return Ok(()); // Skip actual test
567    /// let parser = AionParser::new(&data)?;
568    /// parser.verify_integrity()?; // Returns Ok if valid
569    /// # Ok(())
570    /// # }
571    /// ```
572    #[allow(clippy::arithmetic_side_effects)] // Checked in integrity_hash
573    #[allow(clippy::indexing_slicing)] // Bounds checked
574    pub fn verify_integrity(&self) -> Result<()> {
575        let stored_hash = self.integrity_hash()?;
576        let hash_offset = self.data.len() - HASH_SIZE;
577        let computed_hash = hash(&self.data[..hash_offset]);
578
579        if stored_hash != &computed_hash {
580            return Err(AionError::CorruptedFile {
581                expected: hex::encode(stored_hash),
582                actual: hex::encode(computed_hash),
583            });
584        }
585
586        Ok(())
587    }
588
589    /// Get total file size
590    #[must_use]
591    pub const fn file_size(&self) -> usize {
592        self.data.len()
593    }
594
595    /// Helper: Get a section slice with bounds checking
596    #[allow(clippy::indexing_slicing)] // Bounds checked above
597    fn get_section(&self, offset: usize, length: usize, name: &str) -> Result<&'a [u8]> {
598        let end = offset
599            .checked_add(length)
600            .ok_or_else(|| AionError::InvalidFormat {
601                reason: format!("{name} section: offset + length overflow"),
602            })?;
603
604        if end > self.data.len() {
605            return Err(AionError::InvalidFormat {
606                reason: format!(
607                    "{name} section out of bounds: offset={offset}, length={length}, file_size={}",
608                    self.data.len()
609                ),
610            });
611        }
612
613        Ok(&self.data[offset..end])
614    }
615
616    /// Get a version entry by index
617    ///
618    /// # Errors
619    ///
620    /// Returns error if index is out of bounds or entry cannot be parsed
621    #[allow(clippy::cast_possible_truncation)]
622    #[allow(clippy::indexing_slicing)] // Bounds checked above
623    #[allow(clippy::arithmetic_side_effects)] // Bounds checked above
624    pub fn get_version_entry(&self, index: usize) -> Result<crate::serializer::VersionEntry> {
625        let header = self.header();
626        if index >= header.version_chain_count as usize {
627            return Err(AionError::InvalidFormat {
628                reason: format!(
629                    "Version index {} out of bounds (max {})",
630                    index, header.version_chain_count
631                ),
632            });
633        }
634
635        let bytes = self.version_chain_bytes()?;
636        let offset = index * VERSION_ENTRY_SIZE;
637        let entry_bytes = &bytes[offset..offset + VERSION_ENTRY_SIZE];
638
639        // Parse the entry from bytes
640        Ok(crate::serializer::VersionEntry {
641            version_number: u64::from_le_bytes(entry_bytes[0..8].try_into().map_err(|_| {
642                AionError::InvalidFormat {
643                    reason: "Invalid version number bytes".to_string(),
644                }
645            })?),
646            parent_hash: entry_bytes[8..40]
647                .try_into()
648                .map_err(|_| AionError::InvalidFormat {
649                    reason: "Invalid parent hash bytes".to_string(),
650                })?,
651            rules_hash: entry_bytes[40..72]
652                .try_into()
653                .map_err(|_| AionError::InvalidFormat {
654                    reason: "Invalid rules hash bytes".to_string(),
655                })?,
656            author_id: u64::from_le_bytes(entry_bytes[72..80].try_into().map_err(|_| {
657                AionError::InvalidFormat {
658                    reason: "Invalid author ID bytes".to_string(),
659                }
660            })?),
661            timestamp: u64::from_le_bytes(entry_bytes[80..88].try_into().map_err(|_| {
662                AionError::InvalidFormat {
663                    reason: "Invalid timestamp bytes".to_string(),
664                }
665            })?),
666            message_offset: u64::from_le_bytes(entry_bytes[88..96].try_into().map_err(|_| {
667                AionError::InvalidFormat {
668                    reason: "Invalid message offset bytes".to_string(),
669                }
670            })?),
671            message_length: u32::from_le_bytes(entry_bytes[96..100].try_into().map_err(|_| {
672                AionError::InvalidFormat {
673                    reason: "Invalid message length bytes".to_string(),
674                }
675            })?),
676            reserved: {
677                // Reserved fields must be zero (mirrors #40 for
678                // ArtifactEntry). Silently zeroing them on read would
679                // let `commit_version` "launder" a tampered file:
680                // verify_file flags the integrity-hash mismatch, but
681                // the next commit rebuilds the file with reserved
682                // zeroed and the laundered file then passes verify.
683                if entry_bytes[100..152].iter().any(|b| *b != 0) {
684                    return Err(AionError::InvalidFormat {
685                        reason: "VersionEntry reserved bytes must be all zero".to_string(),
686                    });
687                }
688                [0; 52]
689            },
690        })
691    }
692
693    /// Get a signature entry by index
694    ///
695    /// # Errors
696    ///
697    /// Returns error if index is out of bounds or entry cannot be parsed
698    #[allow(clippy::cast_possible_truncation)]
699    #[allow(clippy::indexing_slicing)] // Bounds checked above
700    #[allow(clippy::arithmetic_side_effects)] // Bounds checked above
701    pub fn get_signature_entry(&self, index: usize) -> Result<crate::serializer::SignatureEntry> {
702        let header = self.header();
703        if index >= header.signatures_count as usize {
704            return Err(AionError::InvalidFormat {
705                reason: format!(
706                    "Signature index {} out of bounds (max {})",
707                    index, header.signatures_count
708                ),
709            });
710        }
711
712        let bytes = self.signatures_bytes()?;
713        let offset = index * SIGNATURE_ENTRY_SIZE;
714        let entry_bytes = &bytes[offset..offset + SIGNATURE_ENTRY_SIZE];
715
716        Ok(crate::serializer::SignatureEntry {
717            author_id: u64::from_le_bytes(entry_bytes[0..8].try_into().map_err(|_| {
718                AionError::InvalidFormat {
719                    reason: "Invalid author ID bytes".to_string(),
720                }
721            })?),
722            public_key: entry_bytes[8..40]
723                .try_into()
724                .map_err(|_| AionError::InvalidFormat {
725                    reason: "Invalid public key bytes".to_string(),
726                })?,
727            signature: entry_bytes[40..104]
728                .try_into()
729                .map_err(|_| AionError::InvalidFormat {
730                    reason: "Invalid signature bytes".to_string(),
731                })?,
732            reserved: {
733                // Reserved fields must be zero (mirrors #40). See
734                // VersionEntry for the laundering concern this guards.
735                if entry_bytes[104..112].iter().any(|b| *b != 0) {
736                    return Err(AionError::InvalidFormat {
737                        reason: "SignatureEntry reserved bytes must be all zero".to_string(),
738                    });
739                }
740                [0; 8]
741            },
742        })
743    }
744
745    /// Get an audit entry by index
746    ///
747    /// # Errors
748    ///
749    /// Returns error if index is out of bounds or entry cannot be parsed
750    #[allow(clippy::cast_possible_truncation)]
751    #[allow(clippy::indexing_slicing)] // Bounds checked above
752    #[allow(clippy::arithmetic_side_effects)] // Bounds checked above
753    pub fn get_audit_entry(&self, index: usize) -> Result<crate::audit::AuditEntry> {
754        let header = self.header();
755        if index >= header.audit_trail_count as usize {
756            return Err(AionError::InvalidFormat {
757                reason: format!(
758                    "Audit index {} out of bounds (max {})",
759                    index, header.audit_trail_count
760                ),
761            });
762        }
763
764        let bytes = self.audit_trail_bytes()?;
765        let entry_size = 80; // AuditEntry is 80 bytes
766        let offset = index * entry_size;
767        let entry_bytes = &bytes[offset..offset + entry_size];
768
769        let timestamp = u64::from_le_bytes(entry_bytes[0..8].try_into().map_err(|_| {
770            AionError::InvalidFormat {
771                reason: "Invalid timestamp bytes".to_string(),
772            }
773        })?);
774        let author_id = u64::from_le_bytes(entry_bytes[8..16].try_into().map_err(|_| {
775            AionError::InvalidFormat {
776                reason: "Invalid author ID bytes".to_string(),
777            }
778        })?);
779        let action_code = u16::from_le_bytes(entry_bytes[16..18].try_into().map_err(|_| {
780            AionError::InvalidFormat {
781                reason: "Invalid action code bytes".to_string(),
782            }
783        })?);
784        let details_offset = u64::from_le_bytes(entry_bytes[24..32].try_into().map_err(|_| {
785            AionError::InvalidFormat {
786                reason: "Invalid details offset bytes".to_string(),
787            }
788        })?);
789        let details_length = u32::from_le_bytes(entry_bytes[32..36].try_into().map_err(|_| {
790            AionError::InvalidFormat {
791                reason: "Invalid details length bytes".to_string(),
792            }
793        })?);
794        let previous_hash: [u8; 32] =
795            entry_bytes[48..80]
796                .try_into()
797                .map_err(|_| AionError::InvalidFormat {
798                    reason: "Invalid previous hash bytes".to_string(),
799                })?;
800
801        let action = crate::audit::ActionCode::from_u16(action_code)?;
802
803        Ok(crate::audit::AuditEntry::new(
804            timestamp,
805            crate::types::AuthorId::new(author_id),
806            action,
807            details_offset,
808            details_length,
809            previous_hash,
810        ))
811    }
812}
813
814/// Memory-mapped file parser for large files
815///
816/// Uses OS-level memory mapping for efficient access to large files
817/// without loading entire file into memory.
818///
819/// # Examples
820///
821/// ```no_run
822/// use aion_context::parser::MmapParser;
823///
824/// # fn example() -> aion_context::Result<()> {
825/// let parser = MmapParser::open("large_file.aion")?;
826/// let header = parser.header();
827/// println!("File ID: {}", header.file_id);
828/// # Ok(())
829/// # }
830/// ```
831#[derive(Debug)]
832pub struct MmapParser {
833    /// Memory-mapped file
834    /// This field is necessary to keep the memory mapping alive
835    #[allow(dead_code)]
836    mmap: memmap2::Mmap,
837    /// Parser wrapping mmap data
838    parser: AionParser<'static>,
839}
840
841impl MmapParser {
842    /// Open and memory-map a file
843    ///
844    /// # Errors
845    ///
846    /// Returns error if:
847    /// - File cannot be opened
848    /// - File cannot be memory-mapped
849    /// - Header parsing fails
850    ///
851    /// # Examples
852    ///
853    /// ```no_run
854    /// use aion_context::parser::MmapParser;
855    ///
856    /// # fn example() -> aion_context::Result<()> {
857    /// let parser = MmapParser::open("file.aion")?;
858    /// # Ok(())
859    /// # }
860    /// ```
861    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
862        let file = std::fs::File::open(path.as_ref()).map_err(|e| AionError::FileReadError {
863            path: path.as_ref().to_path_buf(),
864            source: e,
865        })?;
866
867        // Safety: File is opened read-only, mmap lifetime tied to struct
868        #[allow(unsafe_code)]
869        let mmap = unsafe {
870            memmap2::MmapOptions::new()
871                .map(&file)
872                .map_err(|e| AionError::FileReadError {
873                    path: path.as_ref().to_path_buf(),
874                    source: e,
875                })?
876        };
877
878        // Create parser from mmap
879        // Safety: Mmap owned by struct, transmute to 'static is sound
880        #[allow(unsafe_code)]
881        let parser = unsafe {
882            let slice = std::slice::from_raw_parts(mmap.as_ptr(), mmap.len());
883            // Transmute lifetime is safe: mmap owned by struct
884            let static_slice: &'static [u8] = std::mem::transmute(slice);
885            AionParser::new(static_slice)?
886        };
887
888        Ok(Self { mmap, parser })
889    }
890
891    /// Get reference to file header
892    #[must_use]
893    pub const fn header(&self) -> &FileHeader {
894        self.parser.header()
895    }
896
897    /// Get encrypted rules section
898    pub fn encrypted_rules_bytes(&self) -> Result<&[u8]> {
899        self.parser.encrypted_rules_bytes()
900    }
901
902    /// Get version chain section
903    pub fn version_chain_bytes(&self) -> Result<&[u8]> {
904        self.parser.version_chain_bytes()
905    }
906
907    /// Get signatures section
908    pub fn signatures_bytes(&self) -> Result<&[u8]> {
909        self.parser.signatures_bytes()
910    }
911
912    /// Get audit trail section
913    pub fn audit_trail_bytes(&self) -> Result<&[u8]> {
914        self.parser.audit_trail_bytes()
915    }
916
917    /// Get string table section
918    pub fn string_table_bytes(&self) -> Result<&[u8]> {
919        self.parser.string_table_bytes()
920    }
921
922    /// Get file integrity hash
923    pub fn integrity_hash(&self) -> Result<&[u8; HASH_SIZE]> {
924        self.parser.integrity_hash()
925    }
926
927    /// Verify file integrity
928    pub fn verify_integrity(&self) -> Result<()> {
929        self.parser.verify_integrity()
930    }
931
932    /// Get total file size
933    #[must_use]
934    pub const fn file_size(&self) -> usize {
935        self.parser.file_size()
936    }
937}
938
939#[cfg(test)]
940#[allow(clippy::unwrap_used)]
941#[allow(clippy::field_reassign_with_default)]
942#[allow(clippy::indexing_slicing)]
943mod tests {
944    use super::*;
945
946    mod file_header {
947        use super::*;
948
949        #[test]
950        fn should_have_correct_size() {
951            assert_eq!(std::mem::size_of::<FileHeader>(), HEADER_SIZE);
952        }
953
954        #[test]
955        fn should_validate_magic_number() {
956            let mut header = FileHeader::default();
957            header.magic = *b"AION";
958            assert!(header.is_valid_magic());
959
960            header.magic = *b"XXXX";
961            assert!(!header.is_valid_magic());
962        }
963
964        #[test]
965        fn should_check_encrypted_flag() {
966            let mut header = FileHeader::default();
967            assert!(!header.is_encrypted());
968
969            header.flags = 0x0001;
970            assert!(header.is_encrypted());
971
972            header.flags = 0x0002; // Other bit
973            assert!(!header.is_encrypted());
974        }
975
976        #[test]
977        fn should_validate_header() {
978            let header = FileHeader::default();
979            assert!(header.validate().is_ok());
980        }
981
982        #[test]
983        fn should_reject_invalid_magic() {
984            let mut header = FileHeader::default();
985            header.magic = *b"XXXX";
986            assert!(header.validate().is_err());
987        }
988
989        #[test]
990        fn should_reject_invalid_version() {
991            let mut header = FileHeader::default();
992            header.version = 999;
993            assert!(header.validate().is_err());
994        }
995
996        #[test]
997        fn should_reject_reserved_flags() {
998            let mut header = FileHeader::default();
999            header.flags = 0x0002; // Reserved bit set
1000            assert!(header.validate().is_err());
1001        }
1002
1003        #[test]
1004        fn should_reject_non_zero_reserved_bytes() {
1005            let mut header = FileHeader::default();
1006            header.reserved[0] = 1;
1007            assert!(header.validate().is_err());
1008        }
1009
1010        #[test]
1011        fn should_parse_from_bytes() {
1012            let mut data = vec![0u8; 256];
1013            data[0..4].copy_from_slice(b"AION");
1014            data[4..6].copy_from_slice(&2u16.to_le_bytes());
1015
1016            let header = FileHeader::read_from_prefix(&data).unwrap();
1017            assert!(header.is_valid_magic());
1018            assert_eq!(header.version, 2);
1019        }
1020    }
1021
1022    mod parser {
1023        use super::*;
1024
1025        fn create_minimal_file() -> Vec<u8> {
1026            let mut data = vec![0u8; 512];
1027
1028            // Header
1029            data[0..4].copy_from_slice(b"AION");
1030            data[4..6].copy_from_slice(&2u16.to_le_bytes());
1031
1032            // Set offsets to avoid overlaps
1033            let header_end = 256u64;
1034            data[104..112].copy_from_slice(&header_end.to_le_bytes()); // encrypted_rules_offset
1035            data[112..120].copy_from_slice(&0u64.to_le_bytes()); // encrypted_rules_length
1036            data[120..128].copy_from_slice(&header_end.to_le_bytes()); // version_chain_offset
1037            data[128..136].copy_from_slice(&0u64.to_le_bytes()); // version_chain_count
1038            data[136..144].copy_from_slice(&header_end.to_le_bytes()); // signatures_offset
1039            data[144..152].copy_from_slice(&0u64.to_le_bytes()); // signatures_count
1040            data[152..160].copy_from_slice(&header_end.to_le_bytes()); // audit_trail_offset
1041            data[160..168].copy_from_slice(&0u64.to_le_bytes()); // audit_trail_count
1042            data[168..176].copy_from_slice(&(header_end + 224).to_le_bytes()); // string_table_offset
1043            data[176..184].copy_from_slice(&0u64.to_le_bytes()); // string_table_length
1044
1045            data
1046        }
1047
1048        #[test]
1049        fn should_parse_valid_file() {
1050            let data = create_minimal_file();
1051            let parser = AionParser::new(&data).unwrap();
1052            assert!(parser.header().is_valid_magic());
1053        }
1054
1055        #[test]
1056        fn should_reject_too_small_file() {
1057            let data = vec![0u8; 100];
1058            assert!(AionParser::new(&data).is_err());
1059        }
1060
1061        #[test]
1062        fn should_reject_invalid_header() {
1063            let data = vec![0u8; 256];
1064            assert!(AionParser::new(&data).is_err());
1065        }
1066
1067        #[test]
1068        fn should_get_header_reference() {
1069            let data = create_minimal_file();
1070            let parser = AionParser::new(&data).unwrap();
1071            let header = parser.header();
1072            assert_eq!(header.version, 2);
1073        }
1074
1075        #[test]
1076        fn should_get_file_size() {
1077            let data = create_minimal_file();
1078            let parser = AionParser::new(&data).unwrap();
1079            assert_eq!(parser.file_size(), data.len());
1080        }
1081
1082        #[test]
1083        fn should_get_string_table_bytes() {
1084            let data = create_minimal_file();
1085            let parser = AionParser::new(&data).unwrap();
1086            let result = parser.string_table_bytes();
1087            assert!(result.is_ok());
1088        }
1089
1090        #[test]
1091        fn should_reject_out_of_bounds_section() {
1092            let mut data = create_minimal_file();
1093            // Set string table offset beyond file size
1094            data[168..176].copy_from_slice(&9999u64.to_le_bytes());
1095
1096            let parser = AionParser::new(&data).unwrap();
1097            assert!(parser.string_table_bytes().is_err());
1098        }
1099
1100        #[test]
1101        fn should_get_integrity_hash() {
1102            let data = create_minimal_file();
1103            let parser = AionParser::new(&data).unwrap();
1104            let hash = parser.integrity_hash().unwrap();
1105            assert_eq!(hash.len(), HASH_SIZE);
1106        }
1107    }
1108
1109    mod integrity {
1110        use super::*;
1111        use crate::serializer::{AionFile, AionSerializer};
1112        use crate::types::FileId;
1113
1114        fn create_valid_file() -> Vec<u8> {
1115            let file = AionFile::builder()
1116                .file_id(FileId::new(42))
1117                .created_at(1_700_000_000_000_000_000)
1118                .modified_at(1_700_000_000_000_000_000)
1119                .build()
1120                .unwrap();
1121            AionSerializer::serialize(&file).unwrap()
1122        }
1123
1124        #[test]
1125        fn should_verify_valid_integrity() {
1126            let data = create_valid_file();
1127            let parser = AionParser::new(&data).unwrap();
1128            assert!(parser.verify_integrity().is_ok());
1129        }
1130
1131        #[test]
1132        fn should_detect_corrupted_header() {
1133            let mut data = create_valid_file();
1134            // Corrupt a byte in the header
1135            data[10] ^= 0xFF;
1136
1137            let parser = AionParser::new(&data).unwrap();
1138            let result = parser.verify_integrity();
1139            assert!(result.is_err());
1140            assert!(matches!(result, Err(AionError::CorruptedFile { .. })));
1141        }
1142
1143        #[test]
1144        fn should_detect_corrupted_middle() {
1145            let mut data = create_valid_file();
1146            // Corrupt a byte in the middle of the file
1147            let middle = data.len() / 2;
1148            data[middle] ^= 0xFF;
1149
1150            let parser = AionParser::new(&data).unwrap();
1151            let result = parser.verify_integrity();
1152            assert!(result.is_err());
1153        }
1154
1155        #[test]
1156        fn should_detect_corrupted_hash() {
1157            let mut data = create_valid_file();
1158            // Corrupt the last byte (part of hash)
1159            let last = data.len() - 1;
1160            data[last] ^= 0xFF;
1161
1162            let parser = AionParser::new(&data).unwrap();
1163            let result = parser.verify_integrity();
1164            assert!(result.is_err());
1165        }
1166
1167        #[test]
1168        fn should_detect_single_bit_flip() {
1169            let mut data = create_valid_file();
1170            // Flip a single bit in the file_id field
1171            data[8] ^= 0x01;
1172
1173            let parser = AionParser::new(&data).unwrap();
1174            let result = parser.verify_integrity();
1175            assert!(result.is_err());
1176            assert!(matches!(result, Err(AionError::CorruptedFile { .. })));
1177        }
1178
1179        #[test]
1180        fn should_detect_appended_data() {
1181            let mut data = create_valid_file();
1182            // Append extra bytes after hash
1183            data.extend_from_slice(&[0xFF; 10]);
1184
1185            // Parser won't reject this at parse time, but integrity will fail
1186            let parser = AionParser::new(&data).unwrap();
1187            let result = parser.verify_integrity();
1188            assert!(result.is_err());
1189        }
1190
1191        #[test]
1192        fn should_produce_consistent_hash() {
1193            let data1 = create_valid_file();
1194            let data2 = create_valid_file();
1195
1196            // Same input should produce same output
1197            assert_eq!(data1, data2);
1198
1199            let parser = AionParser::new(&data1).unwrap();
1200            let hash1 = parser.integrity_hash().unwrap();
1201
1202            let parser = AionParser::new(&data2).unwrap();
1203            let hash2 = parser.integrity_hash().unwrap();
1204
1205            assert_eq!(hash1, hash2);
1206        }
1207    }
1208
1209    mod properties {
1210        use super::*;
1211        use hegel::generators as gs;
1212
1213        #[hegel::test]
1214        fn prop_parser_new_never_panics_on_arbitrary_bytes(tc: hegel::TestCase) {
1215            let bytes = tc.draw(gs::binary().max_size(4096));
1216            let _ = AionParser::new(&bytes);
1217        }
1218
1219        #[hegel::test]
1220        fn prop_parser_accessors_never_panic_when_construction_succeeds(tc: hegel::TestCase) {
1221            let bytes = tc.draw(gs::binary().max_size(4096));
1222            if let Ok(parser) = AionParser::new(&bytes) {
1223                let _ = parser.header().is_valid_magic();
1224                let _ = parser.header().is_encrypted();
1225                let _ = parser.file_size();
1226                let _ = parser.string_table_bytes();
1227                let _ = parser.integrity_hash();
1228            }
1229        }
1230
1231        #[hegel::test]
1232        fn prop_small_truncated_inputs_are_rejected_not_panicked(tc: hegel::TestCase) {
1233            let len = tc.draw(gs::integers::<usize>().max_value(HEADER_SIZE - 1));
1234            let bytes = tc.draw(gs::binary().min_size(len).max_size(len));
1235            assert!(AionParser::new(&bytes).is_err());
1236        }
1237    }
1238}