1use crate::audit::AuditEntry;
13use crate::crypto::hash;
14use crate::parser::{FileHeader, HASH_SIZE, HEADER_SIZE, SIGNATURE_ENTRY_SIZE, VERSION_ENTRY_SIZE};
15use crate::types::{AuthorId, FileId, VersionNumber};
16use crate::{AionError, Result};
17use std::path::Path;
18use zerocopy::AsBytes;
19
20pub const AUDIT_ENTRY_SIZE: usize = 80;
22
23#[repr(C)]
25#[derive(Debug, Clone, Copy, AsBytes)]
26pub struct VersionEntry {
27 pub version_number: u64,
29 pub parent_hash: [u8; 32],
31 pub rules_hash: [u8; 32],
33 pub author_id: u64,
35 pub timestamp: u64,
37 pub message_offset: u64,
39 pub message_length: u32,
41 pub reserved: [u8; 52],
43}
44
45const _: () = assert!(std::mem::size_of::<VersionEntry>() == VERSION_ENTRY_SIZE);
46
47impl VersionEntry {
48 #[must_use]
50 pub const fn new(
51 version_number: VersionNumber,
52 parent_hash: [u8; 32],
53 rules_hash: [u8; 32],
54 author_id: AuthorId,
55 timestamp: u64,
56 message_offset: u64,
57 message_length: u32,
58 ) -> Self {
59 Self {
60 version_number: version_number.as_u64(),
61 parent_hash,
62 rules_hash,
63 author_id: author_id.as_u64(),
64 timestamp,
65 message_offset,
66 message_length,
67 reserved: [0; 52],
68 }
69 }
70}
71
72#[repr(C)]
74#[derive(Debug, Clone, Copy, AsBytes)]
75pub struct SignatureEntry {
76 pub author_id: u64,
78 pub public_key: [u8; 32],
80 pub signature: [u8; 64],
82 pub reserved: [u8; 8],
84}
85
86const _: () = assert!(std::mem::size_of::<SignatureEntry>() == SIGNATURE_ENTRY_SIZE);
87
88impl SignatureEntry {
89 #[must_use]
91 pub const fn new(author_id: AuthorId, public_key: [u8; 32], signature: [u8; 64]) -> Self {
92 Self {
93 author_id: author_id.as_u64(),
94 public_key,
95 signature,
96 reserved: [0; 8],
97 }
98 }
99}
100
101#[derive(Debug, Clone)]
103pub struct AionFile {
104 pub file_id: FileId,
106 pub current_version: VersionNumber,
108 pub flags: u16,
110 pub root_hash: [u8; 32],
112 pub current_hash: [u8; 32],
114 pub created_at: u64,
116 pub modified_at: u64,
118 pub encrypted_rules: Vec<u8>,
120 pub versions: Vec<VersionEntry>,
122 pub signatures: Vec<SignatureEntry>,
124 pub audit_entries: Vec<AuditEntry>,
126 pub string_table: Vec<u8>,
128}
129
130impl AionFile {
131 #[must_use]
133 pub fn builder() -> AionFileBuilder {
134 AionFileBuilder::new()
135 }
136}
137
138#[derive(Debug, Default)]
140pub struct AionFileBuilder {
141 file_id: Option<FileId>,
142 current_version: Option<VersionNumber>,
143 flags: u16,
144 root_hash: [u8; 32],
145 current_hash: [u8; 32],
146 created_at: Option<u64>,
147 modified_at: Option<u64>,
148 encrypted_rules: Vec<u8>,
149 versions: Vec<VersionEntry>,
150 signatures: Vec<SignatureEntry>,
151 audit_entries: Vec<AuditEntry>,
152 string_table: Vec<u8>,
153}
154
155impl AionFileBuilder {
156 #[must_use]
158 pub fn new() -> Self {
159 Self::default()
160 }
161
162 #[must_use]
164 pub const fn file_id(mut self, id: FileId) -> Self {
165 self.file_id = Some(id);
166 self
167 }
168
169 #[must_use]
171 pub const fn current_version(mut self, version: VersionNumber) -> Self {
172 self.current_version = Some(version);
173 self
174 }
175
176 #[must_use]
178 pub const fn flags(mut self, flags: u16) -> Self {
179 self.flags = flags;
180 self
181 }
182
183 #[must_use]
185 pub const fn encrypted(mut self, encrypted: bool) -> Self {
186 if encrypted {
187 self.flags |= 0x0001;
188 } else {
189 self.flags &= !0x0001;
190 }
191 self
192 }
193
194 #[must_use]
196 pub const fn root_hash(mut self, hash: [u8; 32]) -> Self {
197 self.root_hash = hash;
198 self
199 }
200
201 #[must_use]
203 pub const fn current_hash(mut self, hash: [u8; 32]) -> Self {
204 self.current_hash = hash;
205 self
206 }
207
208 #[must_use]
210 pub const fn created_at(mut self, timestamp: u64) -> Self {
211 self.created_at = Some(timestamp);
212 self
213 }
214
215 #[must_use]
217 pub const fn modified_at(mut self, timestamp: u64) -> Self {
218 self.modified_at = Some(timestamp);
219 self
220 }
221
222 #[must_use]
224 pub fn encrypted_rules(mut self, data: Vec<u8>) -> Self {
225 self.encrypted_rules = data;
226 self
227 }
228
229 #[must_use]
231 pub fn add_version(mut self, version: VersionEntry) -> Self {
232 self.versions.push(version);
233 self
234 }
235
236 #[must_use]
238 pub fn versions(mut self, versions: Vec<VersionEntry>) -> Self {
239 self.versions = versions;
240 self
241 }
242
243 #[must_use]
245 pub fn add_signature(mut self, signature: SignatureEntry) -> Self {
246 self.signatures.push(signature);
247 self
248 }
249
250 #[must_use]
252 pub fn signatures(mut self, signatures: Vec<SignatureEntry>) -> Self {
253 self.signatures = signatures;
254 self
255 }
256
257 #[must_use]
259 pub fn add_audit_entry(mut self, entry: AuditEntry) -> Self {
260 self.audit_entries.push(entry);
261 self
262 }
263
264 #[must_use]
266 pub fn audit_entries(mut self, entries: Vec<AuditEntry>) -> Self {
267 self.audit_entries = entries;
268 self
269 }
270
271 #[must_use]
273 pub fn string_table(mut self, table: Vec<u8>) -> Self {
274 self.string_table = table;
275 self
276 }
277
278 pub fn build(self) -> Result<AionFile> {
280 let file_id = self.file_id.ok_or_else(|| AionError::InvalidFormat {
281 reason: "file_id is required".to_string(),
282 })?;
283 let created_at = self.created_at.ok_or_else(|| AionError::InvalidFormat {
284 reason: "created_at is required".to_string(),
285 })?;
286 let modified_at = self.modified_at.ok_or_else(|| AionError::InvalidFormat {
287 reason: "modified_at is required".to_string(),
288 })?;
289 let current_version = self.current_version.unwrap_or(if self.versions.is_empty() {
290 VersionNumber(0)
291 } else {
292 VersionNumber(self.versions.len() as u64)
293 });
294
295 Ok(AionFile {
296 file_id,
297 current_version,
298 flags: self.flags,
299 root_hash: self.root_hash,
300 current_hash: self.current_hash,
301 created_at,
302 modified_at,
303 encrypted_rules: self.encrypted_rules,
304 versions: self.versions,
305 signatures: self.signatures,
306 audit_entries: self.audit_entries,
307 string_table: self.string_table,
308 })
309 }
310}
311
312pub struct AionSerializer;
314
315impl AionSerializer {
316 #[allow(clippy::cast_possible_truncation)]
318 #[allow(clippy::arithmetic_side_effects)] pub fn serialize(file: &AionFile) -> Result<Vec<u8>> {
320 let sizes = SectionSizes::from_file(file);
321 let offsets = SectionOffsets::from_sizes(&sizes);
322 let header = build_header(file, &sizes, &offsets);
323
324 let mut buffer = Vec::with_capacity(sizes.total);
325 buffer.extend_from_slice(header.as_bytes());
326 write_body_sections(&mut buffer, file);
327 let integrity_hash = hash(&buffer);
328 buffer.extend_from_slice(&integrity_hash);
329 Ok(buffer)
330 }
331
332 pub fn write_atomic<P: AsRef<Path>>(file: &AionFile, path: P) -> Result<()> {
334 let path = path.as_ref();
335 let bytes = Self::serialize(file)?;
336
337 let parent = path.parent().unwrap_or_else(|| Path::new("."));
338 let temp_path = parent.join(format!(".aion-temp-{}.tmp", std::process::id()));
339
340 std::fs::write(&temp_path, &bytes).map_err(|e| AionError::FileWriteError {
341 path: temp_path.clone(),
342 source: e,
343 })?;
344
345 std::fs::rename(&temp_path, path).map_err(|e| {
346 let _ = std::fs::remove_file(&temp_path);
347 AionError::FileWriteError {
348 path: path.to_path_buf(),
349 source: e,
350 }
351 })?;
352
353 Ok(())
354 }
355
356 #[must_use]
358 pub fn build_string_table(strings: &[&str]) -> (Vec<u8>, Vec<u64>) {
359 let mut table = Vec::new();
360 let mut offsets = Vec::with_capacity(strings.len());
361 for s in strings {
362 offsets.push(table.len() as u64);
363 table.extend_from_slice(s.as_bytes());
364 table.push(0);
365 }
366 (table, offsets)
367 }
368}
369
370#[allow(clippy::arithmetic_side_effects)]
371struct SectionSizes {
372 encrypted_rules: usize,
373 version_chain: usize,
374 signatures: usize,
375 audit_trail: usize,
376 string_table: usize,
377 total: usize,
378}
379
380impl SectionSizes {
381 #[allow(clippy::arithmetic_side_effects)]
382 fn from_file(file: &AionFile) -> Self {
383 let encrypted_rules = file.encrypted_rules.len();
384 let version_chain = file.versions.len() * VERSION_ENTRY_SIZE;
385 let signatures = file.signatures.len() * SIGNATURE_ENTRY_SIZE;
386 let audit_trail = file.audit_entries.len() * AUDIT_ENTRY_SIZE;
387 let string_table = file.string_table.len();
388 let total = HEADER_SIZE
389 + encrypted_rules
390 + version_chain
391 + signatures
392 + audit_trail
393 + string_table
394 + HASH_SIZE;
395 Self {
396 encrypted_rules,
397 version_chain,
398 signatures,
399 audit_trail,
400 string_table,
401 total,
402 }
403 }
404}
405
406#[allow(clippy::arithmetic_side_effects)]
407struct SectionOffsets {
408 encrypted_rules: u64,
409 version_chain: u64,
410 signatures: u64,
411 audit_trail: u64,
412 string_table: u64,
413}
414
415impl SectionOffsets {
416 #[allow(clippy::arithmetic_side_effects)]
417 const fn from_sizes(sizes: &SectionSizes) -> Self {
418 let encrypted_rules = HEADER_SIZE as u64;
419 let version_chain = encrypted_rules + sizes.encrypted_rules as u64;
420 let signatures = version_chain + sizes.version_chain as u64;
421 let audit_trail = signatures + sizes.signatures as u64;
422 let string_table = audit_trail + sizes.audit_trail as u64;
423 Self {
424 encrypted_rules,
425 version_chain,
426 signatures,
427 audit_trail,
428 string_table,
429 }
430 }
431}
432
433#[allow(clippy::cast_possible_truncation)]
434fn build_header(file: &AionFile, sizes: &SectionSizes, offsets: &SectionOffsets) -> FileHeader {
435 FileHeader {
436 magic: *b"AION",
437 version: 2,
438 flags: file.flags,
439 file_id: file.file_id.as_u64(),
440 current_version: file.current_version.as_u64(),
441 root_hash: file.root_hash,
442 current_hash: file.current_hash,
443 created_at: file.created_at,
444 modified_at: file.modified_at,
445 encrypted_rules_offset: offsets.encrypted_rules,
446 encrypted_rules_length: sizes.encrypted_rules as u64,
447 version_chain_offset: offsets.version_chain,
448 version_chain_count: file.versions.len() as u64,
449 signatures_offset: offsets.signatures,
450 signatures_count: file.signatures.len() as u64,
451 audit_trail_offset: offsets.audit_trail,
452 audit_trail_count: file.audit_entries.len() as u64,
453 string_table_offset: offsets.string_table,
454 string_table_length: sizes.string_table as u64,
455 reserved: [0; 72],
456 }
457}
458
459fn write_body_sections(buffer: &mut Vec<u8>, file: &AionFile) {
460 buffer.extend_from_slice(&file.encrypted_rules);
461 for version in &file.versions {
462 buffer.extend_from_slice(version.as_bytes());
463 }
464 for signature in &file.signatures {
465 buffer.extend_from_slice(signature.as_bytes());
466 }
467 for entry in &file.audit_entries {
468 buffer.extend_from_slice(entry.as_bytes());
469 }
470 buffer.extend_from_slice(&file.string_table);
471}
472
473#[cfg(test)]
474#[allow(clippy::unwrap_used)]
475#[allow(clippy::indexing_slicing)]
476#[allow(clippy::inconsistent_digit_grouping)]
477mod tests {
478 use super::*;
479 use crate::audit::ActionCode;
480 use crate::parser::AionParser;
481
482 #[test]
483 fn version_entry_should_have_correct_size() {
484 assert_eq!(std::mem::size_of::<VersionEntry>(), VERSION_ENTRY_SIZE);
485 }
486
487 #[test]
488 fn signature_entry_should_have_correct_size() {
489 assert_eq!(std::mem::size_of::<SignatureEntry>(), SIGNATURE_ENTRY_SIZE);
490 }
491
492 #[test]
493 fn should_build_minimal_file() {
494 let file = AionFile::builder()
495 .file_id(FileId::new(1))
496 .created_at(1700000000_000_000_000)
497 .modified_at(1700000000_000_000_000)
498 .build()
499 .unwrap();
500 assert_eq!(file.file_id, FileId::new(1));
501 }
502
503 #[test]
504 fn should_serialize_minimal_file() {
505 let file = AionFile::builder()
506 .file_id(FileId::new(42))
507 .created_at(1700000000_000_000_000)
508 .modified_at(1700000000_000_000_000)
509 .build()
510 .unwrap();
511 let bytes = AionSerializer::serialize(&file).unwrap();
512 assert!(bytes.len() >= HEADER_SIZE + HASH_SIZE);
513 }
514
515 #[test]
516 fn should_produce_deterministic_output() {
517 let file = AionFile::builder()
518 .file_id(FileId::new(42))
519 .created_at(1700000000_000_000_000)
520 .modified_at(1700000000_000_000_000)
521 .encrypted_rules(vec![1, 2, 3, 4])
522 .build()
523 .unwrap();
524 let bytes1 = AionSerializer::serialize(&file).unwrap();
525 let bytes2 = AionSerializer::serialize(&file).unwrap();
526 assert_eq!(bytes1, bytes2);
527 }
528
529 #[test]
530 fn should_roundtrip_minimal_file() {
531 let file = AionFile::builder()
532 .file_id(FileId::new(42))
533 .current_version(VersionNumber(0))
534 .created_at(1700000000_000_000_000)
535 .modified_at(1700000000_000_000_000)
536 .build()
537 .unwrap();
538
539 let bytes = AionSerializer::serialize(&file).unwrap();
540 let parser = AionParser::new(&bytes).unwrap();
541 let header = parser.header();
542
543 assert_eq!(header.file_id, 42);
544 assert_eq!(header.current_version, 0);
545 }
546
547 #[test]
548 fn should_roundtrip_file_with_versions() {
549 let (string_table, offsets) = AionSerializer::build_string_table(&["Genesis version"]);
550 let version = VersionEntry::new(
551 VersionNumber::GENESIS,
552 [0; 32],
553 [0xAB; 32],
554 AuthorId::new(1001),
555 1700000000_000_000_000,
556 offsets[0],
557 15,
558 );
559 let signature = SignatureEntry::new(AuthorId::new(1001), [0xCC; 32], [0xDD; 64]);
560
561 let file = AionFile::builder()
562 .file_id(FileId::new(100))
563 .current_version(VersionNumber::GENESIS)
564 .created_at(1700000000_000_000_000)
565 .modified_at(1700000001_000_000_000)
566 .encrypted_rules(vec![0u8; 64])
567 .add_version(version)
568 .add_signature(signature)
569 .string_table(string_table)
570 .encrypted(true)
571 .build()
572 .unwrap();
573
574 let bytes = AionSerializer::serialize(&file).unwrap();
575 let parser = AionParser::new(&bytes).unwrap();
576 let header = parser.header();
577
578 assert_eq!(header.file_id, 100);
579 assert_eq!(header.current_version, 1);
580 assert!(header.is_encrypted());
581 assert_eq!(header.version_chain_count, 1);
582 assert_eq!(header.signatures_count, 1);
583 }
584
585 #[test]
586 fn should_roundtrip_with_audit_trail() {
587 let audit_entry = AuditEntry::new(
588 1700000000_000_000_000,
589 AuthorId::new(1001),
590 ActionCode::CreateGenesis,
591 0,
592 10,
593 [0u8; 32],
594 );
595
596 let file = AionFile::builder()
597 .file_id(FileId::new(1))
598 .created_at(1700000000_000_000_000)
599 .modified_at(1700000000_000_000_000)
600 .add_audit_entry(audit_entry)
601 .string_table(b"test entry\0".to_vec())
602 .build()
603 .unwrap();
604
605 let bytes = AionSerializer::serialize(&file).unwrap();
606 let parser = AionParser::new(&bytes).unwrap();
607 assert_eq!(parser.header().audit_trail_count, 1);
608 }
609
610 #[test]
611 fn should_verify_integrity_hash() {
612 let file = AionFile::builder()
613 .file_id(FileId::new(999))
614 .created_at(1700000000_000_000_000)
615 .modified_at(1700000000_000_000_000)
616 .build()
617 .unwrap();
618
619 let bytes = AionSerializer::serialize(&file).unwrap();
620 let hash_offset = bytes.len() - HASH_SIZE;
621 let stored_hash = &bytes[hash_offset..];
622 let computed_hash = hash(&bytes[..hash_offset]);
623 assert_eq!(stored_hash, computed_hash);
624 }
625
626 #[test]
627 fn should_calculate_correct_offsets() {
628 let file = AionFile::builder()
629 .file_id(FileId::new(1))
630 .created_at(1700000000_000_000_000)
631 .modified_at(1700000000_000_000_000)
632 .encrypted_rules(vec![0u8; 100])
633 .add_version(VersionEntry::new(
634 VersionNumber(1),
635 [0; 32],
636 [0; 32],
637 AuthorId::new(1),
638 1700000000_000_000_000,
639 0,
640 0,
641 ))
642 .build()
643 .unwrap();
644
645 let bytes = AionSerializer::serialize(&file).unwrap();
646 let parser = AionParser::new(&bytes).unwrap();
647 let header = parser.header();
648
649 assert_eq!(header.encrypted_rules_offset, HEADER_SIZE as u64);
650 assert_eq!(header.encrypted_rules_length, 100);
651 assert_eq!(header.version_chain_offset, HEADER_SIZE as u64 + 100);
652 assert_eq!(header.version_chain_count, 1);
653 }
654
655 mod properties {
656 use super::*;
657 use hegel::generators as gs;
658
659 fn draw_file(tc: &hegel::TestCase) -> AionFile {
660 let file_id = tc.draw(gs::integers::<u64>());
661 let created_at = tc.draw(gs::integers::<u64>().min_value(1).max_value(1u64 << 62));
662 let bump = tc.draw(gs::integers::<u64>().max_value(10_000_000_000));
663 let modified_at = created_at.saturating_add(bump);
664 AionFile::builder()
665 .file_id(FileId::new(file_id))
666 .created_at(created_at)
667 .modified_at(modified_at)
668 .build()
669 .unwrap_or_else(|_| std::process::abort())
670 }
671
672 #[hegel::test]
673 fn prop_serialize_parse_integrity_holds(tc: hegel::TestCase) {
674 let file = draw_file(&tc);
675 let bytes = AionSerializer::serialize(&file).unwrap_or_else(|_| std::process::abort());
676 let parser = AionParser::new(&bytes).unwrap_or_else(|_| std::process::abort());
677 parser
678 .verify_integrity()
679 .unwrap_or_else(|_| std::process::abort());
680 }
681
682 #[hegel::test]
683 fn prop_serialize_is_deterministic(tc: hegel::TestCase) {
684 let file = draw_file(&tc);
685 let a = AionSerializer::serialize(&file).unwrap_or_else(|_| std::process::abort());
686 let b = AionSerializer::serialize(&file).unwrap_or_else(|_| std::process::abort());
687 assert_eq!(a, b);
688 }
689 }
690}