Skip to main content

aion_context/
operations.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Core operations for AION v2 files
3//!
4//! This module implements the high-level operations for working with AION files:
5//!
6//! - **Commit**: Create a new version with updated rules
7//!
8//! All operations follow the security model defined in RFC-0001:
9//! - Signature chain verification before modifications
10//! - Cryptographic signing of all new versions
11//! - Atomic file writes to prevent corruption
12//!
13//! # Usage Example
14//!
15//! ```no_run
16//! use aion_context::operations::{commit_version, CommitOptions};
17//! use aion_context::crypto::SigningKey;
18//! use aion_context::types::AuthorId;
19//! use std::path::Path;
20//!
21//! let signing_key = SigningKey::generate();
22//! let options = CommitOptions {
23//!     author_id: AuthorId::new(50001),
24//!     signing_key: &signing_key,
25//!     message: "Updated fraud detection rules",
26//!     timestamp: None, // Use current time
27//! };
28//!
29//! // Commit new rules to existing file
30//! // let result = commit_version(
31//! //     Path::new("rules.aion"),
32//! //     b"new rules content",
33//! //     &options,
34//! // );
35//! ```
36
37use crate::audit::{ActionCode, AuditEntry};
38use crate::crypto::{decrypt, derive_key, encrypt, generate_nonce, hash, SigningKey};
39use crate::parser::AionParser;
40use crate::serializer::{AionFile, AionSerializer, SignatureEntry, VersionEntry};
41#[allow(deprecated)] // RFC-0034 Phase D: verify_signature kept for legacy verify_file path
42use crate::signature_chain::{
43    compute_version_hash, create_genesis_version, sign_version, verify_hash_chain,
44    verify_signature, verify_signatures_batch,
45};
46use crate::types::{AuthorId, FileId, VersionNumber};
47use crate::{AionError, Result};
48use std::path::Path;
49use std::time::{SystemTime, UNIX_EPOCH};
50
51// ============================================================================
52// File Creation Operations
53// ============================================================================
54
55/// Options for initializing a new AION file
56pub struct InitOptions<'a> {
57    /// Author ID for the genesis version
58    pub author_id: AuthorId,
59    /// Signing key for the genesis version
60    pub signing_key: &'a SigningKey,
61    /// Commit message for genesis version
62    pub message: &'a str,
63    /// Optional timestamp (uses current time if None)
64    pub timestamp: Option<u64>,
65}
66
67/// Result of file initialization
68#[derive(Debug, Clone)]
69pub struct InitResult {
70    /// Generated file ID
71    pub file_id: FileId,
72    /// Genesis version number (always 1)
73    pub version: VersionNumber,
74    /// Hash of the initial rules
75    pub rules_hash: [u8; 32],
76}
77
78/// Initialize a new AION file with genesis version
79///
80/// Creates a new AION file with:
81/// - Unique file ID
82/// - Genesis version (version 1)
83/// - Encrypted rules
84/// - Cryptographic signature
85/// - Audit trail entry
86///
87/// # Arguments
88///
89/// * `path` - Path where the file will be created
90/// * `initial_rules` - Initial rules content (plaintext)
91/// * `options` - Initialization options (author, key, message)
92///
93/// # Returns
94///
95/// Returns `InitResult` containing file ID and version information.
96///
97/// # Errors
98///
99/// Returns error if:
100/// - File already exists at the path
101/// - Rules encryption fails
102/// - File write fails
103/// - I/O error occurs
104///
105/// # Example
106///
107/// ```no_run
108/// use aion_context::operations::{init_file, InitOptions};
109/// use aion_context::crypto::SigningKey;
110/// use aion_context::types::AuthorId;
111/// use std::path::Path;
112///
113/// let signing_key = SigningKey::generate();
114/// let options = InitOptions {
115///     author_id: AuthorId::new(50001),
116///     signing_key: &signing_key,
117///     message: "Initial policy version",
118///     timestamp: None, // Use current time
119/// };
120///
121/// let initial_rules = b"fraud_threshold: 1000\nrisk_level: medium";
122/// // let result = init_file(
123/// //     Path::new("policy.aion"),
124/// //     initial_rules,
125/// //     &options,
126/// // )?;
127/// // println!("Created file {} with version {}", result.file_id.as_u64(), result.version.as_u64());
128/// ```
129pub fn init_file(path: &Path, initial_rules: &[u8], options: &InitOptions) -> Result<InitResult> {
130    if path.exists() {
131        return Err(AionError::FileExists {
132            path: path.to_path_buf(),
133        });
134    }
135    let file_id = FileId::random();
136    let timestamp = options.timestamp.unwrap_or_else(current_timestamp_nanos);
137    let rules_hash = hash(initial_rules);
138    let (encrypted_rules, _) = encrypt_rules(initial_rules, file_id, VersionNumber::GENESIS)?;
139
140    let aion_file = build_genesis_file(file_id, timestamp, rules_hash, encrypted_rules, options)?;
141    write_serialized_file(&aion_file, path)?;
142
143    tracing::info!(
144        event = "file_initialized",
145        file_id = %crate::obs::short_hex(&file_id.as_u64().to_le_bytes()),
146        author = %crate::obs::author_short(options.author_id),
147        rules_hash = %crate::obs::short_hex(&rules_hash),
148    );
149    Ok(InitResult {
150        file_id,
151        version: VersionNumber::GENESIS,
152        rules_hash,
153    })
154}
155
156#[allow(clippy::cast_possible_truncation)]
157fn build_genesis_file(
158    file_id: FileId,
159    timestamp: u64,
160    rules_hash: [u8; 32],
161    encrypted_rules: Vec<u8>,
162    options: &InitOptions,
163) -> Result<AionFile> {
164    let (string_table, offsets) = AionSerializer::build_string_table(&[options.message]);
165    let message_offset = offsets.first().copied().unwrap_or(0);
166
167    let genesis_version = create_genesis_version(
168        rules_hash,
169        options.author_id,
170        timestamp,
171        message_offset,
172        options.message.len() as u32,
173    );
174    let signature = sign_version(&genesis_version, options.signing_key);
175    let audit_entry = AuditEntry::new(
176        timestamp,
177        options.author_id,
178        ActionCode::CreateGenesis,
179        0,
180        0,
181        [0u8; 32],
182    );
183
184    AionFile::builder()
185        .file_id(file_id)
186        .current_version(VersionNumber::GENESIS)
187        .flags(0x0001)
188        .root_hash(rules_hash)
189        .current_hash(rules_hash)
190        .created_at(timestamp)
191        .modified_at(timestamp)
192        .encrypted_rules(encrypted_rules)
193        .add_version(genesis_version)
194        .add_signature(signature)
195        .add_audit_entry(audit_entry)
196        .string_table(string_table)
197        .build()
198}
199
200fn write_serialized_file(file: &AionFile, path: &Path) -> Result<()> {
201    let file_bytes = AionSerializer::serialize(file)?;
202    std::fs::write(path, &file_bytes).map_err(|e| AionError::FileWriteError {
203        path: path.to_path_buf(),
204        source: e,
205    })
206}
207
208// ============================================================================
209// Version Management Operations
210// ============================================================================
211
212/// Options for committing a new version
213pub struct CommitOptions<'a> {
214    /// Author ID for the new version
215    pub author_id: AuthorId,
216    /// Signing key for the author
217    pub signing_key: &'a SigningKey,
218    /// Commit message describing changes
219    pub message: &'a str,
220    /// Optional timestamp (nanoseconds since Unix epoch)
221    /// If None, uses current system time
222    pub timestamp: Option<u64>,
223}
224
225/// Result of a successful commit operation
226#[derive(Debug)]
227pub struct CommitResult {
228    /// The new version number
229    pub version: VersionNumber,
230    /// Hash of the new version entry
231    pub version_hash: [u8; 32],
232    /// Hash of the new rules
233    pub rules_hash: [u8; 32],
234}
235
236/// Commit a new version with updated rules to an existing AION file
237///
238/// This operation:
239/// 1. Loads and parses the existing file
240/// 2. Verifies the existing signature chain
241/// 3. Encrypts the new rules
242/// 4. Creates a new version entry linked to the previous version
243/// 5. Signs the new version with the author's key
244/// 6. Writes the updated file atomically
245///
246/// # Arguments
247///
248/// * `path` - Path to the existing AION file
249/// * `new_rules` - The new rules content to commit
250/// * `options` - Commit options including author and signing key
251///
252/// # Returns
253///
254/// * `Ok(CommitResult)` - On success, contains the new version number and hashes
255/// * `Err(AionError)` - On failure
256///
257/// # Errors
258///
259/// - `AionError::FileReadError` - Cannot read the file
260/// - `AionError::InvalidFormat` - File format is invalid
261/// - `AionError::SignatureVerificationFailed` - Existing signature chain is invalid
262/// - `AionError::VersionOverflow` - Version number would overflow u64
263/// - `AionError::FileWriteError` - Cannot write the updated file
264///
265/// # Example
266///
267/// ```no_run
268/// use aion_context::operations::{commit_version, CommitOptions};
269/// use aion_context::crypto::SigningKey;
270/// use aion_context::types::AuthorId;
271/// use std::path::Path;
272///
273/// let signing_key = SigningKey::generate();
274/// let options = CommitOptions {
275///     author_id: AuthorId::new(50001),
276///     signing_key: &signing_key,
277///     message: "Updated rules",
278///     timestamp: None,
279/// };
280///
281/// // let result = commit_version(
282/// //     Path::new("rules.aion"),
283/// //     b"new rules content",
284/// //     &options,
285/// // )?;
286/// // println!("Created version {}", result.version.as_u64());
287/// # Ok::<(), aion_context::AionError>(())
288/// ```
289#[must_use = "the CommitResult carries the new version number and rules hash; \
290              dropping it silently usually indicates a missing post-commit step"]
291pub fn commit_version(
292    path: &Path,
293    new_rules: &[u8],
294    options: &CommitOptions<'_>,
295    registry: &crate::key_registry::KeyRegistry,
296) -> Result<CommitResult> {
297    commit_version_inner(path, new_rules, options, registry, true)
298}
299
300/// Commit bypassing the registry authz pre-check (issue #25
301/// `--force-unregistered` escape hatch).
302///
303/// Same behavior as [`commit_version`] except the
304/// `(author, signing key, active epoch)` match is not enforced.
305/// The resulting entry is **not guaranteed to verify** against the
306/// supplied registry — operators using this path must know why
307/// (offline signer, staged rollout) and accept that the file on
308/// disk will not pass `verify` until the registry is updated.
309///
310/// # Errors
311///
312/// Same error surface as [`commit_version`] minus the authz errors.
313#[must_use = "the resulting file will NOT pass `verify` against the \
314              supplied registry until the registry is updated to pin \
315              this signer; check the CommitResult and ensure the \
316              registry update is staged"]
317pub fn commit_version_force_unregistered(
318    path: &Path,
319    new_rules: &[u8],
320    options: &CommitOptions<'_>,
321    registry: &crate::key_registry::KeyRegistry,
322) -> Result<CommitResult> {
323    commit_version_inner(path, new_rules, options, registry, false)
324}
325
326fn commit_version_inner(
327    path: &Path,
328    new_rules: &[u8],
329    options: &CommitOptions<'_>,
330    registry: &crate::key_registry::KeyRegistry,
331    enforce_registry: bool,
332) -> Result<CommitResult> {
333    let file_bytes = std::fs::read(path).map_err(|e| AionError::FileReadError {
334        path: path.to_path_buf(),
335        source: e,
336    })?;
337    let parser = AionParser::new(&file_bytes)?;
338    let header = parser.header();
339
340    // Pre-write integrity gate (audit follow-up to PR #37):
341    //
342    //   1. integrity_hash over the whole on-disk byte range — catches
343    //      any single-bit tamper in any section, including ones that
344    //      verify_head_signature alone wouldn't notice.
345    //   2. parent_hash chain over the version entries — catches
346    //      tampering of an intermediate VersionEntry that doesn't
347    //      change the head signature.
348    //   3. head signature — catches tampering of the latest sig.
349    //
350    // PR #37's commit message claimed (1) was already done by the
351    // parser at parse time. It was not — `AionParser::new` only
352    // does structural validation. Without (1) and (2), commit
353    // could layer a valid new entry on top of a corrupt chain,
354    // hiding the corruption beneath fresh bytes.
355    parser.verify_integrity()?;
356    let existing_versions = collect_versions(&parser, header.version_chain_count)?;
357    crate::signature_chain::verify_hash_chain(&existing_versions)?;
358    verify_head_signature(&parser, registry)?;
359
360    let new_version = VersionNumber(header.current_version).next()?;
361
362    if enforce_registry {
363        preflight_registry_authz(options, new_version, registry)?;
364    }
365
366    let timestamp = options.timestamp.unwrap_or_else(current_timestamp_nanos);
367    let file_id = FileId::new(header.file_id);
368    let (encrypted_rules, rules_hash) = encrypt_rules(new_rules, file_id, new_version)?;
369    let parent_hash = compute_version_hash(&get_last_version_entry(&parser)?);
370    let (string_table, message_offset) = build_string_table_with_message(options.message, &parser)?;
371
372    let (new_version_entry, signature_entry) = build_new_version_and_signature(
373        new_version,
374        parent_hash,
375        rules_hash,
376        timestamp,
377        message_offset,
378        options,
379    );
380
381    let updated_file = build_updated_file(
382        &parser,
383        header,
384        new_version,
385        rules_hash,
386        encrypted_rules,
387        new_version_entry,
388        signature_entry,
389        string_table,
390        timestamp,
391        options.author_id,
392    )?;
393    AionSerializer::write_atomic(&updated_file, path)?;
394
395    let version_hash = compute_version_hash(&new_version_entry);
396    tracing::info!(
397        event = "commit_accepted",
398        file_id = %crate::obs::short_hex(&header.file_id.to_le_bytes()),
399        author = %crate::obs::author_short(options.author_id),
400        version = new_version.as_u64(),
401        version_hash = %crate::obs::short_hex(&version_hash),
402        rules_hash = %crate::obs::short_hex(&rules_hash),
403    );
404    Ok(CommitResult {
405        version: new_version,
406        version_hash,
407        rules_hash,
408    })
409}
410
411/// Pre-write authz check (issue #25): the signer must have an
412/// active epoch at the target version, and the supplied signing
413/// key must match that epoch's pinned operational key.
414fn preflight_registry_authz(
415    options: &CommitOptions<'_>,
416    new_version: VersionNumber,
417    registry: &crate::key_registry::KeyRegistry,
418) -> Result<()> {
419    use subtle::ConstantTimeEq;
420    let Some(epoch) = registry.active_epoch_at(options.author_id, new_version.as_u64()) else {
421        return Err(AionError::UnauthorizedSigner {
422            author: options.author_id,
423            version: new_version.as_u64(),
424        });
425    };
426    let supplied_pk = options.signing_key.verifying_key().to_bytes();
427    // Constant-time comparison — never `==` on key-shaped bytes.
428    // See .claude/rules/crypto.md and the audit verdict on issue
429    // (audit, 2026-04-25): `!=` here is a hard rule violation
430    // even though public keys aren't strictly secret.
431    if !bool::from(supplied_pk.ct_eq(&epoch.public_key)) {
432        return Err(AionError::KeyMismatch {
433            author: options.author_id,
434            epoch: epoch.epoch,
435        });
436    }
437    Ok(())
438}
439
440#[allow(clippy::cast_possible_truncation)]
441fn build_new_version_and_signature(
442    new_version: VersionNumber,
443    parent_hash: [u8; 32],
444    rules_hash: [u8; 32],
445    timestamp: u64,
446    message_offset: u64,
447    options: &CommitOptions<'_>,
448) -> (VersionEntry, SignatureEntry) {
449    let new_version_entry = VersionEntry::new(
450        new_version,
451        parent_hash,
452        rules_hash,
453        options.author_id,
454        timestamp,
455        message_offset,
456        options.message.len() as u32,
457    );
458    let signature_entry = sign_version(&new_version_entry, options.signing_key);
459    (new_version_entry, signature_entry)
460}
461
462/// Verify only the head (most recent) signature against the pinned
463/// registry — issue #35.
464///
465/// Replaces the previous full-chain sweep that ran on every
466/// [`commit_version`]. Walking every prior signature on every append
467/// gave commit O(n) per call and chain construction O(n²) — building
468/// 10k versions took ~150 minutes. The hash chain (verified at parse
469/// time and again on read by [`verify_file`]) seals all earlier links;
470/// re-running every prior signature at write time was redundant.
471///
472/// Cost: one Ed25519 verify and one structural check, regardless of
473/// chain length.
474#[allow(clippy::cast_possible_truncation)] // File counts fit in usize
475#[allow(clippy::arithmetic_side_effects)] // count - 1 guarded by count > 0
476fn verify_head_signature(
477    parser: &AionParser<'_>,
478    registry: &crate::key_registry::KeyRegistry,
479) -> Result<()> {
480    let header = parser.header();
481    let version_count = header.version_chain_count as usize;
482    let signature_count = header.signatures_count as usize;
483
484    if version_count != signature_count {
485        return Err(AionError::InvalidFormat {
486            reason: format!(
487                "Version count ({version_count}) does not match signature count ({signature_count})"
488            ),
489        });
490    }
491    if version_count == 0 {
492        return Err(AionError::InvalidFormat {
493            reason: "File has no versions".to_string(),
494        });
495    }
496
497    let last = version_count - 1;
498    let version = parser.get_version_entry(last)?;
499    let signature = parser.get_signature_entry(last)?;
500    verify_signature(&version, &signature, registry)
501}
502
503/// Get the last version entry from the parser
504#[allow(clippy::cast_possible_truncation)] // File counts fit in usize
505#[allow(clippy::arithmetic_side_effects)] // Checked above
506fn get_last_version_entry(parser: &AionParser<'_>) -> Result<VersionEntry> {
507    let header = parser.header();
508    let version_count = header.version_chain_count as usize;
509
510    if version_count == 0 {
511        return Err(AionError::InvalidFormat {
512            reason: "File has no versions".to_string(),
513        });
514    }
515
516    parser.get_version_entry(version_count - 1)
517}
518
519/// Encrypt rules content using file-specific key derivation
520#[allow(clippy::arithmetic_side_effects)] // Capacity calculation is safe
521fn encrypt_rules(
522    rules: &[u8],
523    file_id: FileId,
524    version: VersionNumber,
525) -> Result<(Vec<u8>, [u8; 32])> {
526    // Compute rules hash first
527    let rules_hash = hash(rules);
528
529    // Derive encryption key from file ID and version
530    let mut encryption_key = [0u8; 32];
531    let salt = file_id.as_u64().to_le_bytes();
532    let info = format!("aion-v2-rules-v{}", version.as_u64());
533
534    // Use a fixed master secret derived from file ID for deterministic key derivation
535    let master_secret = format!("aion-v2-master-{}", file_id.as_u64());
536
537    derive_key(
538        master_secret.as_bytes(),
539        &salt,
540        info.as_bytes(),
541        &mut encryption_key,
542    )?;
543
544    // Generate nonce and encrypt
545    let nonce = generate_nonce();
546    let aad = version.as_u64().to_le_bytes();
547    let ciphertext = encrypt(&encryption_key, &nonce, rules, &aad)?;
548
549    // Prepend nonce to ciphertext (nonce is 12 bytes)
550    let mut encrypted = Vec::with_capacity(12 + ciphertext.len());
551    encrypted.extend_from_slice(&nonce);
552    encrypted.extend_from_slice(&ciphertext);
553
554    Ok((encrypted, rules_hash))
555}
556
557/// Decrypt rules content using file-specific key derivation
558///
559/// This function performs the reverse of `encrypt_rules`:
560/// 1. Extracts the 12-byte nonce from the beginning of encrypted data
561/// 2. Derives the encryption key from `file_id` + version (same as encryption)
562/// 3. Decrypts using ChaCha20-Poly1305 AEAD
563/// 4. Verifies the decrypted data hash matches `expected_hash`
564///
565/// # Arguments
566///
567/// * `encrypted_rules` - Encrypted data with prepended nonce (nonce || ciphertext)
568/// * `file_id` - File identifier used for key derivation
569/// * `version` - Version number used for key derivation and AAD
570/// * `expected_hash` - Expected BLAKE3 hash of the plaintext rules
571///
572/// # Returns
573///
574/// * `Ok(Vec<u8>)` - Decrypted plaintext rules
575/// * `Err(AionError)` - On decryption failure or hash mismatch
576///
577/// # Errors
578///
579/// - `AionError::DecryptionFailed` if:
580///   - Encrypted data is too short (< 12 bytes for nonce)
581///   - Authentication tag is invalid (tampering detected)
582///   - Wrong key or nonce used
583/// - `AionError::HashMismatch` if decrypted data hash doesn't match expected
584///
585/// # Security
586///
587/// - Uses deterministic key derivation from `file_id` and version
588/// - Verifies authentication tag (prevents tampering)
589/// - Verifies content hash after decryption (defense in depth)
590/// - Constant-time operations where possible
591///
592/// # Example
593///
594/// ```no_run
595/// use aion_context::operations::decrypt_rules;
596/// use aion_context::types::{FileId, VersionNumber};
597///
598/// // Assuming we have encrypted rules from the file
599/// // let encrypted = parser.encrypted_rules()?;
600/// // let version_entry = parser.get_version_entry(0)?;
601/// //
602/// // let plaintext = decrypt_rules(
603/// //     encrypted,
604/// //     FileId::new(12345),
605/// //     VersionNumber::GENESIS,
606/// //     version_entry.rules_hash,
607/// // )?;
608/// ```
609pub fn decrypt_rules(
610    encrypted_rules: &[u8],
611    file_id: FileId,
612    version: VersionNumber,
613    expected_hash: [u8; 32],
614) -> Result<Vec<u8>> {
615    // Step 1: Extract nonce (first 12 bytes)
616    if encrypted_rules.len() < 12 {
617        return Err(AionError::DecryptionFailed {
618            reason: format!(
619                "Encrypted data too short: {} bytes, need at least 12 for nonce",
620                encrypted_rules.len()
621            ),
622        });
623    }
624
625    let mut nonce = [0u8; 12];
626    let nonce_slice = encrypted_rules
627        .get(..12)
628        .ok_or_else(|| AionError::DecryptionFailed {
629            reason: "Failed to extract nonce from encrypted data".to_string(),
630        })?;
631    nonce.copy_from_slice(nonce_slice);
632
633    // Step 2: Extract ciphertext (remaining bytes after nonce)
634    let ciphertext = encrypted_rules
635        .get(12..)
636        .ok_or_else(|| AionError::DecryptionFailed {
637            reason: "Failed to extract ciphertext from encrypted data".to_string(),
638        })?;
639
640    // Step 3: Derive encryption key (same process as encryption)
641    let mut encryption_key = [0u8; 32];
642    let salt = file_id.as_u64().to_le_bytes();
643    let info = format!("aion-v2-rules-v{}", version.as_u64());
644    let master_secret = format!("aion-v2-master-{}", file_id.as_u64());
645
646    derive_key(
647        master_secret.as_bytes(),
648        &salt,
649        info.as_bytes(),
650        &mut encryption_key,
651    )?;
652
653    // Step 4: Decrypt using ChaCha20-Poly1305
654    let aad = version.as_u64().to_le_bytes();
655    let plaintext = decrypt(&encryption_key, &nonce, ciphertext, &aad)?;
656
657    // Step 5: Verify hash of decrypted data
658    let actual_hash = hash(&plaintext);
659    if actual_hash != expected_hash {
660        return Err(AionError::HashMismatch {
661            expected: expected_hash,
662            actual: actual_hash,
663        });
664    }
665
666    Ok(plaintext)
667}
668
669/// Temporal warning types for timestamp validation (RFC-0005)
670///
671/// These warnings are informational only and do not cause verification to fail.
672/// They help identify potential clock skew or backdated entries.
673#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
674pub enum TemporalWarning {
675    /// Version has a timestamp earlier than its predecessor
676    NonMonotonicTimestamp {
677        /// The version with the older timestamp
678        version: u64,
679        /// Timestamp of this version (nanoseconds)
680        timestamp: u64,
681        /// Timestamp of the previous version (nanoseconds)
682        previous_timestamp: u64,
683    },
684    /// Version timestamp is in the future
685    FutureTimestamp {
686        /// The version with the future timestamp
687        version: u64,
688        /// The future timestamp (nanoseconds)
689        timestamp: u64,
690        /// Current system time when checked (nanoseconds)
691        current_time: u64,
692    },
693    /// Version timestamps are very close together (possible clock skew)
694    ClockSkewDetected {
695        /// The version where skew was detected
696        version: u64,
697        /// Time difference in nanoseconds (negative means earlier)
698        skew_nanos: i64,
699    },
700}
701
702impl std::fmt::Display for TemporalWarning {
703    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
704        match self {
705            Self::NonMonotonicTimestamp {
706                version,
707                timestamp,
708                previous_timestamp,
709            } => {
710                let diff_secs = previous_timestamp.saturating_sub(*timestamp) / 1_000_000_000;
711                write!(
712                    f,
713                    "Version {version} has non-monotonic timestamp ({diff_secs}s before previous version)"
714                )
715            }
716            Self::FutureTimestamp {
717                version,
718                timestamp,
719                current_time,
720            } => {
721                let diff_secs = timestamp.saturating_sub(*current_time) / 1_000_000_000;
722                write!(
723                    f,
724                    "Version {version} has future timestamp ({diff_secs}s in the future)"
725                )
726            }
727            Self::ClockSkewDetected {
728                version,
729                skew_nanos,
730            } => {
731                let skew_ms = skew_nanos / 1_000_000;
732                write!(
733                    f,
734                    "Version {version} shows potential clock skew ({skew_ms}ms)"
735                )
736            }
737        }
738    }
739}
740
741/// Detailed verification report for an AION file
742#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
743#[allow(clippy::struct_excessive_bools)] // Report struct legitimately needs multiple bool flags
744pub struct VerificationReport {
745    /// File ID
746    pub file_id: FileId,
747    /// Number of versions in the file
748    pub version_count: u64,
749    /// Whether the file structure is valid
750    pub structure_valid: bool,
751    /// Whether the file integrity hash matches
752    pub integrity_hash_valid: bool,
753    /// Whether the hash chain is intact
754    pub hash_chain_valid: bool,
755    /// Whether all signatures are valid
756    pub signatures_valid: bool,
757    /// Overall verification result
758    pub is_valid: bool,
759    /// Errors encountered during verification (if any)
760    pub errors: Vec<String>,
761    /// Temporal warnings (informational only, do not affect validity)
762    pub temporal_warnings: Vec<TemporalWarning>,
763}
764
765impl VerificationReport {
766    /// Create a new verification report
767    #[must_use]
768    pub const fn new(file_id: FileId, version_count: u64) -> Self {
769        Self {
770            file_id,
771            version_count,
772            structure_valid: false,
773            integrity_hash_valid: false,
774            hash_chain_valid: false,
775            signatures_valid: false,
776            is_valid: false,
777            errors: Vec::new(),
778            temporal_warnings: Vec::new(),
779        }
780    }
781
782    /// Check if the report has any temporal warnings
783    #[must_use]
784    pub fn has_temporal_warnings(&self) -> bool {
785        !self.temporal_warnings.is_empty()
786    }
787
788    /// Map this verdict to a process exit code.
789    ///
790    /// This is the **sole** producer of a verify-path exit code in
791    /// the aion CLI. A valid report maps to
792    /// [`std::process::ExitCode::SUCCESS`]; anything else maps to
793    /// `ExitCode::from(1)`. Callers must thread this through their
794    /// return type rather than branching on `is_valid` and calling
795    /// `std::process::exit` by hand — see issue #23.
796    #[must_use]
797    pub const fn exit_code(&self) -> std::process::ExitCode {
798        if self.is_valid {
799            std::process::ExitCode::SUCCESS
800        } else {
801            // NOTE: std::process::ExitCode::from is not const as of 1.70;
802            // construct via the literal path.
803            std::process::ExitCode::FAILURE
804        }
805    }
806
807    /// Mark all checks as passed
808    pub fn mark_valid(&mut self) {
809        self.structure_valid = true;
810        self.integrity_hash_valid = true;
811        self.hash_chain_valid = true;
812        self.signatures_valid = true;
813        self.is_valid = true;
814    }
815}
816
817// ============================================================================
818// Temporal Ordering Validation (RFC-0005)
819// ============================================================================
820
821/// Clock skew tolerance: 5 minutes in nanoseconds
822/// Timestamps within this range won't trigger warnings
823const CLOCK_SKEW_TOLERANCE_NANOS: u64 = 5 * 60 * 1_000_000_000;
824
825/// Future timestamp tolerance: 1 minute in nanoseconds
826/// Timestamps up to this far in the future are tolerated without warning
827const FUTURE_TOLERANCE_NANOS: u64 = 60 * 1_000_000_000;
828
829/// Check temporal ordering of version timestamps
830///
831/// This function validates that timestamps are consistent and monotonically
832/// increasing. It generates warnings (not errors) for:
833///
834/// 1. **Non-monotonic timestamps**: A version has a timestamp earlier than
835///    its predecessor (possible backdating or clock issues)
836/// 2. **Future timestamps**: A version has a timestamp in the future
837///    (possible clock drift)
838/// 3. **Clock skew**: Timestamps are suspiciously close together while
839///    going backwards
840///
841/// Per RFC-0005, these are informational warnings only and do NOT cause
842/// verification to fail. The cryptographic chain remains valid regardless
843/// of timestamp ordering.
844///
845/// # Arguments
846///
847/// * `versions` - Slice of version entries to check
848///
849/// # Returns
850///
851/// Vector of temporal warnings (empty if no issues detected)
852fn check_temporal_ordering(versions: &[VersionEntry]) -> Vec<TemporalWarning> {
853    let mut warnings = Vec::new();
854
855    if versions.is_empty() {
856        return warnings;
857    }
858
859    // Get current time for future timestamp detection
860    let current_time = current_timestamp_nanos();
861
862    // Check each version
863    for (i, version) in versions.iter().enumerate() {
864        let version_num = version.version_number;
865        let timestamp = version.timestamp;
866
867        // Check for future timestamps (beyond tolerance)
868        if timestamp > current_time.saturating_add(FUTURE_TOLERANCE_NANOS) {
869            warnings.push(TemporalWarning::FutureTimestamp {
870                version: version_num,
871                timestamp,
872                current_time,
873            });
874        }
875
876        // Check monotonicity against previous version
877        if let Some(prev) = i.checked_sub(1).and_then(|j| versions.get(j)) {
878            let prev_timestamp = prev.timestamp;
879
880            if timestamp < prev_timestamp {
881                // Non-monotonic: this version is earlier than previous
882                let diff = prev_timestamp.saturating_sub(timestamp);
883
884                // Only report if beyond clock skew tolerance
885                if diff > CLOCK_SKEW_TOLERANCE_NANOS {
886                    warnings.push(TemporalWarning::NonMonotonicTimestamp {
887                        version: version_num,
888                        timestamp,
889                        previous_timestamp: prev_timestamp,
890                    });
891                } else {
892                    // Within tolerance but still backwards - report as clock skew
893                    #[allow(clippy::cast_possible_wrap)]
894                    let skew_nanos = (diff as i64).saturating_neg();
895                    warnings.push(TemporalWarning::ClockSkewDetected {
896                        version: version_num,
897                        skew_nanos,
898                    });
899                }
900            }
901        }
902    }
903
904    warnings
905}
906
907/// Verify the integrity and authenticity of an AION file
908///
909/// This function performs comprehensive verification of an AION file according to RFC-0001.
910/// It validates:
911///
912/// 1. **Structure validation**: File format is parseable and well-formed
913/// 2. **Integrity hash check**: File-level hash matches computed hash
914/// 3. **Hash chain verification**: Version chain is intact from genesis
915/// 4. **Signature verification**: All Ed25519 signatures are valid
916///
917/// # Arguments
918///
919/// * `path` - Path to the AION file to verify
920///
921/// # Returns
922///
923/// * `Ok(VerificationReport)` - Detailed report of verification results
924/// * `Err(AionError)` - Critical error preventing verification
925///
926/// # Errors
927///
928/// - `AionError::Io` if file cannot be read
929/// - `AionError::InvalidFormat` if file structure is corrupted
930///
931/// # Example
932///
933/// ```no_run
934/// use aion_context::operations::verify_file;
935/// use aion_context::key_registry::KeyRegistry;
936/// use std::path::Path;
937///
938/// let registry = KeyRegistry::new(); // pin authors before production use
939/// let report = verify_file(Path::new("rules.aion"), &registry)?;
940///
941/// if report.is_valid {
942///     println!("✅ File verified successfully");
943///     println!("   Versions: {}", report.version_count);
944/// } else {
945///     println!("❌ Verification failed:");
946///     for error in &report.errors {
947///         println!("   - {}", error);
948///     }
949/// }
950/// # Ok::<(), aion_context::AionError>(())
951/// ```
952pub fn verify_file(
953    path: &Path,
954    registry: &crate::key_registry::KeyRegistry,
955) -> Result<VerificationReport> {
956    let file_bytes = std::fs::read(path).map_err(|e| AionError::FileReadError {
957        path: path.to_path_buf(),
958        source: e,
959    })?;
960    let parser = AionParser::new(&file_bytes)?;
961    let header = parser.header();
962
963    let mut report = VerificationReport::new(FileId(header.file_id), header.version_chain_count);
964    report.structure_valid = true;
965
966    match parser.verify_integrity() {
967        Ok(()) => report.integrity_hash_valid = true,
968        Err(e) => report
969            .errors
970            .push(format!("File integrity hash mismatch: {e}")),
971    }
972
973    let Some(versions) = collect_versions_into_report(&parser, &mut report)? else {
974        emit_verify_outcome(&report);
975        return Ok(report);
976    };
977
978    match verify_hash_chain(&versions) {
979        Ok(()) => report.hash_chain_valid = true,
980        Err(e) => report
981            .errors
982            .push(format!("Hash chain verification failed: {e}")),
983    }
984
985    let Some(signatures) = collect_signatures_into_report(&parser, &mut report)? else {
986        emit_verify_outcome(&report);
987        return Ok(report);
988    };
989
990    match verify_signatures_batch(&versions, &signatures, registry) {
991        Ok(()) => report.signatures_valid = true,
992        Err(e) => report
993            .errors
994            .push(format!("Signature verification failed: {e}")),
995    }
996
997    report.temporal_warnings = check_temporal_ordering(&versions);
998    report.is_valid = report.structure_valid
999        && report.integrity_hash_valid
1000        && report.hash_chain_valid
1001        && report.signatures_valid;
1002    emit_verify_outcome(&report);
1003    Ok(report)
1004}
1005
1006/// Bounded `reason` codes for `event="file_rejected"` (RFC-0007 / observability rule).
1007const fn classify_verify_failure(report: &VerificationReport) -> &'static str {
1008    if !report.structure_valid {
1009        "structure_invalid"
1010    } else if !report.integrity_hash_valid {
1011        "integrity_hash_mismatch"
1012    } else if !report.hash_chain_valid {
1013        "hash_chain_broken"
1014    } else if !report.signatures_valid {
1015        "signature_invalid"
1016    } else {
1017        "unknown"
1018    }
1019}
1020
1021fn emit_verify_outcome(report: &VerificationReport) {
1022    let file_id = crate::obs::short_hex(&report.file_id.as_u64().to_le_bytes());
1023    if report.is_valid {
1024        tracing::info!(
1025            event = "file_verified",
1026            file_id = %file_id,
1027            versions = report.version_count,
1028        );
1029    } else {
1030        tracing::warn!(
1031            event = "file_rejected",
1032            file_id = %file_id,
1033            versions = report.version_count,
1034            reason = classify_verify_failure(report),
1035        );
1036    }
1037}
1038
1039#[allow(clippy::cast_possible_truncation)]
1040fn collect_versions_into_report(
1041    parser: &AionParser<'_>,
1042    report: &mut VerificationReport,
1043) -> Result<Option<Vec<VersionEntry>>> {
1044    let count = parser.header().version_chain_count as usize;
1045    let mut versions = Vec::with_capacity(count);
1046    for i in 0..count {
1047        match parser.get_version_entry(i) {
1048            Ok(entry) => versions.push(entry),
1049            Err(e) => {
1050                report
1051                    .errors
1052                    .push(format!("Failed to read version entry {i}: {e}"));
1053                return Ok(None);
1054            }
1055        }
1056    }
1057    Ok(Some(versions))
1058}
1059
1060#[allow(clippy::cast_possible_truncation)]
1061fn collect_signatures_into_report(
1062    parser: &AionParser<'_>,
1063    report: &mut VerificationReport,
1064) -> Result<Option<Vec<SignatureEntry>>> {
1065    let count = parser.header().signatures_count as usize;
1066    let mut signatures = Vec::with_capacity(count);
1067    for i in 0..count {
1068        match parser.get_signature_entry(i) {
1069            Ok(entry) => signatures.push(entry),
1070            Err(e) => {
1071                report
1072                    .errors
1073                    .push(format!("Failed to read signature entry {i}: {e}"));
1074                return Ok(None);
1075            }
1076        }
1077    }
1078    Ok(Some(signatures))
1079}
1080
1081// ============================================================================
1082// File Inspection Operations
1083// ============================================================================
1084
1085/// Information about a version in the file
1086#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1087pub struct VersionInfo {
1088    /// Version number
1089    pub version_number: u64,
1090    /// Author ID
1091    pub author_id: u64,
1092    /// Timestamp (nanoseconds since Unix epoch)
1093    pub timestamp: u64,
1094    /// Commit message
1095    pub message: String,
1096    /// Rules content hash
1097    pub rules_hash: [u8; 32],
1098    /// Parent version hash (None for genesis)
1099    pub parent_hash: Option<[u8; 32]>,
1100}
1101
1102/// Information about a signature in the file
1103#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1104pub struct SignatureInfo {
1105    /// Version number this signature is for
1106    pub version_number: u64,
1107    /// Author ID
1108    pub author_id: u64,
1109    /// Public key (32 bytes)
1110    pub public_key: [u8; 32],
1111    /// Signature verification status
1112    pub verified: bool,
1113    /// Verification error message (if any)
1114    pub error: Option<String>,
1115}
1116
1117/// Complete file information for inspection
1118#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1119pub struct FileInfo {
1120    /// File ID
1121    pub file_id: u64,
1122    /// Total number of versions
1123    pub version_count: u64,
1124    /// Current (latest) version number
1125    pub current_version: u64,
1126    /// List of all versions
1127    pub versions: Vec<VersionInfo>,
1128    /// List of all signatures
1129    pub signatures: Vec<SignatureInfo>,
1130}
1131
1132/// Get the current (latest) rules content from an AION file
1133///
1134/// This function loads the file, finds the latest version entry,
1135/// and decrypts the rules using the file-specific encryption key.
1136///
1137/// # Arguments
1138///
1139/// * `path` - Path to the AION file
1140///
1141/// # Returns
1142///
1143/// * `Ok(Vec<u8>)` - Decrypted rules content
1144/// * `Err(AionError)` - On failure
1145///
1146/// # Errors
1147///
1148/// - `AionError::FileReadError` - Cannot read the file
1149/// - `AionError::InvalidFormat` - File format is invalid
1150/// - `AionError::DecryptionFailed` - Cannot decrypt rules
1151///
1152/// # Example
1153///
1154/// ```no_run
1155/// use aion_context::operations::show_current_rules;
1156/// use std::path::Path;
1157///
1158/// let rules = show_current_rules(Path::new("rules.aion"))?;
1159/// let rules_text = String::from_utf8(rules)?;
1160/// println!("Current rules:\n{}", rules_text);
1161/// # Ok::<(), Box<dyn std::error::Error>>(())
1162/// ```
1163pub fn show_current_rules(path: &Path) -> Result<Vec<u8>> {
1164    // Load and parse the file
1165    let file_bytes = std::fs::read(path).map_err(|e| AionError::FileReadError {
1166        path: path.to_path_buf(),
1167        source: e,
1168    })?;
1169    let parser = AionParser::new(&file_bytes)?;
1170
1171    let header = parser.header();
1172    let file_id = FileId(header.file_id);
1173    let version_count = header.version_chain_count;
1174
1175    if version_count == 0 {
1176        return Err(AionError::InvalidFormat {
1177            reason: "File has no versions".to_string(),
1178        });
1179    }
1180
1181    // Get the latest version (last in chain)
1182    #[allow(clippy::cast_possible_truncation)]
1183    #[allow(clippy::arithmetic_side_effects)] // version_count >= 1 checked above
1184    let latest_idx = (version_count - 1) as usize;
1185    let latest_version = parser.get_version_entry(latest_idx)?;
1186
1187    // Get encrypted rules (entire section contains current version only)
1188    let encrypted_rules = parser.encrypted_rules_bytes()?;
1189
1190    // Decrypt the rules
1191    decrypt_rules(
1192        encrypted_rules,
1193        file_id,
1194        VersionNumber(latest_version.version_number),
1195        latest_version.rules_hash,
1196    )
1197}
1198
1199/// Get version history for all versions in the file
1200///
1201/// Returns detailed information about each version including
1202/// author, timestamp, message, and hashes.
1203///
1204/// # Arguments
1205///
1206/// * `path` - Path to the AION file
1207///
1208/// # Returns
1209///
1210/// * `Ok(Vec<VersionInfo>)` - List of version information
1211/// * `Err(AionError)` - On failure
1212///
1213/// # Errors
1214///
1215/// - `AionError::FileReadError` - Cannot read the file
1216/// - `AionError::InvalidFormat` - File format is invalid
1217///
1218/// # Example
1219///
1220/// ```no_run
1221/// use aion_context::operations::show_version_history;
1222/// use std::path::Path;
1223///
1224/// let versions = show_version_history(Path::new("rules.aion"))?;
1225/// for v in versions {
1226///     println!("Version {}: {} (by author {})",
1227///         v.version_number, v.message, v.author_id);
1228/// }
1229/// # Ok::<(), Box<dyn std::error::Error>>(())
1230/// ```
1231pub fn show_version_history(path: &Path) -> Result<Vec<VersionInfo>> {
1232    // Load and parse the file
1233    let file_bytes = std::fs::read(path).map_err(|e| AionError::FileReadError {
1234        path: path.to_path_buf(),
1235        source: e,
1236    })?;
1237    let parser = AionParser::new(&file_bytes)?;
1238
1239    let header = parser.header();
1240    let version_count = header.version_chain_count;
1241
1242    #[allow(clippy::cast_possible_truncation)] // version_count is header field, won't exceed usize
1243    let mut versions = Vec::with_capacity(version_count as usize);
1244
1245    // Get string table for extracting messages
1246    let string_table = parser.string_table_bytes()?;
1247
1248    #[allow(clippy::cast_possible_truncation)]
1249    for i in 0..version_count as usize {
1250        let entry = parser.get_version_entry(i)?;
1251
1252        // Get commit message from string table
1253        let message_offset = entry.message_offset as usize;
1254        let message_length = entry.message_length as usize;
1255
1256        #[allow(clippy::arithmetic_side_effects)] // Checked bounds before use
1257        let message =
1258            message_offset
1259                .checked_add(message_length)
1260                .map_or_else(String::new, |end_offset| {
1261                    if end_offset <= string_table.len() {
1262                        string_table
1263                            .get(message_offset..end_offset)
1264                            .map(|bytes| String::from_utf8_lossy(bytes).to_string())
1265                            .unwrap_or_default()
1266                    } else {
1267                        String::new()
1268                    }
1269                });
1270
1271        // Parent hash is all zeros for genesis, otherwise it's the actual hash
1272        let parent_hash = if entry.version_number == 1 {
1273            None
1274        } else {
1275            Some(entry.parent_hash)
1276        };
1277
1278        versions.push(VersionInfo {
1279            version_number: entry.version_number,
1280            author_id: entry.author_id,
1281            timestamp: entry.timestamp,
1282            message,
1283            rules_hash: entry.rules_hash,
1284            parent_hash,
1285        });
1286    }
1287
1288    Ok(versions)
1289}
1290
1291/// Get signature information with verification status
1292///
1293/// Returns detailed information about each signature including
1294/// verification status and any errors encountered during verification.
1295///
1296/// # Arguments
1297///
1298/// * `path` - Path to the AION file
1299///
1300/// # Returns
1301///
1302/// * `Ok(Vec<SignatureInfo>)` - List of signature information
1303/// * `Err(AionError)` - On failure
1304///
1305/// # Errors
1306///
1307/// - `AionError::FileReadError` - Cannot read the file
1308/// - `AionError::InvalidFormat` - File format is invalid
1309///
1310/// # Example
1311///
1312/// ```no_run
1313/// use aion_context::operations::show_signatures;
1314/// use aion_context::key_registry::KeyRegistry;
1315/// use std::path::Path;
1316///
1317/// let registry = KeyRegistry::new();
1318/// let signatures = show_signatures(Path::new("rules.aion"), &registry)?;
1319/// for sig in signatures {
1320///     let status = if sig.verified { "✓" } else { "✗" };
1321///     println!("{} Version {} signed by author {}",
1322///         status, sig.version_number, sig.author_id);
1323/// }
1324/// # Ok::<(), Box<dyn std::error::Error>>(())
1325/// ```
1326/// Each signature's `verified` field reflects the registry-aware
1327/// verdict: both the Ed25519 signature AND the signer's active
1328/// epoch (at the signed version number) must match. A signer
1329/// whose pinned active epoch does not match the embedded
1330/// `public_key` is reported `verified = false`.
1331pub fn show_signatures(
1332    path: &Path,
1333    registry: &crate::key_registry::KeyRegistry,
1334) -> Result<Vec<SignatureInfo>> {
1335    // Load and parse the file
1336    let file_bytes = std::fs::read(path).map_err(|e| AionError::FileReadError {
1337        path: path.to_path_buf(),
1338        source: e,
1339    })?;
1340    let parser = AionParser::new(&file_bytes)?;
1341
1342    let header = parser.header();
1343    let sig_count = header.signatures_count;
1344    let version_count = header.version_chain_count;
1345
1346    // Collect version entries for verification
1347    #[allow(clippy::cast_possible_truncation)] // version_count is header field, won't exceed usize
1348    let mut versions = Vec::with_capacity(version_count as usize);
1349    #[allow(clippy::cast_possible_truncation)]
1350    for i in 0..version_count as usize {
1351        versions.push(parser.get_version_entry(i)?);
1352    }
1353
1354    #[allow(clippy::cast_possible_truncation)] // sig_count is header field, won't exceed usize
1355    let mut signatures = Vec::with_capacity(sig_count as usize);
1356
1357    // Signatures are indexed parallel to versions (1:1 mapping)
1358    #[allow(clippy::cast_possible_truncation)]
1359    for i in 0..sig_count as usize {
1360        let sig_entry = parser.get_signature_entry(i)?;
1361
1362        // Get corresponding version entry (parallel indexing)
1363        let version_entry = versions.get(i).ok_or_else(|| AionError::InvalidFormat {
1364            reason: format!(
1365                "Signature index {} exceeds version count {}",
1366                i,
1367                versions.len()
1368            ),
1369        })?;
1370
1371        let result = crate::signature_chain::verify_signature(version_entry, &sig_entry, registry);
1372        let (verified, error) = match result {
1373            Ok(()) => (true, None),
1374            Err(e) => (false, Some(e.to_string())),
1375        };
1376
1377        signatures.push(SignatureInfo {
1378            version_number: version_entry.version_number,
1379            author_id: sig_entry.author_id,
1380            public_key: sig_entry.public_key,
1381            verified,
1382            error,
1383        });
1384    }
1385
1386    Ok(signatures)
1387}
1388
1389/// Get complete file information including versions and signatures
1390///
1391/// This is a convenience function that combines version history
1392/// and signature information into a single comprehensive report.
1393///
1394/// # Arguments
1395///
1396/// * `path` - Path to the AION file
1397///
1398/// # Returns
1399///
1400/// * `Ok(FileInfo)` - Complete file information
1401/// * `Err(AionError)` - On failure
1402///
1403/// # Example
1404///
1405/// ```no_run
1406/// use aion_context::operations::show_file_info;
1407/// use aion_context::key_registry::KeyRegistry;
1408/// use std::path::Path;
1409///
1410/// let registry = KeyRegistry::new();
1411/// let info = show_file_info(Path::new("rules.aion"), &registry)?;
1412/// println!("File ID: 0x{:016x}", info.file_id);
1413/// println!("Current version: {}/{}", info.current_version, info.version_count);
1414/// # Ok::<(), Box<dyn std::error::Error>>(())
1415/// ```
1416pub fn show_file_info(
1417    path: &Path,
1418    registry: &crate::key_registry::KeyRegistry,
1419) -> Result<FileInfo> {
1420    // Load and parse the file
1421    let file_bytes = std::fs::read(path).map_err(|e| AionError::FileReadError {
1422        path: path.to_path_buf(),
1423        source: e,
1424    })?;
1425    let parser = AionParser::new(&file_bytes)?;
1426
1427    let header = parser.header();
1428
1429    let versions = show_version_history(path)?;
1430    let signatures = show_signatures(path, registry)?;
1431
1432    let current_version = versions.last().map_or(0, |v| v.version_number);
1433
1434    Ok(FileInfo {
1435        file_id: header.file_id,
1436        version_count: header.version_chain_count,
1437        current_version,
1438        versions,
1439        signatures,
1440    })
1441}
1442
1443/// Build string table with the new commit message appended
1444fn build_string_table_with_message(
1445    message: &str,
1446    parser: &AionParser<'_>,
1447) -> Result<(Vec<u8>, u64)> {
1448    // Get existing string table
1449    let existing_table = parser.string_table_bytes()?;
1450
1451    // Calculate offset for new message
1452    let message_offset = existing_table.len() as u64;
1453
1454    // Build new table with message appended
1455    let mut new_table = existing_table.to_vec();
1456    new_table.extend_from_slice(message.as_bytes());
1457    new_table.push(0); // Null terminator
1458
1459    Ok((new_table, message_offset))
1460}
1461
1462/// Get current timestamp in nanoseconds since Unix epoch
1463#[allow(clippy::cast_possible_truncation)] // Nanoseconds won't exceed u64 for realistic times
1464fn current_timestamp_nanos() -> u64 {
1465    SystemTime::now()
1466        .duration_since(UNIX_EPOCH)
1467        .map(|d| d.as_nanos() as u64)
1468        .unwrap_or(0)
1469}
1470
1471/// Build the updated `AionFile` with all existing and new data
1472#[allow(clippy::too_many_arguments)]
1473#[allow(clippy::cast_possible_truncation)] // File counts fit in usize
1474#[allow(clippy::arithmetic_side_effects)] // Capacity calculations are safe
1475fn build_updated_file(
1476    parser: &AionParser<'_>,
1477    header: &crate::parser::FileHeader,
1478    new_version: VersionNumber,
1479    new_rules_hash: [u8; 32],
1480    encrypted_rules: Vec<u8>,
1481    new_version_entry: VersionEntry,
1482    new_signature: SignatureEntry,
1483    string_table: Vec<u8>,
1484    timestamp: u64,
1485    author_id: AuthorId,
1486) -> Result<AionFile> {
1487    let versions = collect_existing_plus(parser, header.version_chain_count, new_version_entry)?;
1488    let signatures =
1489        collect_existing_plus_signatures(parser, header.signatures_count, new_signature)?;
1490    let audit_entries = collect_existing_audit_plus_commit(parser, header, timestamp, author_id)?;
1491
1492    AionFile::builder()
1493        .file_id(FileId::new(header.file_id))
1494        .current_version(new_version)
1495        .flags(header.flags)
1496        .root_hash(header.root_hash)
1497        .current_hash(new_rules_hash)
1498        .created_at(header.created_at)
1499        .modified_at(timestamp)
1500        .encrypted_rules(encrypted_rules)
1501        .versions(versions)
1502        .signatures(signatures)
1503        .audit_entries(audit_entries)
1504        .string_table(string_table)
1505        .build()
1506}
1507
1508#[allow(clippy::cast_possible_truncation)]
1509fn collect_versions(parser: &AionParser<'_>, count: u64) -> Result<Vec<VersionEntry>> {
1510    let n = count as usize;
1511    let mut versions = Vec::with_capacity(n);
1512    for i in 0..n {
1513        versions.push(parser.get_version_entry(i)?);
1514    }
1515    Ok(versions)
1516}
1517
1518#[allow(clippy::cast_possible_truncation)]
1519#[allow(clippy::arithmetic_side_effects)]
1520fn collect_existing_plus(
1521    parser: &AionParser<'_>,
1522    count: u64,
1523    new_entry: VersionEntry,
1524) -> Result<Vec<VersionEntry>> {
1525    let mut versions = collect_versions(parser, count)?;
1526    versions.push(new_entry);
1527    Ok(versions)
1528}
1529
1530#[allow(clippy::cast_possible_truncation)]
1531#[allow(clippy::arithmetic_side_effects)]
1532fn collect_existing_plus_signatures(
1533    parser: &AionParser<'_>,
1534    count: u64,
1535    new_entry: SignatureEntry,
1536) -> Result<Vec<SignatureEntry>> {
1537    let n = count as usize;
1538    let mut signatures = Vec::with_capacity(n + 1);
1539    for i in 0..n {
1540        signatures.push(parser.get_signature_entry(i)?);
1541    }
1542    signatures.push(new_entry);
1543    Ok(signatures)
1544}
1545
1546#[allow(clippy::cast_possible_truncation)]
1547#[allow(clippy::arithmetic_side_effects)]
1548fn collect_existing_audit_plus_commit(
1549    parser: &AionParser<'_>,
1550    header: &crate::parser::FileHeader,
1551    timestamp: u64,
1552    author_id: AuthorId,
1553) -> Result<Vec<AuditEntry>> {
1554    let n = header.audit_trail_count as usize;
1555    let mut audit_entries = Vec::with_capacity(n + 1);
1556    for i in 0..n {
1557        audit_entries.push(parser.get_audit_entry(i)?);
1558    }
1559    let previous_hash = audit_entries
1560        .last()
1561        .map_or([0u8; 32], AuditEntry::compute_hash);
1562    audit_entries.push(AuditEntry::new(
1563        timestamp,
1564        author_id,
1565        ActionCode::CommitVersion,
1566        0,
1567        0,
1568        previous_hash,
1569    ));
1570    Ok(audit_entries)
1571}
1572
1573#[cfg(test)]
1574#[allow(clippy::unwrap_used)]
1575#[allow(clippy::inconsistent_digit_grouping)]
1576#[allow(clippy::indexing_slicing)]
1577#[allow(clippy::cast_possible_truncation)]
1578mod tests {
1579    use super::*;
1580    use crate::audit::ActionCode;
1581    use crate::key_registry::KeyRegistry;
1582    use crate::serializer::AionSerializer;
1583    use crate::signature_chain::{create_genesis_version, sign_version};
1584    use tempfile::TempDir;
1585
1586    /// Build a registry pinning `author_id` at epoch 0 with `signing_key`.
1587    /// Used by every test that calls `verify_file` / `commit_version` / `show_signatures`.
1588    fn test_reg(author_id: AuthorId, signing_key: &SigningKey) -> KeyRegistry {
1589        let mut reg = KeyRegistry::new();
1590        let master = SigningKey::generate();
1591        reg.register_author(
1592            author_id,
1593            master.verifying_key(),
1594            signing_key.verifying_key(),
1595            0,
1596        )
1597        .unwrap_or_else(|_| std::process::abort());
1598        reg
1599    }
1600
1601    /// Create a minimal valid AION file for testing
1602    fn create_test_file(signing_key: &SigningKey, author_id: AuthorId) -> Vec<u8> {
1603        let timestamp = 1700000000_000_000_000u64;
1604        let rules = b"initial rules content";
1605        let rules_hash = hash(rules);
1606
1607        // Create genesis version
1608        let genesis = create_genesis_version(rules_hash, author_id, timestamp, 0, 15);
1609
1610        // Sign it
1611        let signature = sign_version(&genesis, signing_key);
1612
1613        // Create audit entry
1614        let audit = AuditEntry::new(
1615            timestamp,
1616            author_id,
1617            ActionCode::CreateGenesis,
1618            0,
1619            0,
1620            [0u8; 32],
1621        );
1622
1623        // Encrypt rules (simplified for test)
1624        let file_id = FileId::new(12345);
1625        let (encrypted_rules, _) = encrypt_rules(rules, file_id, VersionNumber::GENESIS).unwrap();
1626
1627        // Build string table
1628        let (string_table, _) = AionSerializer::build_string_table(&["Genesis version"]);
1629
1630        // Build file
1631        let file = AionFile::builder()
1632            .file_id(file_id)
1633            .current_version(VersionNumber::GENESIS)
1634            .flags(0x0001) // encrypted
1635            .root_hash(rules_hash)
1636            .current_hash(rules_hash)
1637            .created_at(timestamp)
1638            .modified_at(timestamp)
1639            .encrypted_rules(encrypted_rules)
1640            .add_version(genesis)
1641            .add_signature(signature)
1642            .add_audit_entry(audit)
1643            .string_table(string_table)
1644            .build()
1645            .unwrap();
1646
1647        AionSerializer::serialize(&file).unwrap()
1648    }
1649
1650    mod commit_version_tests {
1651        use super::*;
1652
1653        #[test]
1654        fn should_commit_new_version() {
1655            let temp_dir = TempDir::new().unwrap();
1656            let file_path = temp_dir.path().join("test.aion");
1657
1658            // Create initial file
1659            let signing_key = SigningKey::generate();
1660            let author_id = AuthorId::new(50001);
1661            let initial_bytes = create_test_file(&signing_key, author_id);
1662            std::fs::write(&file_path, &initial_bytes).unwrap();
1663
1664            // Commit new version
1665            let options = CommitOptions {
1666                author_id,
1667                signing_key: &signing_key,
1668                message: "Updated rules",
1669                timestamp: Some(1700000001_000_000_000),
1670            };
1671
1672            let result = commit_version(
1673                &file_path,
1674                b"new rules content",
1675                &options,
1676                &test_reg(author_id, &signing_key),
1677            )
1678            .unwrap();
1679
1680            assert_eq!(result.version.as_u64(), 2);
1681            assert_ne!(result.rules_hash, [0u8; 32]);
1682        }
1683
1684        #[test]
1685        fn should_verify_chain_before_commit() {
1686            let temp_dir = TempDir::new().unwrap();
1687            let file_path = temp_dir.path().join("test.aion");
1688
1689            // Create initial file
1690            let signing_key = SigningKey::generate();
1691            let author_id = AuthorId::new(50001);
1692            let initial_bytes = create_test_file(&signing_key, author_id);
1693            std::fs::write(&file_path, &initial_bytes).unwrap();
1694
1695            // Read file and verify we can parse it
1696            let bytes = std::fs::read(&file_path).unwrap();
1697            let parser = AionParser::new(&bytes).unwrap();
1698            assert_eq!(parser.header().current_version, 1);
1699        }
1700
1701        #[test]
1702        fn should_increment_version_correctly() {
1703            let temp_dir = TempDir::new().unwrap();
1704            let file_path = temp_dir.path().join("test.aion");
1705
1706            // Create initial file
1707            let signing_key = SigningKey::generate();
1708            let author_id = AuthorId::new(50001);
1709            let initial_bytes = create_test_file(&signing_key, author_id);
1710            std::fs::write(&file_path, &initial_bytes).unwrap();
1711
1712            // Commit multiple versions
1713            for i in 2..=5 {
1714                let options = CommitOptions {
1715                    author_id,
1716                    signing_key: &signing_key,
1717                    message: &format!("Version {i}"),
1718                    timestamp: Some(1700000000_000_000_000 + i * 1_000_000_000),
1719                };
1720
1721                let result = commit_version(
1722                    &file_path,
1723                    format!("rules v{i}").as_bytes(),
1724                    &options,
1725                    &test_reg(author_id, &signing_key),
1726                )
1727                .unwrap();
1728                assert_eq!(result.version.as_u64(), i);
1729            }
1730
1731            // Verify final state
1732            let bytes = std::fs::read(&file_path).unwrap();
1733            let parser = AionParser::new(&bytes).unwrap();
1734            assert_eq!(parser.header().current_version, 5);
1735            assert_eq!(parser.header().version_chain_count, 5);
1736        }
1737
1738        #[test]
1739        fn should_preserve_existing_versions() {
1740            let temp_dir = TempDir::new().unwrap();
1741            let file_path = temp_dir.path().join("test.aion");
1742
1743            // Create initial file
1744            let signing_key = SigningKey::generate();
1745            let author_id = AuthorId::new(50001);
1746            let initial_bytes = create_test_file(&signing_key, author_id);
1747            std::fs::write(&file_path, &initial_bytes).unwrap();
1748
1749            // Get initial version hash
1750            let initial_parser = AionParser::new(&initial_bytes).unwrap();
1751            let initial_version = initial_parser.get_version_entry(0).unwrap();
1752            let initial_hash = compute_version_hash(&initial_version);
1753
1754            // Commit new version
1755            let options = CommitOptions {
1756                author_id,
1757                signing_key: &signing_key,
1758                message: "New version",
1759                timestamp: Some(1700000001_000_000_000),
1760            };
1761            commit_version(
1762                &file_path,
1763                b"new rules",
1764                &options,
1765                &test_reg(author_id, &signing_key),
1766            )
1767            .unwrap();
1768
1769            // Verify original version is preserved
1770            let bytes = std::fs::read(&file_path).unwrap();
1771            let parser = AionParser::new(&bytes).unwrap();
1772            let preserved_version = parser.get_version_entry(0).unwrap();
1773            let preserved_hash = compute_version_hash(&preserved_version);
1774
1775            assert_eq!(initial_hash, preserved_hash);
1776        }
1777
1778        #[test]
1779        fn should_link_to_parent_correctly() {
1780            let temp_dir = TempDir::new().unwrap();
1781            let file_path = temp_dir.path().join("test.aion");
1782
1783            // Create initial file
1784            let signing_key = SigningKey::generate();
1785            let author_id = AuthorId::new(50001);
1786            let initial_bytes = create_test_file(&signing_key, author_id);
1787            std::fs::write(&file_path, &initial_bytes).unwrap();
1788
1789            // Get genesis hash
1790            let parser = AionParser::new(&initial_bytes).unwrap();
1791            let genesis = parser.get_version_entry(0).unwrap();
1792            let genesis_hash = compute_version_hash(&genesis);
1793
1794            // Commit new version
1795            let options = CommitOptions {
1796                author_id,
1797                signing_key: &signing_key,
1798                message: "Version 2",
1799                timestamp: Some(1700000001_000_000_000),
1800            };
1801            commit_version(
1802                &file_path,
1803                b"new rules",
1804                &options,
1805                &test_reg(author_id, &signing_key),
1806            )
1807            .unwrap();
1808
1809            // Verify version 2 links to genesis
1810            let bytes = std::fs::read(&file_path).unwrap();
1811            let parser = AionParser::new(&bytes).unwrap();
1812            let version2 = parser.get_version_entry(1).unwrap();
1813
1814            assert_eq!(version2.parent_hash, genesis_hash);
1815        }
1816    }
1817
1818    mod encrypt_rules_tests {
1819        use super::*;
1820
1821        #[test]
1822        fn should_encrypt_rules_deterministically_with_same_nonce() {
1823            // Note: With random nonce, ciphertext differs each time
1824            // This test verifies the structure is correct
1825            let rules = b"test rules content";
1826            let file_id = FileId::new(12345);
1827            let version = VersionNumber::GENESIS;
1828
1829            let (encrypted1, hash1) = encrypt_rules(rules, file_id, version).unwrap();
1830            let (encrypted2, hash2) = encrypt_rules(rules, file_id, version).unwrap();
1831
1832            // Hashes should be identical
1833            assert_eq!(hash1, hash2);
1834
1835            // Encrypted data should have nonce + ciphertext
1836            assert!(encrypted1.len() >= 12 + rules.len());
1837            assert!(encrypted2.len() >= 12 + rules.len());
1838        }
1839
1840        #[test]
1841        fn should_produce_different_hashes_for_different_rules() {
1842            let file_id = FileId::new(12345);
1843            let version = VersionNumber::GENESIS;
1844
1845            let (_, hash1) = encrypt_rules(b"rules A", file_id, version).unwrap();
1846            let (_, hash2) = encrypt_rules(b"rules B", file_id, version).unwrap();
1847
1848            assert_ne!(hash1, hash2);
1849        }
1850    }
1851
1852    mod decrypt_rules_tests {
1853        use super::*;
1854
1855        #[test]
1856        fn should_decrypt_encrypted_rules_successfully() {
1857            // Arrange: Encrypt rules
1858            let rules = b"test rules content that needs decryption";
1859            let file_id = FileId::new(12345);
1860            let version = VersionNumber::GENESIS;
1861
1862            let (encrypted, expected_hash) = encrypt_rules(rules, file_id, version).unwrap();
1863
1864            // Act: Decrypt the encrypted rules
1865            let decrypted = decrypt_rules(&encrypted, file_id, version, expected_hash).unwrap();
1866
1867            // Assert: Decrypted matches original
1868            assert_eq!(decrypted, rules);
1869        }
1870
1871        #[test]
1872        fn should_verify_roundtrip_for_multiple_versions() {
1873            let file_id = FileId::new(54321);
1874
1875            for version_num in 1..=5 {
1876                let version = VersionNumber(version_num);
1877                let rules = format!("Rules for version {version_num}").into_bytes();
1878
1879                let (encrypted, hash) = encrypt_rules(&rules, file_id, version).unwrap();
1880                let decrypted = decrypt_rules(&encrypted, file_id, version, hash).unwrap();
1881
1882                assert_eq!(decrypted, rules);
1883            }
1884        }
1885
1886        #[test]
1887        fn should_reject_decryption_with_wrong_file_id() {
1888            // Arrange: Encrypt with one file ID
1889            let rules = b"sensitive rules";
1890            let correct_file_id = FileId::new(12345);
1891            let wrong_file_id = FileId::new(99999);
1892            let version = VersionNumber::GENESIS;
1893
1894            let (encrypted, hash) = encrypt_rules(rules, correct_file_id, version).unwrap();
1895
1896            // Act: Try to decrypt with wrong file ID
1897            let result = decrypt_rules(&encrypted, wrong_file_id, version, hash);
1898
1899            // Assert: Decryption fails (wrong key derived)
1900            assert!(result.is_err());
1901        }
1902
1903        #[test]
1904        fn should_reject_decryption_with_wrong_version() {
1905            // Arrange: Encrypt with one version
1906            let rules = b"version-specific rules";
1907            let file_id = FileId::new(12345);
1908            let correct_version = VersionNumber(1);
1909            let wrong_version = VersionNumber(2);
1910
1911            let (encrypted, hash) = encrypt_rules(rules, file_id, correct_version).unwrap();
1912
1913            // Act: Try to decrypt with wrong version
1914            let result = decrypt_rules(&encrypted, file_id, wrong_version, hash);
1915
1916            // Assert: Decryption fails (wrong AAD)
1917            assert!(result.is_err());
1918        }
1919
1920        #[test]
1921        fn should_reject_tampered_ciphertext() {
1922            // Arrange: Encrypt rules
1923            let rules = b"rules that will be tampered with";
1924            let file_id = FileId::new(12345);
1925            let version = VersionNumber::GENESIS;
1926
1927            let (mut encrypted, hash) = encrypt_rules(rules, file_id, version).unwrap();
1928
1929            // Act: Tamper with ciphertext (skip nonce, modify actual ciphertext)
1930            if encrypted.len() > 20 {
1931                encrypted[20] ^= 0x01;
1932            }
1933
1934            let result = decrypt_rules(&encrypted, file_id, version, hash);
1935
1936            // Assert: Decryption fails (authentication tag invalid)
1937            assert!(result.is_err());
1938            if let Err(e) = result {
1939                assert!(matches!(e, AionError::DecryptionFailed { .. }));
1940            }
1941        }
1942
1943        #[test]
1944        fn should_reject_tampered_nonce() {
1945            // Arrange: Encrypt rules
1946            let rules = b"rules with nonce tampering";
1947            let file_id = FileId::new(12345);
1948            let version = VersionNumber::GENESIS;
1949
1950            let (mut encrypted, hash) = encrypt_rules(rules, file_id, version).unwrap();
1951
1952            // Act: Tamper with nonce (first 12 bytes)
1953            if !encrypted.is_empty() {
1954                encrypted[0] ^= 0x01;
1955            }
1956
1957            let result = decrypt_rules(&encrypted, file_id, version, hash);
1958
1959            // Assert: Decryption fails (wrong nonce)
1960            assert!(result.is_err());
1961        }
1962
1963        #[test]
1964        fn should_reject_wrong_expected_hash() {
1965            // Arrange: Encrypt rules
1966            let rules = b"rules with wrong hash";
1967            let file_id = FileId::new(12345);
1968            let version = VersionNumber::GENESIS;
1969
1970            let (encrypted, _correct_hash) = encrypt_rules(rules, file_id, version).unwrap();
1971
1972            // Act: Use wrong expected hash
1973            let wrong_hash = [0u8; 32]; // All zeros is very unlikely to match
1974            let result = decrypt_rules(&encrypted, file_id, version, wrong_hash);
1975
1976            // Assert: Hash verification fails
1977            assert!(result.is_err());
1978            if let Err(e) = result {
1979                assert!(matches!(e, AionError::HashMismatch { .. }));
1980            }
1981        }
1982
1983        #[test]
1984        fn should_reject_too_short_encrypted_data() {
1985            // Arrange: Create data that's too short for nonce
1986            let short_data = [0u8; 8]; // Less than 12 bytes needed for nonce
1987            let file_id = FileId::new(12345);
1988            let version = VersionNumber::GENESIS;
1989            let hash = [0u8; 32];
1990
1991            // Act: Try to decrypt
1992            let result = decrypt_rules(&short_data, file_id, version, hash);
1993
1994            // Assert: Fails with appropriate error
1995            assert!(result.is_err());
1996            if let Err(e) = result {
1997                assert!(matches!(e, AionError::DecryptionFailed { .. }));
1998            }
1999        }
2000
2001        #[test]
2002        fn should_handle_empty_rules_content() {
2003            // Arrange: Encrypt empty rules
2004            let rules = b"";
2005            let file_id = FileId::new(12345);
2006            let version = VersionNumber::GENESIS;
2007
2008            let (encrypted, hash) = encrypt_rules(rules, file_id, version).unwrap();
2009
2010            // Act: Decrypt
2011            let decrypted = decrypt_rules(&encrypted, file_id, version, hash).unwrap();
2012
2013            // Assert: Empty content preserved
2014            assert_eq!(decrypted, rules);
2015            assert!(decrypted.is_empty());
2016        }
2017
2018        #[test]
2019        fn should_handle_large_rules_content() {
2020            // Arrange: Encrypt large rules (1 MB)
2021            let rules = vec![0xAB; 1024 * 1024]; // 1 MB of data
2022            let file_id = FileId::new(12345);
2023            let version = VersionNumber::GENESIS;
2024
2025            let (encrypted, hash) = encrypt_rules(&rules, file_id, version).unwrap();
2026
2027            // Act: Decrypt
2028            let decrypted = decrypt_rules(&encrypted, file_id, version, hash).unwrap();
2029
2030            // Assert: Large content preserved
2031            assert_eq!(decrypted.len(), rules.len());
2032            assert_eq!(decrypted, rules);
2033        }
2034
2035        #[test]
2036        fn should_derive_different_keys_for_different_versions() {
2037            // This test verifies key derivation produces different keys for different versions
2038            let rules = b"same rules, different versions";
2039            let file_id = FileId::new(12345);
2040
2041            let (encrypted_v1, hash1) = encrypt_rules(rules, file_id, VersionNumber(1)).unwrap();
2042            let (encrypted_v2, hash2) = encrypt_rules(rules, file_id, VersionNumber(2)).unwrap();
2043
2044            // Hashes should be the same (same plaintext)
2045            assert_eq!(hash1, hash2);
2046
2047            // But ciphertext should be different (different keys due to version in derivation)
2048            // Note: Even with same key, different nonces would make them different
2049            // But we verify by trying to decrypt with wrong version
2050            let result = decrypt_rules(&encrypted_v1, file_id, VersionNumber(2), hash1);
2051            assert!(result.is_err(), "Should not decrypt v1 data with v2 key");
2052
2053            // Verify v2 also decrypts correctly with its own version
2054            let decrypted_v2 =
2055                decrypt_rules(&encrypted_v2, file_id, VersionNumber(2), hash2).unwrap();
2056            assert_eq!(decrypted_v2, rules);
2057        }
2058    }
2059
2060    mod verification_tests {
2061        use super::*;
2062
2063        #[test]
2064        fn should_reject_tampered_signature() {
2065            let temp_dir = TempDir::new().unwrap();
2066            let file_path = temp_dir.path().join("test.aion");
2067
2068            // Create initial file
2069            let signing_key = SigningKey::generate();
2070            let author_id = AuthorId::new(50001);
2071            let mut initial_bytes = create_test_file(&signing_key, author_id);
2072
2073            // Tamper with signature (signature is in the signatures section)
2074            // Find signature section and modify a byte
2075            let parser = AionParser::new(&initial_bytes).unwrap();
2076            let sig_offset = parser.header().signatures_offset as usize;
2077            if sig_offset + 50 < initial_bytes.len() {
2078                initial_bytes[sig_offset + 50] ^= 0x01;
2079            }
2080
2081            std::fs::write(&file_path, &initial_bytes).unwrap();
2082
2083            // Attempt to commit - should fail verification
2084            let options = CommitOptions {
2085                author_id,
2086                signing_key: &signing_key,
2087                message: "Should fail",
2088                timestamp: Some(1700000001_000_000_000),
2089            };
2090
2091            let result = commit_version(
2092                &file_path,
2093                b"new rules",
2094                &options,
2095                &test_reg(author_id, &signing_key),
2096            );
2097            assert!(result.is_err());
2098        }
2099
2100        /// Follow-up to issue #40 — same silent-zero pattern existed
2101        /// in `get_version_entry` (52 reserved bytes) and
2102        /// `get_signature_entry` (8 reserved bytes). Pre-fix, an
2103        /// attacker could flip reserved bytes; `verify_file` would
2104        /// flag the integrity-hash mismatch, but the next
2105        /// `commit_version` would silently launder the tamper because
2106        /// `get_version_entry` zeroed reserved on read. Post-fix,
2107        /// the parser rejects non-zero reserved at parse time and
2108        /// `commit_version` propagates the Err.
2109        #[test]
2110        fn should_reject_tampered_version_entry_reserved_bytes() {
2111            let temp_dir = TempDir::new().unwrap();
2112            let file_path = temp_dir.path().join("ver_tamper.aion");
2113            let signing_key = SigningKey::generate();
2114            let author_id = AuthorId::new(50_011);
2115            let mut bytes = create_test_file(&signing_key, author_id);
2116
2117            let parser = AionParser::new(&bytes).unwrap();
2118            // Flip a byte squarely inside VersionEntry.reserved
2119            // (entry-relative offset 100..152 → pick 130).
2120            let off = parser.header().version_chain_offset as usize + 130;
2121            bytes[off] ^= 0x55;
2122            std::fs::write(&file_path, &bytes).unwrap();
2123
2124            // Direct parser-level check.
2125            let parser2 = AionParser::new(&bytes).unwrap();
2126            assert!(
2127                parser2.get_version_entry(0).is_err(),
2128                "VersionEntry with non-zero reserved must be rejected at parse"
2129            );
2130
2131            // Laundering path is also closed: commit_version fails
2132            // because rebuild reads every existing version.
2133            let options = CommitOptions {
2134                author_id,
2135                signing_key: &signing_key,
2136                message: "should fail",
2137                timestamp: None,
2138            };
2139            let result = commit_version(
2140                &file_path,
2141                b"new",
2142                &options,
2143                &test_reg(author_id, &signing_key),
2144            );
2145            assert!(
2146                result.is_err(),
2147                "commit_version must reject tampered reserved bytes (laundering closed)"
2148            );
2149        }
2150
2151        #[test]
2152        fn should_reject_tampered_signature_entry_reserved_bytes() {
2153            let temp_dir = TempDir::new().unwrap();
2154            let file_path = temp_dir.path().join("sig_tamper.aion");
2155            let signing_key = SigningKey::generate();
2156            let author_id = AuthorId::new(50_012);
2157            let mut bytes = create_test_file(&signing_key, author_id);
2158
2159            let parser = AionParser::new(&bytes).unwrap();
2160            // SignatureEntry.reserved is bytes 104..112 of each
2161            // 112-byte entry; pick offset 108 inside the first
2162            // signature.
2163            let off = parser.header().signatures_offset as usize + 108;
2164            bytes[off] ^= 0x55;
2165            std::fs::write(&file_path, &bytes).unwrap();
2166
2167            let parser2 = AionParser::new(&bytes).unwrap();
2168            assert!(
2169                parser2.get_signature_entry(0).is_err(),
2170                "SignatureEntry with non-zero reserved must be rejected at parse"
2171            );
2172
2173            let options = CommitOptions {
2174                author_id,
2175                signing_key: &signing_key,
2176                message: "should fail",
2177                timestamp: None,
2178            };
2179            let result = commit_version(
2180                &file_path,
2181                b"new",
2182                &options,
2183                &test_reg(author_id, &signing_key),
2184            );
2185            assert!(
2186                result.is_err(),
2187                "commit_version must reject tampered SignatureEntry reserved"
2188            );
2189        }
2190    }
2191
2192    mod file_verification_tests {
2193        use super::*;
2194
2195        #[test]
2196        fn should_verify_valid_file() {
2197            let temp_dir = TempDir::new().unwrap();
2198            let file_path = temp_dir.path().join("test.aion");
2199
2200            // Create a valid file
2201            let signing_key = SigningKey::generate();
2202            let author_id = AuthorId::new(50001);
2203            let file_bytes = create_test_file(&signing_key, author_id);
2204            std::fs::write(&file_path, &file_bytes).unwrap();
2205
2206            // Verify the file
2207            let report = verify_file(&file_path, &test_reg(author_id, &signing_key)).unwrap();
2208
2209            assert!(report.is_valid);
2210            assert!(report.structure_valid);
2211            assert!(report.integrity_hash_valid);
2212            assert!(report.hash_chain_valid);
2213            assert!(report.signatures_valid);
2214            assert_eq!(report.version_count, 1);
2215            assert!(report.errors.is_empty());
2216        }
2217
2218        #[test]
2219        fn should_verify_multi_version_file() {
2220            let temp_dir = TempDir::new().unwrap();
2221            let file_path = temp_dir.path().join("test.aion");
2222
2223            // Create initial file
2224            let signing_key = SigningKey::generate();
2225            let author_id = AuthorId::new(50001);
2226            let initial_bytes = create_test_file(&signing_key, author_id);
2227            std::fs::write(&file_path, &initial_bytes).unwrap();
2228
2229            // Add more versions
2230            let options = CommitOptions {
2231                author_id,
2232                signing_key: &signing_key,
2233                message: "Version 2",
2234                timestamp: Some(1700000001_000_000_000),
2235            };
2236            commit_version(
2237                &file_path,
2238                b"rules v2",
2239                &options,
2240                &test_reg(author_id, &signing_key),
2241            )
2242            .unwrap();
2243
2244            let options = CommitOptions {
2245                author_id,
2246                signing_key: &signing_key,
2247                message: "Version 3",
2248                timestamp: Some(1700000002_000_000_000),
2249            };
2250            commit_version(
2251                &file_path,
2252                b"rules v3",
2253                &options,
2254                &test_reg(author_id, &signing_key),
2255            )
2256            .unwrap();
2257
2258            // Verify the file
2259            let report = verify_file(&file_path, &test_reg(author_id, &signing_key)).unwrap();
2260
2261            assert!(report.is_valid);
2262            assert_eq!(report.version_count, 3);
2263            assert!(report.errors.is_empty());
2264        }
2265
2266        #[test]
2267        fn should_detect_corrupted_integrity_hash() {
2268            let temp_dir = TempDir::new().unwrap();
2269            let file_path = temp_dir.path().join("test.aion");
2270
2271            // Create a valid file
2272            let signing_key = SigningKey::generate();
2273            let author_id = AuthorId::new(50001);
2274            let mut file_bytes = create_test_file(&signing_key, author_id);
2275
2276            // Corrupt the integrity hash (last 32 bytes)
2277            let len = file_bytes.len();
2278            if len > 32 {
2279                file_bytes[len - 10] ^= 0xFF;
2280            }
2281
2282            std::fs::write(&file_path, &file_bytes).unwrap();
2283
2284            // Verify the file
2285            let report = verify_file(&file_path, &test_reg(author_id, &signing_key)).unwrap();
2286
2287            assert!(!report.is_valid);
2288            assert!(report.structure_valid);
2289            assert!(!report.integrity_hash_valid);
2290            assert!(!report.errors.is_empty());
2291        }
2292
2293        #[test]
2294        fn should_detect_broken_hash_chain() {
2295            let temp_dir = TempDir::new().unwrap();
2296            let file_path = temp_dir.path().join("test.aion");
2297
2298            // Create initial file
2299            let signing_key = SigningKey::generate();
2300            let author_id = AuthorId::new(50001);
2301            let initial_bytes = create_test_file(&signing_key, author_id);
2302            std::fs::write(&file_path, &initial_bytes).unwrap();
2303
2304            // Add a version
2305            let options = CommitOptions {
2306                author_id,
2307                signing_key: &signing_key,
2308                message: "Version 2",
2309                timestamp: Some(1700000001_000_000_000),
2310            };
2311            commit_version(
2312                &file_path,
2313                b"rules v2",
2314                &options,
2315                &test_reg(author_id, &signing_key),
2316            )
2317            .unwrap();
2318
2319            // Corrupt the version chain (change second version number to 99)
2320            let mut file_bytes = std::fs::read(&file_path).unwrap();
2321            let version_offset = {
2322                let parser = AionParser::new(&file_bytes).unwrap();
2323                parser.header().version_chain_offset as usize
2324            };
2325
2326            // Tamper with second version's version number (first 8 bytes of second entry)
2327            // This breaks version monotonicity
2328            let version_entry_size = 108; // Size of VersionEntry
2329            if version_offset + version_entry_size + 7 < file_bytes.len() {
2330                file_bytes[version_offset + version_entry_size] = 99; // Change version to 99
2331            }
2332
2333            std::fs::write(&file_path, &file_bytes).unwrap();
2334
2335            // Verify the file
2336            let report = verify_file(&file_path, &test_reg(author_id, &signing_key)).unwrap();
2337
2338            // Tampering should cause overall validation failure
2339            assert!(!report.is_valid);
2340            assert!(!report.errors.is_empty());
2341        }
2342
2343        #[test]
2344        fn should_detect_invalid_signature() {
2345            let temp_dir = TempDir::new().unwrap();
2346            let file_path = temp_dir.path().join("test.aion");
2347
2348            // Create a valid file
2349            let signing_key = SigningKey::generate();
2350            let author_id = AuthorId::new(50001);
2351            let mut file_bytes = create_test_file(&signing_key, author_id);
2352
2353            // Corrupt a signature
2354            let parser = AionParser::new(&file_bytes).unwrap();
2355            let sig_offset = parser.header().signatures_offset as usize;
2356            if sig_offset + 50 < file_bytes.len() {
2357                file_bytes[sig_offset + 50] ^= 0x01;
2358            }
2359
2360            std::fs::write(&file_path, &file_bytes).unwrap();
2361
2362            // Verify the file
2363            let report = verify_file(&file_path, &test_reg(author_id, &signing_key)).unwrap();
2364
2365            assert!(!report.is_valid);
2366            assert!(report.structure_valid);
2367            assert!(!report.signatures_valid);
2368            assert!(!report.errors.is_empty());
2369        }
2370
2371        #[test]
2372        fn should_handle_malformed_file() {
2373            let temp_dir = TempDir::new().unwrap();
2374            let file_path = temp_dir.path().join("test.aion");
2375
2376            // Write invalid data
2377            std::fs::write(&file_path, b"not a valid aion file").unwrap();
2378
2379            // Verify the file
2380            let result = verify_file(&file_path, &KeyRegistry::new());
2381
2382            // Should fail to parse
2383            assert!(result.is_err());
2384        }
2385
2386        #[test]
2387        fn should_handle_nonexistent_file() {
2388            let temp_dir = TempDir::new().unwrap();
2389            let file_path = temp_dir.path().join("nonexistent.aion");
2390
2391            // Verify the file
2392            let result = verify_file(&file_path, &KeyRegistry::new());
2393
2394            // Should fail with file read error
2395            assert!(result.is_err());
2396        }
2397
2398        #[test]
2399        fn should_report_all_errors() {
2400            let temp_dir = TempDir::new().unwrap();
2401            let file_path = temp_dir.path().join("test.aion");
2402
2403            // Create initial file
2404            let signing_key = SigningKey::generate();
2405            let author_id = AuthorId::new(50001);
2406            let initial_bytes = create_test_file(&signing_key, author_id);
2407            std::fs::write(&file_path, &initial_bytes).unwrap();
2408
2409            // Add a version
2410            let options = CommitOptions {
2411                author_id,
2412                signing_key: &signing_key,
2413                message: "Version 2",
2414                timestamp: Some(1700000001_000_000_000),
2415            };
2416            commit_version(
2417                &file_path,
2418                b"rules v2",
2419                &options,
2420                &test_reg(author_id, &signing_key),
2421            )
2422            .unwrap();
2423
2424            // Corrupt multiple things: hash, chain, and signature
2425            let mut file_bytes = std::fs::read(&file_path).unwrap();
2426
2427            // Read offsets before mutating
2428            let (version_offset, sig_offset) = {
2429                let parser = AionParser::new(&file_bytes).unwrap();
2430                let header = parser.header();
2431                (
2432                    header.version_chain_offset as usize,
2433                    header.signatures_offset as usize,
2434                )
2435            };
2436
2437            // Corrupt integrity hash
2438            let len = file_bytes.len();
2439            if len > 32 {
2440                file_bytes[len - 10] ^= 0xFF;
2441            }
2442
2443            // Corrupt version chain (change second version number)
2444            let version_entry_size = 108;
2445            if version_offset + version_entry_size + 7 < file_bytes.len() {
2446                file_bytes[version_offset + version_entry_size] = 99; // Change version to 99
2447            }
2448
2449            // Corrupt signature
2450            if sig_offset + 50 < file_bytes.len() {
2451                file_bytes[sig_offset + 50] ^= 0x01;
2452            }
2453
2454            std::fs::write(&file_path, &file_bytes).unwrap();
2455
2456            // Verify the file
2457            let report = verify_file(&file_path, &test_reg(author_id, &signing_key)).unwrap();
2458
2459            // Multiple tampering should cause validation failure
2460            assert!(!report.is_valid);
2461            assert!(report.structure_valid); // Structure is still parseable
2462            assert!(!report.integrity_hash_valid);
2463
2464            // Should report multiple errors (at least integrity hash failed)
2465            assert!(!report.errors.is_empty());
2466        }
2467
2468        #[test]
2469        fn should_verify_empty_errors_on_valid_file() {
2470            let temp_dir = TempDir::new().unwrap();
2471            let file_path = temp_dir.path().join("test.aion");
2472
2473            // Create a valid file
2474            let signing_key = SigningKey::generate();
2475            let author_id = AuthorId::new(50001);
2476            let file_bytes = create_test_file(&signing_key, author_id);
2477            std::fs::write(&file_path, &file_bytes).unwrap();
2478
2479            // Verify the file
2480            let report = verify_file(&file_path, &test_reg(author_id, &signing_key)).unwrap();
2481
2482            // Valid file should have no errors
2483            assert!(report.errors.is_empty());
2484            assert!(report.is_valid);
2485        }
2486    }
2487
2488    mod file_inspection_tests {
2489        use super::*;
2490
2491        #[test]
2492        fn should_show_current_rules() {
2493            let temp_dir = TempDir::new().unwrap();
2494            let file_path = temp_dir.path().join("test.aion");
2495
2496            // Create a test file
2497            let signing_key = SigningKey::generate();
2498            let author_id = AuthorId::new(50001);
2499            let file_bytes = create_test_file(&signing_key, author_id);
2500            std::fs::write(&file_path, &file_bytes).unwrap();
2501
2502            // Show current rules
2503            let rules = show_current_rules(&file_path).unwrap();
2504
2505            // Should get the test rules back
2506            assert_eq!(rules, b"initial rules content");
2507        }
2508
2509        #[test]
2510        fn should_show_version_history_single_version() {
2511            let temp_dir = TempDir::new().unwrap();
2512            let file_path = temp_dir.path().join("test.aion");
2513
2514            // Create a test file
2515            let signing_key = SigningKey::generate();
2516            let author_id = AuthorId::new(50001);
2517            let file_bytes = create_test_file(&signing_key, author_id);
2518            std::fs::write(&file_path, &file_bytes).unwrap();
2519
2520            // Show version history
2521            let versions = show_version_history(&file_path).unwrap();
2522
2523            assert_eq!(versions.len(), 1);
2524            assert_eq!(versions[0].version_number, 1);
2525            assert_eq!(versions[0].author_id, author_id.as_u64());
2526            assert_eq!(versions[0].message, "Genesis version");
2527            assert!(versions[0].parent_hash.is_none()); // Genesis has no parent
2528        }
2529
2530        #[test]
2531        fn should_show_version_history_multiple_versions() {
2532            let temp_dir = TempDir::new().unwrap();
2533            let file_path = temp_dir.path().join("test.aion");
2534
2535            // Create initial file
2536            let signing_key = SigningKey::generate();
2537            let author_id = AuthorId::new(50001);
2538            let initial_bytes = create_test_file(&signing_key, author_id);
2539            std::fs::write(&file_path, &initial_bytes).unwrap();
2540
2541            // Add more versions
2542            let options = CommitOptions {
2543                author_id,
2544                signing_key: &signing_key,
2545                message: "Version 2",
2546                timestamp: Some(1700000001_000_000_000),
2547            };
2548            commit_version(
2549                &file_path,
2550                b"rules v2",
2551                &options,
2552                &test_reg(author_id, &signing_key),
2553            )
2554            .unwrap();
2555
2556            let options = CommitOptions {
2557                author_id,
2558                signing_key: &signing_key,
2559                message: "Version 3",
2560                timestamp: Some(1700000002_000_000_000),
2561            };
2562            commit_version(
2563                &file_path,
2564                b"rules v3",
2565                &options,
2566                &test_reg(author_id, &signing_key),
2567            )
2568            .unwrap();
2569
2570            // Show version history
2571            let versions = show_version_history(&file_path).unwrap();
2572
2573            assert_eq!(versions.len(), 3);
2574            assert_eq!(versions[0].version_number, 1);
2575            assert_eq!(versions[0].message, "Genesis version");
2576            assert!(versions[0].parent_hash.is_none());
2577
2578            assert_eq!(versions[1].version_number, 2);
2579            assert_eq!(versions[1].message, "Version 2");
2580            assert!(versions[1].parent_hash.is_some());
2581
2582            assert_eq!(versions[2].version_number, 3);
2583            assert_eq!(versions[2].message, "Version 3");
2584            assert!(versions[2].parent_hash.is_some());
2585        }
2586
2587        #[test]
2588        fn should_show_signatures_with_verification() {
2589            let temp_dir = TempDir::new().unwrap();
2590            let file_path = temp_dir.path().join("test.aion");
2591
2592            // Create a test file
2593            let signing_key = SigningKey::generate();
2594            let author_id = AuthorId::new(50001);
2595            let file_bytes = create_test_file(&signing_key, author_id);
2596            std::fs::write(&file_path, &file_bytes).unwrap();
2597
2598            // Show signatures
2599            let signatures =
2600                show_signatures(&file_path, &test_reg(author_id, &signing_key)).unwrap();
2601
2602            assert_eq!(signatures.len(), 1);
2603            assert_eq!(signatures[0].version_number, 1);
2604            assert_eq!(signatures[0].author_id, author_id.as_u64());
2605            assert!(signatures[0].verified);
2606            assert!(signatures[0].error.is_none());
2607        }
2608
2609        #[test]
2610        fn should_show_signatures_with_multiple_versions() {
2611            let temp_dir = TempDir::new().unwrap();
2612            let file_path = temp_dir.path().join("test.aion");
2613
2614            // Create initial file
2615            let signing_key = SigningKey::generate();
2616            let author_id = AuthorId::new(50001);
2617            let initial_bytes = create_test_file(&signing_key, author_id);
2618            std::fs::write(&file_path, &initial_bytes).unwrap();
2619
2620            // Add another version
2621            let options = CommitOptions {
2622                author_id,
2623                signing_key: &signing_key,
2624                message: "Version 2",
2625                timestamp: Some(1700000001_000_000_000),
2626            };
2627            commit_version(
2628                &file_path,
2629                b"rules v2",
2630                &options,
2631                &test_reg(author_id, &signing_key),
2632            )
2633            .unwrap();
2634
2635            // Show signatures
2636            let signatures =
2637                show_signatures(&file_path, &test_reg(author_id, &signing_key)).unwrap();
2638
2639            assert_eq!(signatures.len(), 2);
2640            assert!(signatures[0].verified);
2641            assert!(signatures[1].verified);
2642            assert!(signatures[0].error.is_none());
2643            assert!(signatures[1].error.is_none());
2644        }
2645
2646        #[test]
2647        fn should_detect_invalid_signature_in_show() {
2648            let temp_dir = TempDir::new().unwrap();
2649            let file_path = temp_dir.path().join("test.aion");
2650
2651            // Create a valid file
2652            let signing_key = SigningKey::generate();
2653            let author_id = AuthorId::new(50001);
2654            let mut file_bytes = create_test_file(&signing_key, author_id);
2655
2656            // Corrupt a signature
2657            let parser = AionParser::new(&file_bytes).unwrap();
2658            let sig_offset = parser.header().signatures_offset as usize;
2659            if sig_offset + 50 < file_bytes.len() {
2660                file_bytes[sig_offset + 50] ^= 0x01;
2661            }
2662
2663            std::fs::write(&file_path, &file_bytes).unwrap();
2664
2665            // Show signatures
2666            let signatures =
2667                show_signatures(&file_path, &test_reg(author_id, &signing_key)).unwrap();
2668
2669            assert_eq!(signatures.len(), 1);
2670            assert!(!signatures[0].verified);
2671            assert!(signatures[0].error.is_some());
2672        }
2673
2674        #[test]
2675        fn should_show_complete_file_info() {
2676            let temp_dir = TempDir::new().unwrap();
2677            let file_path = temp_dir.path().join("test.aion");
2678
2679            // Create initial file
2680            let signing_key = SigningKey::generate();
2681            let author_id = AuthorId::new(50001);
2682            let initial_bytes = create_test_file(&signing_key, author_id);
2683            std::fs::write(&file_path, &initial_bytes).unwrap();
2684
2685            // Add another version
2686            let options = CommitOptions {
2687                author_id,
2688                signing_key: &signing_key,
2689                message: "Version 2",
2690                timestamp: Some(1700000001_000_000_000),
2691            };
2692            commit_version(
2693                &file_path,
2694                b"rules v2",
2695                &options,
2696                &test_reg(author_id, &signing_key),
2697            )
2698            .unwrap();
2699
2700            // Show file info
2701            let info = show_file_info(&file_path, &test_reg(author_id, &signing_key)).unwrap();
2702
2703            assert_eq!(info.version_count, 2);
2704            assert_eq!(info.current_version, 2);
2705            assert_eq!(info.versions.len(), 2);
2706            assert_eq!(info.signatures.len(), 2);
2707
2708            // All signatures should be verified
2709            for sig in &info.signatures {
2710                assert!(sig.verified);
2711            }
2712        }
2713
2714        #[test]
2715        fn should_show_current_rules_for_latest_version() {
2716            let temp_dir = TempDir::new().unwrap();
2717            let file_path = temp_dir.path().join("test.aion");
2718
2719            // Create initial file
2720            let signing_key = SigningKey::generate();
2721            let author_id = AuthorId::new(50001);
2722            let initial_bytes = create_test_file(&signing_key, author_id);
2723            std::fs::write(&file_path, &initial_bytes).unwrap();
2724
2725            // Add new version with different rules
2726            let options = CommitOptions {
2727                author_id,
2728                signing_key: &signing_key,
2729                message: "Updated rules",
2730                timestamp: Some(1700000001_000_000_000),
2731            };
2732            let new_rules = b"these are the updated rules";
2733            commit_version(
2734                &file_path,
2735                new_rules,
2736                &options,
2737                &test_reg(author_id, &signing_key),
2738            )
2739            .unwrap();
2740
2741            // Show current rules should return the latest
2742            let rules = show_current_rules(&file_path).unwrap();
2743            assert_eq!(rules, new_rules);
2744        }
2745
2746        #[test]
2747        fn should_handle_empty_file() {
2748            let temp_dir = TempDir::new().unwrap();
2749            let file_path = temp_dir.path().join("test.aion");
2750
2751            // Write invalid/empty file
2752            std::fs::write(&file_path, b"").unwrap();
2753
2754            // All operations should fail gracefully
2755            assert!(show_current_rules(&file_path).is_err());
2756            assert!(show_version_history(&file_path).is_err());
2757            assert!(show_signatures(&file_path, &KeyRegistry::new()).is_err());
2758            assert!(show_file_info(&file_path, &KeyRegistry::new()).is_err());
2759        }
2760
2761        #[test]
2762        fn should_handle_nonexistent_file() {
2763            let temp_dir = TempDir::new().unwrap();
2764            let file_path = temp_dir.path().join("nonexistent.aion");
2765
2766            // All operations should fail with file read error
2767            assert!(show_current_rules(&file_path).is_err());
2768            assert!(show_version_history(&file_path).is_err());
2769            assert!(show_signatures(&file_path, &KeyRegistry::new()).is_err());
2770            assert!(show_file_info(&file_path, &KeyRegistry::new()).is_err());
2771        }
2772    }
2773
2774    mod init_file_tests {
2775        use super::*;
2776
2777        #[test]
2778        fn should_create_new_file_successfully() {
2779            let temp_dir = TempDir::new().unwrap();
2780            let file_path = temp_dir.path().join("new.aion");
2781
2782            let signing_key = SigningKey::generate();
2783            let author_id = AuthorId::new(50001);
2784            let options = InitOptions {
2785                author_id,
2786                signing_key: &signing_key,
2787                message: "Initial version",
2788                timestamp: Some(1700000000_000_000_000),
2789            };
2790
2791            let rules = b"fraud_threshold: 1000\nrisk_level: medium";
2792            let result = init_file(&file_path, rules, &options).unwrap();
2793
2794            // Check result
2795            assert_eq!(result.version.as_u64(), 1);
2796            assert!(file_path.exists());
2797
2798            // Verify file can be read back
2799            let loaded_rules = show_current_rules(&file_path).unwrap();
2800            assert_eq!(loaded_rules, rules);
2801        }
2802
2803        #[test]
2804        fn should_create_file_with_correct_structure() {
2805            let temp_dir = TempDir::new().unwrap();
2806            let file_path = temp_dir.path().join("structured.aion");
2807
2808            let signing_key = SigningKey::generate();
2809            let author_id = AuthorId::new(50001);
2810            let options = InitOptions {
2811                author_id,
2812                signing_key: &signing_key,
2813                message: "Genesis",
2814                timestamp: Some(1700000000_000_000_000),
2815            };
2816
2817            let rules = b"test rules";
2818            init_file(&file_path, rules, &options).unwrap();
2819
2820            // Verify file structure
2821            let info = show_file_info(&file_path, &test_reg(author_id, &signing_key)).unwrap();
2822            assert_eq!(info.version_count, 1);
2823            assert_eq!(info.current_version, 1);
2824            assert_eq!(info.versions.len(), 1);
2825            assert_eq!(info.signatures.len(), 1);
2826
2827            // Check version details
2828            assert_eq!(info.versions[0].version_number, 1);
2829            assert_eq!(info.versions[0].author_id, author_id.as_u64());
2830            assert_eq!(info.versions[0].message, "Genesis");
2831            assert!(info.versions[0].parent_hash.is_none());
2832
2833            // Check signature
2834            assert!(info.signatures[0].verified);
2835            assert_eq!(info.signatures[0].author_id, author_id.as_u64());
2836        }
2837
2838        #[test]
2839        fn should_fail_if_file_already_exists() {
2840            let temp_dir = TempDir::new().unwrap();
2841            let file_path = temp_dir.path().join("exists.aion");
2842
2843            // Create file first
2844            let signing_key = SigningKey::generate();
2845            let author_id = AuthorId::new(50001);
2846            let options = InitOptions {
2847                author_id,
2848                signing_key: &signing_key,
2849                message: "Initial version",
2850                timestamp: Some(1700000000_000_000_000),
2851            };
2852
2853            init_file(&file_path, b"rules", &options).unwrap();
2854
2855            // Try to create again - should fail
2856            let result = init_file(&file_path, b"new rules", &options);
2857            assert!(result.is_err());
2858            assert!(matches!(result.unwrap_err(), AionError::FileExists { .. }));
2859        }
2860
2861        #[test]
2862        fn should_generate_unique_file_ids() {
2863            let temp_dir = TempDir::new().unwrap();
2864            let path1 = temp_dir.path().join("file1.aion");
2865            let path2 = temp_dir.path().join("file2.aion");
2866
2867            let signing_key = SigningKey::generate();
2868            let author_id = AuthorId::new(50001);
2869            let options = InitOptions {
2870                author_id,
2871                signing_key: &signing_key,
2872                message: "Initial",
2873                timestamp: Some(1700000000_000_000_000),
2874            };
2875
2876            let result1 = init_file(&path1, b"rules1", &options).unwrap();
2877            let result2 = init_file(&path2, b"rules2", &options).unwrap();
2878
2879            // File IDs should be different
2880            assert_ne!(result1.file_id.as_u64(), result2.file_id.as_u64());
2881        }
2882
2883        #[test]
2884        fn should_encrypt_rules_content() {
2885            let temp_dir = TempDir::new().unwrap();
2886            let file_path = temp_dir.path().join("encrypted.aion");
2887
2888            let signing_key = SigningKey::generate();
2889            let author_id = AuthorId::new(50001);
2890            let options = InitOptions {
2891                author_id,
2892                signing_key: &signing_key,
2893                message: "Initial",
2894                timestamp: Some(1700000000_000_000_000),
2895            };
2896
2897            let secret_rules = b"secret: fraud_detection_threshold_is_5000";
2898            init_file(&file_path, secret_rules, &options).unwrap();
2899
2900            // Read raw file bytes - should not contain plaintext
2901            let file_bytes = std::fs::read(&file_path).unwrap();
2902            let file_string = String::from_utf8_lossy(&file_bytes);
2903            assert!(!file_string.contains("secret"));
2904            assert!(!file_string.contains("fraud_detection_threshold"));
2905
2906            // But decryption should work
2907            let decrypted = show_current_rules(&file_path).unwrap();
2908            assert_eq!(decrypted, secret_rules);
2909        }
2910
2911        #[test]
2912        fn should_create_valid_signature() {
2913            let temp_dir = TempDir::new().unwrap();
2914            let file_path = temp_dir.path().join("signed.aion");
2915
2916            let signing_key = SigningKey::generate();
2917            let author_id = AuthorId::new(50001);
2918            let options = InitOptions {
2919                author_id,
2920                signing_key: &signing_key,
2921                message: "Initial",
2922                timestamp: Some(1700000000_000_000_000),
2923            };
2924
2925            init_file(&file_path, b"rules", &options).unwrap();
2926
2927            // Verify signature is valid
2928            let report = verify_file(&file_path, &test_reg(author_id, &signing_key)).unwrap();
2929            assert!(report.is_valid);
2930            assert!(report.signatures_valid);
2931            assert!(report.errors.is_empty());
2932        }
2933
2934        #[test]
2935        fn should_use_current_timestamp_when_none_provided() {
2936            let temp_dir = TempDir::new().unwrap();
2937            let file_path = temp_dir.path().join("timestamped.aion");
2938
2939            let signing_key = SigningKey::generate();
2940            let author_id = AuthorId::new(50001);
2941            let options = InitOptions {
2942                author_id,
2943                signing_key: &signing_key,
2944                message: "Initial",
2945                timestamp: None, // Should use current time
2946            };
2947
2948            let before = current_timestamp_nanos();
2949            init_file(&file_path, b"rules", &options).unwrap();
2950            let after = current_timestamp_nanos();
2951
2952            // Check timestamp is reasonable
2953            let versions = show_version_history(&file_path).unwrap();
2954            assert_eq!(versions.len(), 1);
2955            assert!(versions[0].timestamp >= before);
2956            assert!(versions[0].timestamp <= after);
2957        }
2958
2959        #[test]
2960        fn should_handle_empty_rules() {
2961            let temp_dir = TempDir::new().unwrap();
2962            let file_path = temp_dir.path().join("empty.aion");
2963
2964            let signing_key = SigningKey::generate();
2965            let author_id = AuthorId::new(50001);
2966            let options = InitOptions {
2967                author_id,
2968                signing_key: &signing_key,
2969                message: "Empty genesis",
2970                timestamp: Some(1700000000_000_000_000),
2971            };
2972
2973            let result = init_file(&file_path, b"", &options).unwrap();
2974            assert_eq!(result.version.as_u64(), 1);
2975
2976            // Should be able to read back empty rules
2977            let rules = show_current_rules(&file_path).unwrap();
2978            assert_eq!(rules, b"");
2979        }
2980
2981        #[test]
2982        fn should_handle_large_rules() {
2983            let temp_dir = TempDir::new().unwrap();
2984            let file_path = temp_dir.path().join("large.aion");
2985
2986            let signing_key = SigningKey::generate();
2987            let author_id = AuthorId::new(50001);
2988            let options = InitOptions {
2989                author_id,
2990                signing_key: &signing_key,
2991                message: "Large ruleset",
2992                timestamp: Some(1700000000_000_000_000),
2993            };
2994
2995            // Create large rules (1MB)
2996            let large_rules = vec![b'X'; 1024 * 1024];
2997            init_file(&file_path, &large_rules, &options).unwrap();
2998
2999            // Verify
3000            let decrypted = show_current_rules(&file_path).unwrap();
3001            assert_eq!(decrypted.len(), large_rules.len());
3002            assert_eq!(decrypted, large_rules);
3003        }
3004
3005        #[test]
3006        fn should_handle_long_commit_messages() {
3007            let temp_dir = TempDir::new().unwrap();
3008            let file_path = temp_dir.path().join("longmsg.aion");
3009
3010            let signing_key = SigningKey::generate();
3011            let author_id = AuthorId::new(50001);
3012            let long_message = "A".repeat(1000);
3013            let options = InitOptions {
3014                author_id,
3015                signing_key: &signing_key,
3016                message: &long_message,
3017                timestamp: Some(1700000000_000_000_000),
3018            };
3019
3020            init_file(&file_path, b"rules", &options).unwrap();
3021
3022            // Check message is preserved
3023            let versions = show_version_history(&file_path).unwrap();
3024            assert_eq!(versions[0].message, long_message);
3025        }
3026    }
3027
3028    mod exit_code_tests {
3029        use super::*;
3030
3031        fn report_with(is_valid: bool) -> VerificationReport {
3032            let mut r = VerificationReport::new(FileId::new(1), 1);
3033            r.is_valid = is_valid;
3034            r
3035        }
3036
3037        #[test]
3038        fn valid_report_maps_to_success() {
3039            assert_eq!(
3040                report_with(true).exit_code(),
3041                std::process::ExitCode::SUCCESS
3042            );
3043        }
3044
3045        #[test]
3046        fn invalid_report_maps_to_failure() {
3047            // ExitCode is opaque; compare via stable debug repr.
3048            let invalid = format!("{:?}", report_with(false).exit_code());
3049            let failure = format!("{:?}", std::process::ExitCode::FAILURE);
3050            assert_eq!(invalid, failure);
3051        }
3052
3053        mod properties {
3054            use super::*;
3055            use hegel::generators as gs;
3056
3057            #[hegel::test]
3058            fn prop_exit_code_reflects_verdict(tc: hegel::TestCase) {
3059                let is_valid = tc.draw(gs::integers::<u8>()) % 2 == 1;
3060                let report = report_with(is_valid);
3061                let observed = format!("{:?}", report.exit_code());
3062                let expected = format!(
3063                    "{:?}",
3064                    if is_valid {
3065                        std::process::ExitCode::SUCCESS
3066                    } else {
3067                        std::process::ExitCode::FAILURE
3068                    }
3069                );
3070                if observed != expected {
3071                    std::process::abort();
3072                }
3073            }
3074        }
3075    }
3076
3077    /// Issue #25 — registry authz pre-check properties. These test
3078    /// `preflight_registry_authz` directly since building a real
3079    /// `.aion` file on every Hegel trial would be expensive; the full
3080    /// end-to-end contract is exercised by the CLI integration tests.
3081    mod registry_precheck_tests {
3082        use super::*;
3083        use crate::crypto::SigningKey;
3084        use crate::key_registry::KeyRegistry;
3085
3086        mod properties {
3087            use super::*;
3088            use hegel::generators as gs;
3089
3090            /// Build a fresh registry that pins exactly one author at
3091            /// epoch 0 with the supplied operational key.
3092            fn single_author_registry(author: AuthorId, op_key: &SigningKey) -> KeyRegistry {
3093                let master = SigningKey::generate();
3094                let mut reg = KeyRegistry::new();
3095                reg.register_author(author, master.verifying_key(), op_key.verifying_key(), 0)
3096                    .unwrap_or_else(|_| std::process::abort());
3097                reg
3098            }
3099
3100            fn options(author: AuthorId, key: &SigningKey) -> CommitOptions<'_> {
3101                CommitOptions {
3102                    author_id: author,
3103                    signing_key: key,
3104                    message: "",
3105                    timestamp: None,
3106                }
3107            }
3108
3109            /// Author not in the registry ⇒ `UnauthorizedSigner`.
3110            #[hegel::test]
3111            fn prop_unknown_author_rejects(tc: hegel::TestCase) {
3112                let pinned_id = tc.draw(gs::integers::<u64>().min_value(1).max_value(1 << 40));
3113                let probe_id = tc.draw(gs::integers::<u64>().min_value(1).max_value(1 << 40));
3114                if pinned_id == probe_id {
3115                    return; // skip the trivial collision
3116                }
3117                let version = tc.draw(gs::integers::<u64>().min_value(1).max_value(1 << 30));
3118
3119                let pinned_key = SigningKey::generate();
3120                let reg = single_author_registry(AuthorId::new(pinned_id), &pinned_key);
3121
3122                // Probe: a different author, any key.
3123                let probe_key = SigningKey::generate();
3124                let opts = options(AuthorId::new(probe_id), &probe_key);
3125
3126                match preflight_registry_authz(&opts, VersionNumber(version), &reg) {
3127                    Err(AionError::UnauthorizedSigner { .. }) => {}
3128                    _ => std::process::abort(),
3129                }
3130            }
3131
3132            /// Author pinned and key matches ⇒ `Ok`.
3133            #[hegel::test]
3134            fn prop_pinned_matching_key_accepts(tc: hegel::TestCase) {
3135                let author_id = tc.draw(gs::integers::<u64>().min_value(1).max_value(1 << 40));
3136                let version = tc.draw(gs::integers::<u64>().min_value(1).max_value(1 << 30));
3137                let author = AuthorId::new(author_id);
3138
3139                let op_key = SigningKey::generate();
3140                let reg = single_author_registry(author, &op_key);
3141                let opts = options(author, &op_key);
3142
3143                if preflight_registry_authz(&opts, VersionNumber(version), &reg).is_err() {
3144                    std::process::abort();
3145                }
3146            }
3147
3148            /// Author pinned but key differs ⇒ `KeyMismatch`.
3149            #[hegel::test]
3150            fn prop_pinned_wrong_key_rejects(tc: hegel::TestCase) {
3151                let author_id = tc.draw(gs::integers::<u64>().min_value(1).max_value(1 << 40));
3152                let version = tc.draw(gs::integers::<u64>().min_value(1).max_value(1 << 30));
3153                let author = AuthorId::new(author_id);
3154
3155                let pinned_key = SigningKey::generate();
3156                let reg = single_author_registry(author, &pinned_key);
3157
3158                let wrong_key = SigningKey::generate();
3159                let opts = options(author, &wrong_key);
3160
3161                match preflight_registry_authz(&opts, VersionNumber(version), &reg) {
3162                    Err(AionError::KeyMismatch { .. }) => {}
3163                    _ => std::process::abort(),
3164                }
3165            }
3166        }
3167    }
3168
3169    /// Issue #35 — `commit_version` must still detect head-signature
3170    /// tampering even though the verify is now O(1) (head only) rather
3171    /// than O(n) (full sweep). Tampering of *non-head* prior signatures
3172    /// is intentionally caught by `verify_file`, not by commit.
3173    mod commit_head_verify_tests {
3174        use super::*;
3175        use crate::parser::SIGNATURE_ENTRY_SIZE;
3176
3177        /// Tamper with a specific signature entry inside an .aion
3178        /// file's signature section by flipping one byte.
3179        #[allow(clippy::arithmetic_side_effects)] // bounded test inputs
3180        fn flip_byte_in_signature_at(bytes: &mut [u8], index: usize) {
3181            let parser = AionParser::new(bytes).unwrap();
3182            let sig_offset = parser.header().signatures_offset as usize;
3183            let target = sig_offset + index * SIGNATURE_ENTRY_SIZE + 50;
3184            assert!(target < bytes.len(), "tamper offset out of bounds");
3185            bytes[target] ^= 0x01;
3186        }
3187
3188        /// Build a chain of v1..v3, all valid. Then tamper the HEAD
3189        /// (v3) signature; `commit_version` of v4 must reject.
3190        ///
3191        /// This is the post-#35 contract: head-only verify still
3192        /// catches tampering of the latest signature, even when
3193        /// every earlier signature is intact.
3194        #[test]
3195        fn commit_rejects_tampered_head_on_multi_version_chain() {
3196            let temp = TempDir::new().unwrap();
3197            let path = temp.path().join("head_tamper.aion");
3198            let signing_key = SigningKey::generate();
3199            let author_id = AuthorId::new(70_001);
3200            let registry = test_reg(author_id, &signing_key);
3201
3202            // Build v1, v2, v3 — all valid.
3203            let init_opts = InitOptions {
3204                author_id,
3205                signing_key: &signing_key,
3206                message: "v1",
3207                timestamp: None,
3208            };
3209            init_file(&path, b"r1", &init_opts).unwrap();
3210            for _ in 2..=3u64 {
3211                let opts = CommitOptions {
3212                    author_id,
3213                    signing_key: &signing_key,
3214                    message: "amend",
3215                    timestamp: None,
3216                };
3217                commit_version(&path, b"r", &opts, &registry).unwrap();
3218            }
3219
3220            // Tamper signature index 2 (== v3, the head).
3221            let mut bytes = std::fs::read(&path).unwrap();
3222            flip_byte_in_signature_at(&mut bytes, 2);
3223            std::fs::write(&path, &bytes).unwrap();
3224
3225            let next_opts = CommitOptions {
3226                author_id,
3227                signing_key: &signing_key,
3228                message: "v4",
3229                timestamp: None,
3230            };
3231            let result = commit_version(&path, b"r4", &next_opts, &registry);
3232            assert!(
3233                result.is_err(),
3234                "commit_version must reject when HEAD signature is tampered"
3235            );
3236        }
3237
3238        /// Audit follow-up to #37: post-fix, `commit_version` runs
3239        /// `verify_integrity()` and `verify_hash_chain()` before
3240        /// every append, so tampering with ANY prior entry — not
3241        /// just the head — is caught at write time. This closes the
3242        /// laundering path that the original #37 narrative
3243        /// documented as intentional.
3244        ///
3245        /// (The function name predates the fix and is preserved for
3246        /// git-blame continuity; the docstring captures the new
3247        /// contract.)
3248        #[test]
3249        fn commit_now_catches_non_head_tamper_at_write_time() {
3250            let temp = TempDir::new().unwrap();
3251            let path = temp.path().join("non_head_tamper.aion");
3252            let signing_key = SigningKey::generate();
3253            let author_id = AuthorId::new(70_002);
3254            let registry = test_reg(author_id, &signing_key);
3255
3256            // v1, v2, v3.
3257            let init_opts = InitOptions {
3258                author_id,
3259                signing_key: &signing_key,
3260                message: "v1",
3261                timestamp: None,
3262            };
3263            init_file(&path, b"r1", &init_opts).unwrap();
3264            for _ in 2..=3u64 {
3265                let opts = CommitOptions {
3266                    author_id,
3267                    signing_key: &signing_key,
3268                    message: "amend",
3269                    timestamp: None,
3270                };
3271                commit_version(&path, b"r", &opts, &registry).unwrap();
3272            }
3273
3274            // Tamper signature index 0 (v1, NOT the head).
3275            let mut bytes = std::fs::read(&path).unwrap();
3276            flip_byte_in_signature_at(&mut bytes, 0);
3277            std::fs::write(&path, &bytes).unwrap();
3278            let tampered = std::fs::read(&path).unwrap();
3279
3280            // commit_version must NOW reject — the integrity hash
3281            // catches the byte flip even though it lives in a
3282            // non-head entry.
3283            let next_opts = CommitOptions {
3284                author_id,
3285                signing_key: &signing_key,
3286                message: "v4",
3287                timestamp: None,
3288            };
3289            let result = commit_version(&path, b"r4", &next_opts, &registry);
3290            assert!(
3291                result.is_err(),
3292                "commit_version must reject non-head tamper at write time"
3293            );
3294
3295            // No bytes written — the refused commit must not mutate.
3296            let post = std::fs::read(&path).unwrap();
3297            assert_eq!(
3298                tampered, post,
3299                "refused commit must not mutate the tampered file"
3300            );
3301
3302            // verify_file also rejects, as before.
3303            let report = verify_file(&path, &registry).unwrap();
3304            assert!(
3305                !report.is_valid,
3306                "verify_file must reject after a non-head signature tamper"
3307            );
3308        }
3309
3310        /// Build cost smoke-check: a 200-version chain post-#35 should
3311        /// take well under a second. Pre-#35, this exact loop was
3312        /// ~636 ms (already noticeable); the head-only verify drops
3313        /// it close to file-I/O cost.
3314        #[test]
3315        fn commit_succeeds_on_clean_chain_of_many_versions() {
3316            let temp = TempDir::new().unwrap();
3317            let path = temp.path().join("many.aion");
3318            let signing_key = SigningKey::generate();
3319            let author_id = AuthorId::new(70_003);
3320            let registry = test_reg(author_id, &signing_key);
3321
3322            let init_opts = InitOptions {
3323                author_id,
3324                signing_key: &signing_key,
3325                message: "v1",
3326                timestamp: None,
3327            };
3328            init_file(&path, b"v1", &init_opts).unwrap();
3329
3330            for _ in 2..=200u64 {
3331                let opts = CommitOptions {
3332                    author_id,
3333                    signing_key: &signing_key,
3334                    message: "amend",
3335                    timestamp: None,
3336                };
3337                commit_version(&path, b"amend", &opts, &registry).unwrap();
3338            }
3339
3340            let report = verify_file(&path, &registry).unwrap();
3341            assert!(report.is_valid, "verify_file must accept the built chain");
3342            assert_eq!(report.version_count, 200);
3343        }
3344    }
3345}