Skip to main content

aion_context/
serializer.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Deterministic serializer for AION v2 file format
3//!
4//! This module provides deterministic serialization of AION v2 files as specified
5//! in RFC-0002. Key properties:
6//!
7//! - **Deterministic**: Same data always produces identical bytes (enables signatures)
8//! - **Atomic writes**: Files are written to temp location then renamed
9//! - **Offset calculation**: Automatic calculation of section offsets
10//! - **Roundtrip safe**: Serialize → parse → serialize produces identical output
11
12use 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
20/// Size of audit entry in bytes
21pub const AUDIT_ENTRY_SIZE: usize = 80;
22
23/// Version chain entry for serialization (152 bytes)
24#[repr(C)]
25#[derive(Debug, Clone, Copy, AsBytes)]
26pub struct VersionEntry {
27    /// Version number (1, 2, 3, ...)
28    pub version_number: u64,
29    /// Parent hash (BLAKE3 of previous version rules), all zeros for genesis
30    pub parent_hash: [u8; 32],
31    /// Rules hash (BLAKE3 of this version's rules)
32    pub rules_hash: [u8; 32],
33    /// Author ID who created this version
34    pub author_id: u64,
35    /// Creation timestamp (nanoseconds since Unix epoch)
36    pub timestamp: u64,
37    /// Commit message offset in string table
38    pub message_offset: u64,
39    /// Commit message length (bytes)
40    pub message_length: u32,
41    /// Reserved (must be zero)
42    pub reserved: [u8; 52],
43}
44
45const _: () = assert!(std::mem::size_of::<VersionEntry>() == VERSION_ENTRY_SIZE);
46
47impl VersionEntry {
48    /// Create a new version entry
49    #[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/// Signature entry for serialization (112 bytes)
73#[repr(C)]
74#[derive(Debug, Clone, Copy, AsBytes)]
75pub struct SignatureEntry {
76    /// Author ID
77    pub author_id: u64,
78    /// Ed25519 public key (32 bytes)
79    pub public_key: [u8; 32],
80    /// Ed25519 signature (64 bytes)
81    pub signature: [u8; 64],
82    /// Reserved (must be zero)
83    pub reserved: [u8; 8],
84}
85
86const _: () = assert!(std::mem::size_of::<SignatureEntry>() == SIGNATURE_ENTRY_SIZE);
87
88impl SignatureEntry {
89    /// Create a new signature entry
90    #[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/// AION file data for serialization
102#[derive(Debug, Clone)]
103pub struct AionFile {
104    /// File ID
105    pub file_id: FileId,
106    /// Current version number
107    pub current_version: VersionNumber,
108    /// Feature flags (bit 0 = encrypted)
109    pub flags: u16,
110    /// Root hash (genesis version)
111    pub root_hash: [u8; 32],
112    /// Current hash (latest version)
113    pub current_hash: [u8; 32],
114    /// Creation timestamp (nanoseconds)
115    pub created_at: u64,
116    /// Modification timestamp (nanoseconds)
117    pub modified_at: u64,
118    /// Encrypted rules data (nonce + ciphertext + tag)
119    pub encrypted_rules: Vec<u8>,
120    /// Version chain entries
121    pub versions: Vec<VersionEntry>,
122    /// Signature entries
123    pub signatures: Vec<SignatureEntry>,
124    /// Audit trail entries
125    pub audit_entries: Vec<AuditEntry>,
126    /// String table (concatenated null-terminated strings)
127    pub string_table: Vec<u8>,
128}
129
130impl AionFile {
131    /// Create a new file builder
132    #[must_use]
133    pub fn builder() -> AionFileBuilder {
134        AionFileBuilder::new()
135    }
136}
137
138/// Builder for `AionFile`
139#[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    /// Create a new builder
157    #[must_use]
158    pub fn new() -> Self {
159        Self::default()
160    }
161
162    /// Set the file ID
163    #[must_use]
164    pub const fn file_id(mut self, id: FileId) -> Self {
165        self.file_id = Some(id);
166        self
167    }
168
169    /// Set the current version number
170    #[must_use]
171    pub const fn current_version(mut self, version: VersionNumber) -> Self {
172        self.current_version = Some(version);
173        self
174    }
175
176    /// Set the flags
177    #[must_use]
178    pub const fn flags(mut self, flags: u16) -> Self {
179        self.flags = flags;
180        self
181    }
182
183    /// Set encrypted flag
184    #[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    /// Set root hash
195    #[must_use]
196    pub const fn root_hash(mut self, hash: [u8; 32]) -> Self {
197        self.root_hash = hash;
198        self
199    }
200
201    /// Set current hash
202    #[must_use]
203    pub const fn current_hash(mut self, hash: [u8; 32]) -> Self {
204        self.current_hash = hash;
205        self
206    }
207
208    /// Set creation timestamp
209    #[must_use]
210    pub const fn created_at(mut self, timestamp: u64) -> Self {
211        self.created_at = Some(timestamp);
212        self
213    }
214
215    /// Set modification timestamp
216    #[must_use]
217    pub const fn modified_at(mut self, timestamp: u64) -> Self {
218        self.modified_at = Some(timestamp);
219        self
220    }
221
222    /// Set encrypted rules data
223    #[must_use]
224    pub fn encrypted_rules(mut self, data: Vec<u8>) -> Self {
225        self.encrypted_rules = data;
226        self
227    }
228
229    /// Add a version entry
230    #[must_use]
231    pub fn add_version(mut self, version: VersionEntry) -> Self {
232        self.versions.push(version);
233        self
234    }
235
236    /// Set all version entries
237    #[must_use]
238    pub fn versions(mut self, versions: Vec<VersionEntry>) -> Self {
239        self.versions = versions;
240        self
241    }
242
243    /// Add a signature entry
244    #[must_use]
245    pub fn add_signature(mut self, signature: SignatureEntry) -> Self {
246        self.signatures.push(signature);
247        self
248    }
249
250    /// Set all signature entries
251    #[must_use]
252    pub fn signatures(mut self, signatures: Vec<SignatureEntry>) -> Self {
253        self.signatures = signatures;
254        self
255    }
256
257    /// Add an audit entry
258    #[must_use]
259    pub fn add_audit_entry(mut self, entry: AuditEntry) -> Self {
260        self.audit_entries.push(entry);
261        self
262    }
263
264    /// Set all audit entries
265    #[must_use]
266    pub fn audit_entries(mut self, entries: Vec<AuditEntry>) -> Self {
267        self.audit_entries = entries;
268        self
269    }
270
271    /// Set string table
272    #[must_use]
273    pub fn string_table(mut self, table: Vec<u8>) -> Self {
274        self.string_table = table;
275        self
276    }
277
278    /// Build the file
279    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
312/// Serializer for AION v2 files
313pub struct AionSerializer;
314
315impl AionSerializer {
316    /// Serialize an AION file to bytes (deterministic output)
317    #[allow(clippy::cast_possible_truncation)]
318    #[allow(clippy::arithmetic_side_effects)] // Sizes are bounded by input
319    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    /// Write an AION file atomically to disk (temp file + rename)
333    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    /// Build a string table from a list of strings
357    #[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}