1use crate::crypto::hash;
74use crate::{AionError, Result};
75use std::path::Path;
76use zerocopy::{AsBytes, FromBytes, FromZeroes};
77
78pub const MAGIC: [u8; 4] = [0x41, 0x49, 0x4F, 0x4E];
80
81pub const FORMAT_VERSION: u16 = 2;
83
84pub const HEADER_SIZE: usize = 256;
86
87pub const VERSION_ENTRY_SIZE: usize = 152;
89
90pub const SIGNATURE_ENTRY_SIZE: usize = 112;
92
93pub const HASH_SIZE: usize = 32;
95
96#[derive(Debug, Clone, Copy, AsBytes, FromBytes, FromZeroes)]
114#[repr(C)]
115pub struct FileHeader {
116 pub magic: [u8; 4],
118
119 pub version: u16,
121
122 pub flags: u16,
126
127 pub file_id: u64,
129
130 pub current_version: u64,
132
133 pub root_hash: [u8; 32],
135
136 pub current_hash: [u8; 32],
138
139 pub created_at: u64,
141
142 pub modified_at: u64,
144
145 pub encrypted_rules_offset: u64,
147
148 pub encrypted_rules_length: u64,
150
151 pub version_chain_offset: u64,
153
154 pub version_chain_count: u64,
156
157 pub signatures_offset: u64,
159
160 pub signatures_count: u64,
162
163 pub audit_trail_offset: u64,
165
166 pub audit_trail_count: u64,
168
169 pub string_table_offset: u64,
171
172 pub string_table_length: u64,
174
175 pub reserved: [u8; 72],
177}
178
179const _: () = assert!(std::mem::size_of::<FileHeader>() == HEADER_SIZE);
181
182impl FileHeader {
183 #[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 #[must_use]
221 pub const fn is_encrypted(&self) -> bool {
222 (self.flags & 0x0001) != 0
223 }
224
225 #[must_use]
238 pub const fn file_id(&self) -> crate::types::FileId {
239 crate::types::FileId(self.file_id)
240 }
241
242 #[must_use]
244 pub const fn current_version(&self) -> crate::types::VersionNumber {
245 crate::types::VersionNumber(self.current_version)
246 }
247
248 pub fn validate(&self) -> Result<()> {
259 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 if self.version != FORMAT_VERSION {
271 return Err(AionError::UnsupportedVersion {
272 version: self.version,
273 supported: FORMAT_VERSION.to_string(),
274 });
275 }
276
277 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 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#[derive(Debug)]
342pub struct AionParser<'a> {
343 data: &'a [u8],
345 header: &'a FileHeader,
350}
351
352impl<'a> AionParser<'a> {
353 pub fn new(data: &'a [u8]) -> Result<Self> {
374 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 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 #[must_use]
421 pub const fn header(&self) -> &'a FileHeader {
422 self.header
423 }
424
425 #[allow(clippy::cast_possible_truncation)] 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 #[allow(clippy::cast_possible_truncation)] 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 #[allow(clippy::cast_possible_truncation)] 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 #[allow(clippy::cast_possible_truncation)] #[allow(clippy::arithmetic_side_effects)] pub fn audit_trail_bytes(&self) -> Result<&'a [u8]> {
495 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 #[allow(clippy::cast_possible_truncation)] 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 #[allow(clippy::arithmetic_side_effects)] #[allow(clippy::indexing_slicing)] 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 #[allow(clippy::arithmetic_side_effects)] #[allow(clippy::indexing_slicing)] 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 #[must_use]
591 pub const fn file_size(&self) -> usize {
592 self.data.len()
593 }
594
595 #[allow(clippy::indexing_slicing)] 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 #[allow(clippy::cast_possible_truncation)]
622 #[allow(clippy::indexing_slicing)] #[allow(clippy::arithmetic_side_effects)] 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 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 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 #[allow(clippy::cast_possible_truncation)]
699 #[allow(clippy::indexing_slicing)] #[allow(clippy::arithmetic_side_effects)] 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 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 #[allow(clippy::cast_possible_truncation)]
751 #[allow(clippy::indexing_slicing)] #[allow(clippy::arithmetic_side_effects)] 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; 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#[derive(Debug)]
832pub struct MmapParser {
833 #[allow(dead_code)]
836 mmap: memmap2::Mmap,
837 parser: AionParser<'static>,
839}
840
841impl MmapParser {
842 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 #[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 #[allow(unsafe_code)]
881 let parser = unsafe {
882 let slice = std::slice::from_raw_parts(mmap.as_ptr(), mmap.len());
883 let static_slice: &'static [u8] = std::mem::transmute(slice);
885 AionParser::new(static_slice)?
886 };
887
888 Ok(Self { mmap, parser })
889 }
890
891 #[must_use]
893 pub const fn header(&self) -> &FileHeader {
894 self.parser.header()
895 }
896
897 pub fn encrypted_rules_bytes(&self) -> Result<&[u8]> {
899 self.parser.encrypted_rules_bytes()
900 }
901
902 pub fn version_chain_bytes(&self) -> Result<&[u8]> {
904 self.parser.version_chain_bytes()
905 }
906
907 pub fn signatures_bytes(&self) -> Result<&[u8]> {
909 self.parser.signatures_bytes()
910 }
911
912 pub fn audit_trail_bytes(&self) -> Result<&[u8]> {
914 self.parser.audit_trail_bytes()
915 }
916
917 pub fn string_table_bytes(&self) -> Result<&[u8]> {
919 self.parser.string_table_bytes()
920 }
921
922 pub fn integrity_hash(&self) -> Result<&[u8; HASH_SIZE]> {
924 self.parser.integrity_hash()
925 }
926
927 pub fn verify_integrity(&self) -> Result<()> {
929 self.parser.verify_integrity()
930 }
931
932 #[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; 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; 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 data[0..4].copy_from_slice(b"AION");
1030 data[4..6].copy_from_slice(&2u16.to_le_bytes());
1031
1032 let header_end = 256u64;
1034 data[104..112].copy_from_slice(&header_end.to_le_bytes()); data[112..120].copy_from_slice(&0u64.to_le_bytes()); data[120..128].copy_from_slice(&header_end.to_le_bytes()); data[128..136].copy_from_slice(&0u64.to_le_bytes()); data[136..144].copy_from_slice(&header_end.to_le_bytes()); data[144..152].copy_from_slice(&0u64.to_le_bytes()); data[152..160].copy_from_slice(&header_end.to_le_bytes()); data[160..168].copy_from_slice(&0u64.to_le_bytes()); data[168..176].copy_from_slice(&(header_end + 224).to_le_bytes()); data[176..184].copy_from_slice(&0u64.to_le_bytes()); 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 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 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 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 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 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 data.extend_from_slice(&[0xFF; 10]);
1184
1185 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 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}