use crate::audit::{ActionCode, AuditEntry};
use crate::crypto::{decrypt, derive_key, encrypt, generate_nonce, hash, SigningKey};
use crate::parser::AionParser;
use crate::serializer::{AionFile, AionSerializer, SignatureEntry, VersionEntry};
#[allow(deprecated)] use crate::signature_chain::{
compute_version_hash, create_genesis_version, sign_version, verify_hash_chain,
verify_signature, verify_signatures_batch,
};
use crate::types::{AuthorId, FileId, VersionNumber};
use crate::{AionError, Result};
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
pub struct InitOptions<'a> {
pub author_id: AuthorId,
pub signing_key: &'a SigningKey,
pub message: &'a str,
pub timestamp: Option<u64>,
}
#[derive(Debug, Clone)]
pub struct InitResult {
pub file_id: FileId,
pub version: VersionNumber,
pub rules_hash: [u8; 32],
}
pub fn init_file(path: &Path, initial_rules: &[u8], options: &InitOptions) -> Result<InitResult> {
if path.exists() {
return Err(AionError::FileExists {
path: path.to_path_buf(),
});
}
let file_id = FileId::random();
let timestamp = options.timestamp.unwrap_or_else(current_timestamp_nanos);
let rules_hash = hash(initial_rules);
let (encrypted_rules, _) = encrypt_rules(initial_rules, file_id, VersionNumber::GENESIS)?;
let aion_file = build_genesis_file(file_id, timestamp, rules_hash, encrypted_rules, options)?;
write_serialized_file(&aion_file, path)?;
tracing::info!(
event = "file_initialized",
file_id = %crate::obs::short_hex(&file_id.as_u64().to_le_bytes()),
author = %crate::obs::author_short(options.author_id),
rules_hash = %crate::obs::short_hex(&rules_hash),
);
Ok(InitResult {
file_id,
version: VersionNumber::GENESIS,
rules_hash,
})
}
#[allow(clippy::cast_possible_truncation)]
fn build_genesis_file(
file_id: FileId,
timestamp: u64,
rules_hash: [u8; 32],
encrypted_rules: Vec<u8>,
options: &InitOptions,
) -> Result<AionFile> {
let (string_table, offsets) = AionSerializer::build_string_table(&[options.message]);
let message_offset = offsets.first().copied().unwrap_or(0);
let genesis_version = create_genesis_version(
rules_hash,
options.author_id,
timestamp,
message_offset,
options.message.len() as u32,
);
let signature = sign_version(&genesis_version, options.signing_key);
let audit_entry = AuditEntry::new(
timestamp,
options.author_id,
ActionCode::CreateGenesis,
0,
0,
[0u8; 32],
);
AionFile::builder()
.file_id(file_id)
.current_version(VersionNumber::GENESIS)
.flags(0x0001)
.root_hash(rules_hash)
.current_hash(rules_hash)
.created_at(timestamp)
.modified_at(timestamp)
.encrypted_rules(encrypted_rules)
.add_version(genesis_version)
.add_signature(signature)
.add_audit_entry(audit_entry)
.string_table(string_table)
.build()
}
fn write_serialized_file(file: &AionFile, path: &Path) -> Result<()> {
let file_bytes = AionSerializer::serialize(file)?;
std::fs::write(path, &file_bytes).map_err(|e| AionError::FileWriteError {
path: path.to_path_buf(),
source: e,
})
}
pub struct CommitOptions<'a> {
pub author_id: AuthorId,
pub signing_key: &'a SigningKey,
pub message: &'a str,
pub timestamp: Option<u64>,
}
#[derive(Debug)]
pub struct CommitResult {
pub version: VersionNumber,
pub version_hash: [u8; 32],
pub rules_hash: [u8; 32],
}
#[must_use = "the CommitResult carries the new version number and rules hash; \
dropping it silently usually indicates a missing post-commit step"]
pub fn commit_version(
path: &Path,
new_rules: &[u8],
options: &CommitOptions<'_>,
registry: &crate::key_registry::KeyRegistry,
) -> Result<CommitResult> {
commit_version_inner(path, new_rules, options, registry, true)
}
#[must_use = "the resulting file will NOT pass `verify` against the \
supplied registry until the registry is updated to pin \
this signer; check the CommitResult and ensure the \
registry update is staged"]
pub fn commit_version_force_unregistered(
path: &Path,
new_rules: &[u8],
options: &CommitOptions<'_>,
registry: &crate::key_registry::KeyRegistry,
) -> Result<CommitResult> {
commit_version_inner(path, new_rules, options, registry, false)
}
fn commit_version_inner(
path: &Path,
new_rules: &[u8],
options: &CommitOptions<'_>,
registry: &crate::key_registry::KeyRegistry,
enforce_registry: bool,
) -> Result<CommitResult> {
let file_bytes = std::fs::read(path).map_err(|e| AionError::FileReadError {
path: path.to_path_buf(),
source: e,
})?;
let parser = AionParser::new(&file_bytes)?;
let header = parser.header();
parser.verify_integrity()?;
let existing_versions = collect_versions(&parser, header.version_chain_count)?;
crate::signature_chain::verify_hash_chain(&existing_versions)?;
verify_head_signature(&parser, registry)?;
let new_version = VersionNumber(header.current_version).next()?;
if enforce_registry {
preflight_registry_authz(options, new_version, registry)?;
}
let timestamp = options.timestamp.unwrap_or_else(current_timestamp_nanos);
let file_id = FileId::new(header.file_id);
let (encrypted_rules, rules_hash) = encrypt_rules(new_rules, file_id, new_version)?;
let parent_hash = compute_version_hash(&get_last_version_entry(&parser)?);
let (string_table, message_offset) = build_string_table_with_message(options.message, &parser)?;
let (new_version_entry, signature_entry) = build_new_version_and_signature(
new_version,
parent_hash,
rules_hash,
timestamp,
message_offset,
options,
);
let updated_file = build_updated_file(
&parser,
header,
new_version,
rules_hash,
encrypted_rules,
new_version_entry,
signature_entry,
string_table,
timestamp,
options.author_id,
)?;
AionSerializer::write_atomic(&updated_file, path)?;
let version_hash = compute_version_hash(&new_version_entry);
tracing::info!(
event = "commit_accepted",
file_id = %crate::obs::short_hex(&header.file_id.to_le_bytes()),
author = %crate::obs::author_short(options.author_id),
version = new_version.as_u64(),
version_hash = %crate::obs::short_hex(&version_hash),
rules_hash = %crate::obs::short_hex(&rules_hash),
);
Ok(CommitResult {
version: new_version,
version_hash,
rules_hash,
})
}
fn preflight_registry_authz(
options: &CommitOptions<'_>,
new_version: VersionNumber,
registry: &crate::key_registry::KeyRegistry,
) -> Result<()> {
use subtle::ConstantTimeEq;
let Some(epoch) = registry.active_epoch_at(options.author_id, new_version.as_u64()) else {
return Err(AionError::UnauthorizedSigner {
author: options.author_id,
version: new_version.as_u64(),
});
};
let supplied_pk = options.signing_key.verifying_key().to_bytes();
if !bool::from(supplied_pk.ct_eq(&epoch.public_key)) {
return Err(AionError::KeyMismatch {
author: options.author_id,
epoch: epoch.epoch,
});
}
Ok(())
}
#[allow(clippy::cast_possible_truncation)]
fn build_new_version_and_signature(
new_version: VersionNumber,
parent_hash: [u8; 32],
rules_hash: [u8; 32],
timestamp: u64,
message_offset: u64,
options: &CommitOptions<'_>,
) -> (VersionEntry, SignatureEntry) {
let new_version_entry = VersionEntry::new(
new_version,
parent_hash,
rules_hash,
options.author_id,
timestamp,
message_offset,
options.message.len() as u32,
);
let signature_entry = sign_version(&new_version_entry, options.signing_key);
(new_version_entry, signature_entry)
}
#[allow(clippy::cast_possible_truncation)] #[allow(clippy::arithmetic_side_effects)] fn verify_head_signature(
parser: &AionParser<'_>,
registry: &crate::key_registry::KeyRegistry,
) -> Result<()> {
let header = parser.header();
let version_count = header.version_chain_count as usize;
let signature_count = header.signatures_count as usize;
if version_count != signature_count {
return Err(AionError::InvalidFormat {
reason: format!(
"Version count ({version_count}) does not match signature count ({signature_count})"
),
});
}
if version_count == 0 {
return Err(AionError::InvalidFormat {
reason: "File has no versions".to_string(),
});
}
let last = version_count - 1;
let version = parser.get_version_entry(last)?;
let signature = parser.get_signature_entry(last)?;
verify_signature(&version, &signature, registry)
}
#[allow(clippy::cast_possible_truncation)] #[allow(clippy::arithmetic_side_effects)] fn get_last_version_entry(parser: &AionParser<'_>) -> Result<VersionEntry> {
let header = parser.header();
let version_count = header.version_chain_count as usize;
if version_count == 0 {
return Err(AionError::InvalidFormat {
reason: "File has no versions".to_string(),
});
}
parser.get_version_entry(version_count - 1)
}
#[allow(clippy::arithmetic_side_effects)] fn encrypt_rules(
rules: &[u8],
file_id: FileId,
version: VersionNumber,
) -> Result<(Vec<u8>, [u8; 32])> {
let rules_hash = hash(rules);
let mut encryption_key = [0u8; 32];
let salt = file_id.as_u64().to_le_bytes();
let info = format!("aion-v2-rules-v{}", version.as_u64());
let master_secret = format!("aion-v2-master-{}", file_id.as_u64());
derive_key(
master_secret.as_bytes(),
&salt,
info.as_bytes(),
&mut encryption_key,
)?;
let nonce = generate_nonce();
let aad = version.as_u64().to_le_bytes();
let ciphertext = encrypt(&encryption_key, &nonce, rules, &aad)?;
let mut encrypted = Vec::with_capacity(12 + ciphertext.len());
encrypted.extend_from_slice(&nonce);
encrypted.extend_from_slice(&ciphertext);
Ok((encrypted, rules_hash))
}
pub fn decrypt_rules(
encrypted_rules: &[u8],
file_id: FileId,
version: VersionNumber,
expected_hash: [u8; 32],
) -> Result<Vec<u8>> {
if encrypted_rules.len() < 12 {
return Err(AionError::DecryptionFailed {
reason: format!(
"Encrypted data too short: {} bytes, need at least 12 for nonce",
encrypted_rules.len()
),
});
}
let mut nonce = [0u8; 12];
let nonce_slice = encrypted_rules
.get(..12)
.ok_or_else(|| AionError::DecryptionFailed {
reason: "Failed to extract nonce from encrypted data".to_string(),
})?;
nonce.copy_from_slice(nonce_slice);
let ciphertext = encrypted_rules
.get(12..)
.ok_or_else(|| AionError::DecryptionFailed {
reason: "Failed to extract ciphertext from encrypted data".to_string(),
})?;
let mut encryption_key = [0u8; 32];
let salt = file_id.as_u64().to_le_bytes();
let info = format!("aion-v2-rules-v{}", version.as_u64());
let master_secret = format!("aion-v2-master-{}", file_id.as_u64());
derive_key(
master_secret.as_bytes(),
&salt,
info.as_bytes(),
&mut encryption_key,
)?;
let aad = version.as_u64().to_le_bytes();
let plaintext = decrypt(&encryption_key, &nonce, ciphertext, &aad)?;
let actual_hash = hash(&plaintext);
if actual_hash != expected_hash {
return Err(AionError::HashMismatch {
expected: expected_hash,
actual: actual_hash,
});
}
Ok(plaintext)
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum TemporalWarning {
NonMonotonicTimestamp {
version: u64,
timestamp: u64,
previous_timestamp: u64,
},
FutureTimestamp {
version: u64,
timestamp: u64,
current_time: u64,
},
ClockSkewDetected {
version: u64,
skew_nanos: i64,
},
}
impl std::fmt::Display for TemporalWarning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NonMonotonicTimestamp {
version,
timestamp,
previous_timestamp,
} => {
let diff_secs = previous_timestamp.saturating_sub(*timestamp) / 1_000_000_000;
write!(
f,
"Version {version} has non-monotonic timestamp ({diff_secs}s before previous version)"
)
}
Self::FutureTimestamp {
version,
timestamp,
current_time,
} => {
let diff_secs = timestamp.saturating_sub(*current_time) / 1_000_000_000;
write!(
f,
"Version {version} has future timestamp ({diff_secs}s in the future)"
)
}
Self::ClockSkewDetected {
version,
skew_nanos,
} => {
let skew_ms = skew_nanos / 1_000_000;
write!(
f,
"Version {version} shows potential clock skew ({skew_ms}ms)"
)
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[allow(clippy::struct_excessive_bools)] pub struct VerificationReport {
pub file_id: FileId,
pub version_count: u64,
pub structure_valid: bool,
pub integrity_hash_valid: bool,
pub hash_chain_valid: bool,
pub signatures_valid: bool,
pub is_valid: bool,
pub errors: Vec<String>,
pub temporal_warnings: Vec<TemporalWarning>,
}
impl VerificationReport {
#[must_use]
pub const fn new(file_id: FileId, version_count: u64) -> Self {
Self {
file_id,
version_count,
structure_valid: false,
integrity_hash_valid: false,
hash_chain_valid: false,
signatures_valid: false,
is_valid: false,
errors: Vec::new(),
temporal_warnings: Vec::new(),
}
}
#[must_use]
pub fn has_temporal_warnings(&self) -> bool {
!self.temporal_warnings.is_empty()
}
#[must_use]
pub const fn exit_code(&self) -> std::process::ExitCode {
if self.is_valid {
std::process::ExitCode::SUCCESS
} else {
std::process::ExitCode::FAILURE
}
}
pub fn mark_valid(&mut self) {
self.structure_valid = true;
self.integrity_hash_valid = true;
self.hash_chain_valid = true;
self.signatures_valid = true;
self.is_valid = true;
}
}
const CLOCK_SKEW_TOLERANCE_NANOS: u64 = 5 * 60 * 1_000_000_000;
const FUTURE_TOLERANCE_NANOS: u64 = 60 * 1_000_000_000;
fn check_temporal_ordering(versions: &[VersionEntry]) -> Vec<TemporalWarning> {
let mut warnings = Vec::new();
if versions.is_empty() {
return warnings;
}
let current_time = current_timestamp_nanos();
for (i, version) in versions.iter().enumerate() {
let version_num = version.version_number;
let timestamp = version.timestamp;
if timestamp > current_time.saturating_add(FUTURE_TOLERANCE_NANOS) {
warnings.push(TemporalWarning::FutureTimestamp {
version: version_num,
timestamp,
current_time,
});
}
if let Some(prev) = i.checked_sub(1).and_then(|j| versions.get(j)) {
let prev_timestamp = prev.timestamp;
if timestamp < prev_timestamp {
let diff = prev_timestamp.saturating_sub(timestamp);
if diff > CLOCK_SKEW_TOLERANCE_NANOS {
warnings.push(TemporalWarning::NonMonotonicTimestamp {
version: version_num,
timestamp,
previous_timestamp: prev_timestamp,
});
} else {
#[allow(clippy::cast_possible_wrap)]
let skew_nanos = (diff as i64).saturating_neg();
warnings.push(TemporalWarning::ClockSkewDetected {
version: version_num,
skew_nanos,
});
}
}
}
}
warnings
}
pub fn verify_file(
path: &Path,
registry: &crate::key_registry::KeyRegistry,
) -> Result<VerificationReport> {
let file_bytes = std::fs::read(path).map_err(|e| AionError::FileReadError {
path: path.to_path_buf(),
source: e,
})?;
let parser = AionParser::new(&file_bytes)?;
let header = parser.header();
let mut report = VerificationReport::new(FileId(header.file_id), header.version_chain_count);
report.structure_valid = true;
match parser.verify_integrity() {
Ok(()) => report.integrity_hash_valid = true,
Err(e) => report
.errors
.push(format!("File integrity hash mismatch: {e}")),
}
let Some(versions) = collect_versions_into_report(&parser, &mut report)? else {
emit_verify_outcome(&report);
return Ok(report);
};
match verify_hash_chain(&versions) {
Ok(()) => report.hash_chain_valid = true,
Err(e) => report
.errors
.push(format!("Hash chain verification failed: {e}")),
}
let Some(signatures) = collect_signatures_into_report(&parser, &mut report)? else {
emit_verify_outcome(&report);
return Ok(report);
};
match verify_signatures_batch(&versions, &signatures, registry) {
Ok(()) => report.signatures_valid = true,
Err(e) => report
.errors
.push(format!("Signature verification failed: {e}")),
}
report.temporal_warnings = check_temporal_ordering(&versions);
report.is_valid = report.structure_valid
&& report.integrity_hash_valid
&& report.hash_chain_valid
&& report.signatures_valid;
emit_verify_outcome(&report);
Ok(report)
}
const fn classify_verify_failure(report: &VerificationReport) -> &'static str {
if !report.structure_valid {
"structure_invalid"
} else if !report.integrity_hash_valid {
"integrity_hash_mismatch"
} else if !report.hash_chain_valid {
"hash_chain_broken"
} else if !report.signatures_valid {
"signature_invalid"
} else {
"unknown"
}
}
fn emit_verify_outcome(report: &VerificationReport) {
let file_id = crate::obs::short_hex(&report.file_id.as_u64().to_le_bytes());
if report.is_valid {
tracing::info!(
event = "file_verified",
file_id = %file_id,
versions = report.version_count,
);
} else {
tracing::warn!(
event = "file_rejected",
file_id = %file_id,
versions = report.version_count,
reason = classify_verify_failure(report),
);
}
}
#[allow(clippy::cast_possible_truncation)]
fn collect_versions_into_report(
parser: &AionParser<'_>,
report: &mut VerificationReport,
) -> Result<Option<Vec<VersionEntry>>> {
let count = parser.header().version_chain_count as usize;
let mut versions = Vec::with_capacity(count);
for i in 0..count {
match parser.get_version_entry(i) {
Ok(entry) => versions.push(entry),
Err(e) => {
report
.errors
.push(format!("Failed to read version entry {i}: {e}"));
return Ok(None);
}
}
}
Ok(Some(versions))
}
#[allow(clippy::cast_possible_truncation)]
fn collect_signatures_into_report(
parser: &AionParser<'_>,
report: &mut VerificationReport,
) -> Result<Option<Vec<SignatureEntry>>> {
let count = parser.header().signatures_count as usize;
let mut signatures = Vec::with_capacity(count);
for i in 0..count {
match parser.get_signature_entry(i) {
Ok(entry) => signatures.push(entry),
Err(e) => {
report
.errors
.push(format!("Failed to read signature entry {i}: {e}"));
return Ok(None);
}
}
}
Ok(Some(signatures))
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct VersionInfo {
pub version_number: u64,
pub author_id: u64,
pub timestamp: u64,
pub message: String,
pub rules_hash: [u8; 32],
pub parent_hash: Option<[u8; 32]>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SignatureInfo {
pub version_number: u64,
pub author_id: u64,
pub public_key: [u8; 32],
pub verified: bool,
pub error: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct FileInfo {
pub file_id: u64,
pub version_count: u64,
pub current_version: u64,
pub versions: Vec<VersionInfo>,
pub signatures: Vec<SignatureInfo>,
}
pub fn show_current_rules(path: &Path) -> Result<Vec<u8>> {
let file_bytes = std::fs::read(path).map_err(|e| AionError::FileReadError {
path: path.to_path_buf(),
source: e,
})?;
let parser = AionParser::new(&file_bytes)?;
let header = parser.header();
let file_id = FileId(header.file_id);
let version_count = header.version_chain_count;
if version_count == 0 {
return Err(AionError::InvalidFormat {
reason: "File has no versions".to_string(),
});
}
#[allow(clippy::cast_possible_truncation)]
#[allow(clippy::arithmetic_side_effects)] let latest_idx = (version_count - 1) as usize;
let latest_version = parser.get_version_entry(latest_idx)?;
let encrypted_rules = parser.encrypted_rules_bytes()?;
decrypt_rules(
encrypted_rules,
file_id,
VersionNumber(latest_version.version_number),
latest_version.rules_hash,
)
}
pub fn show_version_history(path: &Path) -> Result<Vec<VersionInfo>> {
let file_bytes = std::fs::read(path).map_err(|e| AionError::FileReadError {
path: path.to_path_buf(),
source: e,
})?;
let parser = AionParser::new(&file_bytes)?;
let header = parser.header();
let version_count = header.version_chain_count;
#[allow(clippy::cast_possible_truncation)] let mut versions = Vec::with_capacity(version_count as usize);
let string_table = parser.string_table_bytes()?;
#[allow(clippy::cast_possible_truncation)]
for i in 0..version_count as usize {
let entry = parser.get_version_entry(i)?;
let message_offset = entry.message_offset as usize;
let message_length = entry.message_length as usize;
#[allow(clippy::arithmetic_side_effects)] let message =
message_offset
.checked_add(message_length)
.map_or_else(String::new, |end_offset| {
if end_offset <= string_table.len() {
string_table
.get(message_offset..end_offset)
.map(|bytes| String::from_utf8_lossy(bytes).to_string())
.unwrap_or_default()
} else {
String::new()
}
});
let parent_hash = if entry.version_number == 1 {
None
} else {
Some(entry.parent_hash)
};
versions.push(VersionInfo {
version_number: entry.version_number,
author_id: entry.author_id,
timestamp: entry.timestamp,
message,
rules_hash: entry.rules_hash,
parent_hash,
});
}
Ok(versions)
}
pub fn show_signatures(
path: &Path,
registry: &crate::key_registry::KeyRegistry,
) -> Result<Vec<SignatureInfo>> {
let file_bytes = std::fs::read(path).map_err(|e| AionError::FileReadError {
path: path.to_path_buf(),
source: e,
})?;
let parser = AionParser::new(&file_bytes)?;
let header = parser.header();
let sig_count = header.signatures_count;
let version_count = header.version_chain_count;
#[allow(clippy::cast_possible_truncation)] let mut versions = Vec::with_capacity(version_count as usize);
#[allow(clippy::cast_possible_truncation)]
for i in 0..version_count as usize {
versions.push(parser.get_version_entry(i)?);
}
#[allow(clippy::cast_possible_truncation)] let mut signatures = Vec::with_capacity(sig_count as usize);
#[allow(clippy::cast_possible_truncation)]
for i in 0..sig_count as usize {
let sig_entry = parser.get_signature_entry(i)?;
let version_entry = versions.get(i).ok_or_else(|| AionError::InvalidFormat {
reason: format!(
"Signature index {} exceeds version count {}",
i,
versions.len()
),
})?;
let result = crate::signature_chain::verify_signature(version_entry, &sig_entry, registry);
let (verified, error) = match result {
Ok(()) => (true, None),
Err(e) => (false, Some(e.to_string())),
};
signatures.push(SignatureInfo {
version_number: version_entry.version_number,
author_id: sig_entry.author_id,
public_key: sig_entry.public_key,
verified,
error,
});
}
Ok(signatures)
}
pub fn show_file_info(
path: &Path,
registry: &crate::key_registry::KeyRegistry,
) -> Result<FileInfo> {
let file_bytes = std::fs::read(path).map_err(|e| AionError::FileReadError {
path: path.to_path_buf(),
source: e,
})?;
let parser = AionParser::new(&file_bytes)?;
let header = parser.header();
let versions = show_version_history(path)?;
let signatures = show_signatures(path, registry)?;
let current_version = versions.last().map_or(0, |v| v.version_number);
Ok(FileInfo {
file_id: header.file_id,
version_count: header.version_chain_count,
current_version,
versions,
signatures,
})
}
fn build_string_table_with_message(
message: &str,
parser: &AionParser<'_>,
) -> Result<(Vec<u8>, u64)> {
let existing_table = parser.string_table_bytes()?;
let message_offset = existing_table.len() as u64;
let mut new_table = existing_table.to_vec();
new_table.extend_from_slice(message.as_bytes());
new_table.push(0);
Ok((new_table, message_offset))
}
#[allow(clippy::cast_possible_truncation)] fn current_timestamp_nanos() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0)
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::cast_possible_truncation)] #[allow(clippy::arithmetic_side_effects)] fn build_updated_file(
parser: &AionParser<'_>,
header: &crate::parser::FileHeader,
new_version: VersionNumber,
new_rules_hash: [u8; 32],
encrypted_rules: Vec<u8>,
new_version_entry: VersionEntry,
new_signature: SignatureEntry,
string_table: Vec<u8>,
timestamp: u64,
author_id: AuthorId,
) -> Result<AionFile> {
let versions = collect_existing_plus(parser, header.version_chain_count, new_version_entry)?;
let signatures =
collect_existing_plus_signatures(parser, header.signatures_count, new_signature)?;
let audit_entries = collect_existing_audit_plus_commit(parser, header, timestamp, author_id)?;
AionFile::builder()
.file_id(FileId::new(header.file_id))
.current_version(new_version)
.flags(header.flags)
.root_hash(header.root_hash)
.current_hash(new_rules_hash)
.created_at(header.created_at)
.modified_at(timestamp)
.encrypted_rules(encrypted_rules)
.versions(versions)
.signatures(signatures)
.audit_entries(audit_entries)
.string_table(string_table)
.build()
}
#[allow(clippy::cast_possible_truncation)]
fn collect_versions(parser: &AionParser<'_>, count: u64) -> Result<Vec<VersionEntry>> {
let n = count as usize;
let mut versions = Vec::with_capacity(n);
for i in 0..n {
versions.push(parser.get_version_entry(i)?);
}
Ok(versions)
}
#[allow(clippy::cast_possible_truncation)]
#[allow(clippy::arithmetic_side_effects)]
fn collect_existing_plus(
parser: &AionParser<'_>,
count: u64,
new_entry: VersionEntry,
) -> Result<Vec<VersionEntry>> {
let mut versions = collect_versions(parser, count)?;
versions.push(new_entry);
Ok(versions)
}
#[allow(clippy::cast_possible_truncation)]
#[allow(clippy::arithmetic_side_effects)]
fn collect_existing_plus_signatures(
parser: &AionParser<'_>,
count: u64,
new_entry: SignatureEntry,
) -> Result<Vec<SignatureEntry>> {
let n = count as usize;
let mut signatures = Vec::with_capacity(n + 1);
for i in 0..n {
signatures.push(parser.get_signature_entry(i)?);
}
signatures.push(new_entry);
Ok(signatures)
}
#[allow(clippy::cast_possible_truncation)]
#[allow(clippy::arithmetic_side_effects)]
fn collect_existing_audit_plus_commit(
parser: &AionParser<'_>,
header: &crate::parser::FileHeader,
timestamp: u64,
author_id: AuthorId,
) -> Result<Vec<AuditEntry>> {
let n = header.audit_trail_count as usize;
let mut audit_entries = Vec::with_capacity(n + 1);
for i in 0..n {
audit_entries.push(parser.get_audit_entry(i)?);
}
let previous_hash = audit_entries
.last()
.map_or([0u8; 32], AuditEntry::compute_hash);
audit_entries.push(AuditEntry::new(
timestamp,
author_id,
ActionCode::CommitVersion,
0,
0,
previous_hash,
));
Ok(audit_entries)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
#[allow(clippy::inconsistent_digit_grouping)]
#[allow(clippy::indexing_slicing)]
#[allow(clippy::cast_possible_truncation)]
mod tests {
use super::*;
use crate::audit::ActionCode;
use crate::key_registry::KeyRegistry;
use crate::serializer::AionSerializer;
use crate::signature_chain::{create_genesis_version, sign_version};
use tempfile::TempDir;
fn test_reg(author_id: AuthorId, signing_key: &SigningKey) -> KeyRegistry {
let mut reg = KeyRegistry::new();
let master = SigningKey::generate();
reg.register_author(
author_id,
master.verifying_key(),
signing_key.verifying_key(),
0,
)
.unwrap_or_else(|_| std::process::abort());
reg
}
fn create_test_file(signing_key: &SigningKey, author_id: AuthorId) -> Vec<u8> {
let timestamp = 1700000000_000_000_000u64;
let rules = b"initial rules content";
let rules_hash = hash(rules);
let genesis = create_genesis_version(rules_hash, author_id, timestamp, 0, 15);
let signature = sign_version(&genesis, signing_key);
let audit = AuditEntry::new(
timestamp,
author_id,
ActionCode::CreateGenesis,
0,
0,
[0u8; 32],
);
let file_id = FileId::new(12345);
let (encrypted_rules, _) = encrypt_rules(rules, file_id, VersionNumber::GENESIS).unwrap();
let (string_table, _) = AionSerializer::build_string_table(&["Genesis version"]);
let file = AionFile::builder()
.file_id(file_id)
.current_version(VersionNumber::GENESIS)
.flags(0x0001) .root_hash(rules_hash)
.current_hash(rules_hash)
.created_at(timestamp)
.modified_at(timestamp)
.encrypted_rules(encrypted_rules)
.add_version(genesis)
.add_signature(signature)
.add_audit_entry(audit)
.string_table(string_table)
.build()
.unwrap();
AionSerializer::serialize(&file).unwrap()
}
mod commit_version_tests {
use super::*;
#[test]
fn should_commit_new_version() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.aion");
let signing_key = SigningKey::generate();
let author_id = AuthorId::new(50001);
let initial_bytes = create_test_file(&signing_key, author_id);
std::fs::write(&file_path, &initial_bytes).unwrap();
let options = CommitOptions {
author_id,
signing_key: &signing_key,
message: "Updated rules",
timestamp: Some(1700000001_000_000_000),
};
let result = commit_version(
&file_path,
b"new rules content",
&options,
&test_reg(author_id, &signing_key),
)
.unwrap();
assert_eq!(result.version.as_u64(), 2);
assert_ne!(result.rules_hash, [0u8; 32]);
}
#[test]
fn should_verify_chain_before_commit() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.aion");
let signing_key = SigningKey::generate();
let author_id = AuthorId::new(50001);
let initial_bytes = create_test_file(&signing_key, author_id);
std::fs::write(&file_path, &initial_bytes).unwrap();
let bytes = std::fs::read(&file_path).unwrap();
let parser = AionParser::new(&bytes).unwrap();
assert_eq!(parser.header().current_version, 1);
}
#[test]
fn should_increment_version_correctly() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.aion");
let signing_key = SigningKey::generate();
let author_id = AuthorId::new(50001);
let initial_bytes = create_test_file(&signing_key, author_id);
std::fs::write(&file_path, &initial_bytes).unwrap();
for i in 2..=5 {
let options = CommitOptions {
author_id,
signing_key: &signing_key,
message: &format!("Version {i}"),
timestamp: Some(1700000000_000_000_000 + i * 1_000_000_000),
};
let result = commit_version(
&file_path,
format!("rules v{i}").as_bytes(),
&options,
&test_reg(author_id, &signing_key),
)
.unwrap();
assert_eq!(result.version.as_u64(), i);
}
let bytes = std::fs::read(&file_path).unwrap();
let parser = AionParser::new(&bytes).unwrap();
assert_eq!(parser.header().current_version, 5);
assert_eq!(parser.header().version_chain_count, 5);
}
#[test]
fn should_preserve_existing_versions() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.aion");
let signing_key = SigningKey::generate();
let author_id = AuthorId::new(50001);
let initial_bytes = create_test_file(&signing_key, author_id);
std::fs::write(&file_path, &initial_bytes).unwrap();
let initial_parser = AionParser::new(&initial_bytes).unwrap();
let initial_version = initial_parser.get_version_entry(0).unwrap();
let initial_hash = compute_version_hash(&initial_version);
let options = CommitOptions {
author_id,
signing_key: &signing_key,
message: "New version",
timestamp: Some(1700000001_000_000_000),
};
commit_version(
&file_path,
b"new rules",
&options,
&test_reg(author_id, &signing_key),
)
.unwrap();
let bytes = std::fs::read(&file_path).unwrap();
let parser = AionParser::new(&bytes).unwrap();
let preserved_version = parser.get_version_entry(0).unwrap();
let preserved_hash = compute_version_hash(&preserved_version);
assert_eq!(initial_hash, preserved_hash);
}
#[test]
fn should_link_to_parent_correctly() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.aion");
let signing_key = SigningKey::generate();
let author_id = AuthorId::new(50001);
let initial_bytes = create_test_file(&signing_key, author_id);
std::fs::write(&file_path, &initial_bytes).unwrap();
let parser = AionParser::new(&initial_bytes).unwrap();
let genesis = parser.get_version_entry(0).unwrap();
let genesis_hash = compute_version_hash(&genesis);
let options = CommitOptions {
author_id,
signing_key: &signing_key,
message: "Version 2",
timestamp: Some(1700000001_000_000_000),
};
commit_version(
&file_path,
b"new rules",
&options,
&test_reg(author_id, &signing_key),
)
.unwrap();
let bytes = std::fs::read(&file_path).unwrap();
let parser = AionParser::new(&bytes).unwrap();
let version2 = parser.get_version_entry(1).unwrap();
assert_eq!(version2.parent_hash, genesis_hash);
}
}
mod encrypt_rules_tests {
use super::*;
#[test]
fn should_encrypt_rules_deterministically_with_same_nonce() {
let rules = b"test rules content";
let file_id = FileId::new(12345);
let version = VersionNumber::GENESIS;
let (encrypted1, hash1) = encrypt_rules(rules, file_id, version).unwrap();
let (encrypted2, hash2) = encrypt_rules(rules, file_id, version).unwrap();
assert_eq!(hash1, hash2);
assert!(encrypted1.len() >= 12 + rules.len());
assert!(encrypted2.len() >= 12 + rules.len());
}
#[test]
fn should_produce_different_hashes_for_different_rules() {
let file_id = FileId::new(12345);
let version = VersionNumber::GENESIS;
let (_, hash1) = encrypt_rules(b"rules A", file_id, version).unwrap();
let (_, hash2) = encrypt_rules(b"rules B", file_id, version).unwrap();
assert_ne!(hash1, hash2);
}
}
mod decrypt_rules_tests {
use super::*;
#[test]
fn should_decrypt_encrypted_rules_successfully() {
let rules = b"test rules content that needs decryption";
let file_id = FileId::new(12345);
let version = VersionNumber::GENESIS;
let (encrypted, expected_hash) = encrypt_rules(rules, file_id, version).unwrap();
let decrypted = decrypt_rules(&encrypted, file_id, version, expected_hash).unwrap();
assert_eq!(decrypted, rules);
}
#[test]
fn should_verify_roundtrip_for_multiple_versions() {
let file_id = FileId::new(54321);
for version_num in 1..=5 {
let version = VersionNumber(version_num);
let rules = format!("Rules for version {version_num}").into_bytes();
let (encrypted, hash) = encrypt_rules(&rules, file_id, version).unwrap();
let decrypted = decrypt_rules(&encrypted, file_id, version, hash).unwrap();
assert_eq!(decrypted, rules);
}
}
#[test]
fn should_reject_decryption_with_wrong_file_id() {
let rules = b"sensitive rules";
let correct_file_id = FileId::new(12345);
let wrong_file_id = FileId::new(99999);
let version = VersionNumber::GENESIS;
let (encrypted, hash) = encrypt_rules(rules, correct_file_id, version).unwrap();
let result = decrypt_rules(&encrypted, wrong_file_id, version, hash);
assert!(result.is_err());
}
#[test]
fn should_reject_decryption_with_wrong_version() {
let rules = b"version-specific rules";
let file_id = FileId::new(12345);
let correct_version = VersionNumber(1);
let wrong_version = VersionNumber(2);
let (encrypted, hash) = encrypt_rules(rules, file_id, correct_version).unwrap();
let result = decrypt_rules(&encrypted, file_id, wrong_version, hash);
assert!(result.is_err());
}
#[test]
fn should_reject_tampered_ciphertext() {
let rules = b"rules that will be tampered with";
let file_id = FileId::new(12345);
let version = VersionNumber::GENESIS;
let (mut encrypted, hash) = encrypt_rules(rules, file_id, version).unwrap();
if encrypted.len() > 20 {
encrypted[20] ^= 0x01;
}
let result = decrypt_rules(&encrypted, file_id, version, hash);
assert!(result.is_err());
if let Err(e) = result {
assert!(matches!(e, AionError::DecryptionFailed { .. }));
}
}
#[test]
fn should_reject_tampered_nonce() {
let rules = b"rules with nonce tampering";
let file_id = FileId::new(12345);
let version = VersionNumber::GENESIS;
let (mut encrypted, hash) = encrypt_rules(rules, file_id, version).unwrap();
if !encrypted.is_empty() {
encrypted[0] ^= 0x01;
}
let result = decrypt_rules(&encrypted, file_id, version, hash);
assert!(result.is_err());
}
#[test]
fn should_reject_wrong_expected_hash() {
let rules = b"rules with wrong hash";
let file_id = FileId::new(12345);
let version = VersionNumber::GENESIS;
let (encrypted, _correct_hash) = encrypt_rules(rules, file_id, version).unwrap();
let wrong_hash = [0u8; 32]; let result = decrypt_rules(&encrypted, file_id, version, wrong_hash);
assert!(result.is_err());
if let Err(e) = result {
assert!(matches!(e, AionError::HashMismatch { .. }));
}
}
#[test]
fn should_reject_too_short_encrypted_data() {
let short_data = [0u8; 8]; let file_id = FileId::new(12345);
let version = VersionNumber::GENESIS;
let hash = [0u8; 32];
let result = decrypt_rules(&short_data, file_id, version, hash);
assert!(result.is_err());
if let Err(e) = result {
assert!(matches!(e, AionError::DecryptionFailed { .. }));
}
}
#[test]
fn should_handle_empty_rules_content() {
let rules = b"";
let file_id = FileId::new(12345);
let version = VersionNumber::GENESIS;
let (encrypted, hash) = encrypt_rules(rules, file_id, version).unwrap();
let decrypted = decrypt_rules(&encrypted, file_id, version, hash).unwrap();
assert_eq!(decrypted, rules);
assert!(decrypted.is_empty());
}
#[test]
fn should_handle_large_rules_content() {
let rules = vec![0xAB; 1024 * 1024]; let file_id = FileId::new(12345);
let version = VersionNumber::GENESIS;
let (encrypted, hash) = encrypt_rules(&rules, file_id, version).unwrap();
let decrypted = decrypt_rules(&encrypted, file_id, version, hash).unwrap();
assert_eq!(decrypted.len(), rules.len());
assert_eq!(decrypted, rules);
}
#[test]
fn should_derive_different_keys_for_different_versions() {
let rules = b"same rules, different versions";
let file_id = FileId::new(12345);
let (encrypted_v1, hash1) = encrypt_rules(rules, file_id, VersionNumber(1)).unwrap();
let (encrypted_v2, hash2) = encrypt_rules(rules, file_id, VersionNumber(2)).unwrap();
assert_eq!(hash1, hash2);
let result = decrypt_rules(&encrypted_v1, file_id, VersionNumber(2), hash1);
assert!(result.is_err(), "Should not decrypt v1 data with v2 key");
let decrypted_v2 =
decrypt_rules(&encrypted_v2, file_id, VersionNumber(2), hash2).unwrap();
assert_eq!(decrypted_v2, rules);
}
}
mod verification_tests {
use super::*;
#[test]
fn should_reject_tampered_signature() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.aion");
let signing_key = SigningKey::generate();
let author_id = AuthorId::new(50001);
let mut initial_bytes = create_test_file(&signing_key, author_id);
let parser = AionParser::new(&initial_bytes).unwrap();
let sig_offset = parser.header().signatures_offset as usize;
if sig_offset + 50 < initial_bytes.len() {
initial_bytes[sig_offset + 50] ^= 0x01;
}
std::fs::write(&file_path, &initial_bytes).unwrap();
let options = CommitOptions {
author_id,
signing_key: &signing_key,
message: "Should fail",
timestamp: Some(1700000001_000_000_000),
};
let result = commit_version(
&file_path,
b"new rules",
&options,
&test_reg(author_id, &signing_key),
);
assert!(result.is_err());
}
#[test]
fn should_reject_tampered_version_entry_reserved_bytes() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("ver_tamper.aion");
let signing_key = SigningKey::generate();
let author_id = AuthorId::new(50_011);
let mut bytes = create_test_file(&signing_key, author_id);
let parser = AionParser::new(&bytes).unwrap();
let off = parser.header().version_chain_offset as usize + 130;
bytes[off] ^= 0x55;
std::fs::write(&file_path, &bytes).unwrap();
let parser2 = AionParser::new(&bytes).unwrap();
assert!(
parser2.get_version_entry(0).is_err(),
"VersionEntry with non-zero reserved must be rejected at parse"
);
let options = CommitOptions {
author_id,
signing_key: &signing_key,
message: "should fail",
timestamp: None,
};
let result = commit_version(
&file_path,
b"new",
&options,
&test_reg(author_id, &signing_key),
);
assert!(
result.is_err(),
"commit_version must reject tampered reserved bytes (laundering closed)"
);
}
#[test]
fn should_reject_tampered_signature_entry_reserved_bytes() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("sig_tamper.aion");
let signing_key = SigningKey::generate();
let author_id = AuthorId::new(50_012);
let mut bytes = create_test_file(&signing_key, author_id);
let parser = AionParser::new(&bytes).unwrap();
let off = parser.header().signatures_offset as usize + 108;
bytes[off] ^= 0x55;
std::fs::write(&file_path, &bytes).unwrap();
let parser2 = AionParser::new(&bytes).unwrap();
assert!(
parser2.get_signature_entry(0).is_err(),
"SignatureEntry with non-zero reserved must be rejected at parse"
);
let options = CommitOptions {
author_id,
signing_key: &signing_key,
message: "should fail",
timestamp: None,
};
let result = commit_version(
&file_path,
b"new",
&options,
&test_reg(author_id, &signing_key),
);
assert!(
result.is_err(),
"commit_version must reject tampered SignatureEntry reserved"
);
}
}
mod file_verification_tests {
use super::*;
#[test]
fn should_verify_valid_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.aion");
let signing_key = SigningKey::generate();
let author_id = AuthorId::new(50001);
let file_bytes = create_test_file(&signing_key, author_id);
std::fs::write(&file_path, &file_bytes).unwrap();
let report = verify_file(&file_path, &test_reg(author_id, &signing_key)).unwrap();
assert!(report.is_valid);
assert!(report.structure_valid);
assert!(report.integrity_hash_valid);
assert!(report.hash_chain_valid);
assert!(report.signatures_valid);
assert_eq!(report.version_count, 1);
assert!(report.errors.is_empty());
}
#[test]
fn should_verify_multi_version_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.aion");
let signing_key = SigningKey::generate();
let author_id = AuthorId::new(50001);
let initial_bytes = create_test_file(&signing_key, author_id);
std::fs::write(&file_path, &initial_bytes).unwrap();
let options = CommitOptions {
author_id,
signing_key: &signing_key,
message: "Version 2",
timestamp: Some(1700000001_000_000_000),
};
commit_version(
&file_path,
b"rules v2",
&options,
&test_reg(author_id, &signing_key),
)
.unwrap();
let options = CommitOptions {
author_id,
signing_key: &signing_key,
message: "Version 3",
timestamp: Some(1700000002_000_000_000),
};
commit_version(
&file_path,
b"rules v3",
&options,
&test_reg(author_id, &signing_key),
)
.unwrap();
let report = verify_file(&file_path, &test_reg(author_id, &signing_key)).unwrap();
assert!(report.is_valid);
assert_eq!(report.version_count, 3);
assert!(report.errors.is_empty());
}
#[test]
fn should_detect_corrupted_integrity_hash() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.aion");
let signing_key = SigningKey::generate();
let author_id = AuthorId::new(50001);
let mut file_bytes = create_test_file(&signing_key, author_id);
let len = file_bytes.len();
if len > 32 {
file_bytes[len - 10] ^= 0xFF;
}
std::fs::write(&file_path, &file_bytes).unwrap();
let report = verify_file(&file_path, &test_reg(author_id, &signing_key)).unwrap();
assert!(!report.is_valid);
assert!(report.structure_valid);
assert!(!report.integrity_hash_valid);
assert!(!report.errors.is_empty());
}
#[test]
fn should_detect_broken_hash_chain() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.aion");
let signing_key = SigningKey::generate();
let author_id = AuthorId::new(50001);
let initial_bytes = create_test_file(&signing_key, author_id);
std::fs::write(&file_path, &initial_bytes).unwrap();
let options = CommitOptions {
author_id,
signing_key: &signing_key,
message: "Version 2",
timestamp: Some(1700000001_000_000_000),
};
commit_version(
&file_path,
b"rules v2",
&options,
&test_reg(author_id, &signing_key),
)
.unwrap();
let mut file_bytes = std::fs::read(&file_path).unwrap();
let version_offset = {
let parser = AionParser::new(&file_bytes).unwrap();
parser.header().version_chain_offset as usize
};
let version_entry_size = 108; if version_offset + version_entry_size + 7 < file_bytes.len() {
file_bytes[version_offset + version_entry_size] = 99; }
std::fs::write(&file_path, &file_bytes).unwrap();
let report = verify_file(&file_path, &test_reg(author_id, &signing_key)).unwrap();
assert!(!report.is_valid);
assert!(!report.errors.is_empty());
}
#[test]
fn should_detect_invalid_signature() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.aion");
let signing_key = SigningKey::generate();
let author_id = AuthorId::new(50001);
let mut file_bytes = create_test_file(&signing_key, author_id);
let parser = AionParser::new(&file_bytes).unwrap();
let sig_offset = parser.header().signatures_offset as usize;
if sig_offset + 50 < file_bytes.len() {
file_bytes[sig_offset + 50] ^= 0x01;
}
std::fs::write(&file_path, &file_bytes).unwrap();
let report = verify_file(&file_path, &test_reg(author_id, &signing_key)).unwrap();
assert!(!report.is_valid);
assert!(report.structure_valid);
assert!(!report.signatures_valid);
assert!(!report.errors.is_empty());
}
#[test]
fn should_handle_malformed_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.aion");
std::fs::write(&file_path, b"not a valid aion file").unwrap();
let result = verify_file(&file_path, &KeyRegistry::new());
assert!(result.is_err());
}
#[test]
fn should_handle_nonexistent_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("nonexistent.aion");
let result = verify_file(&file_path, &KeyRegistry::new());
assert!(result.is_err());
}
#[test]
fn should_report_all_errors() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.aion");
let signing_key = SigningKey::generate();
let author_id = AuthorId::new(50001);
let initial_bytes = create_test_file(&signing_key, author_id);
std::fs::write(&file_path, &initial_bytes).unwrap();
let options = CommitOptions {
author_id,
signing_key: &signing_key,
message: "Version 2",
timestamp: Some(1700000001_000_000_000),
};
commit_version(
&file_path,
b"rules v2",
&options,
&test_reg(author_id, &signing_key),
)
.unwrap();
let mut file_bytes = std::fs::read(&file_path).unwrap();
let (version_offset, sig_offset) = {
let parser = AionParser::new(&file_bytes).unwrap();
let header = parser.header();
(
header.version_chain_offset as usize,
header.signatures_offset as usize,
)
};
let len = file_bytes.len();
if len > 32 {
file_bytes[len - 10] ^= 0xFF;
}
let version_entry_size = 108;
if version_offset + version_entry_size + 7 < file_bytes.len() {
file_bytes[version_offset + version_entry_size] = 99; }
if sig_offset + 50 < file_bytes.len() {
file_bytes[sig_offset + 50] ^= 0x01;
}
std::fs::write(&file_path, &file_bytes).unwrap();
let report = verify_file(&file_path, &test_reg(author_id, &signing_key)).unwrap();
assert!(!report.is_valid);
assert!(report.structure_valid); assert!(!report.integrity_hash_valid);
assert!(!report.errors.is_empty());
}
#[test]
fn should_verify_empty_errors_on_valid_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.aion");
let signing_key = SigningKey::generate();
let author_id = AuthorId::new(50001);
let file_bytes = create_test_file(&signing_key, author_id);
std::fs::write(&file_path, &file_bytes).unwrap();
let report = verify_file(&file_path, &test_reg(author_id, &signing_key)).unwrap();
assert!(report.errors.is_empty());
assert!(report.is_valid);
}
}
mod file_inspection_tests {
use super::*;
#[test]
fn should_show_current_rules() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.aion");
let signing_key = SigningKey::generate();
let author_id = AuthorId::new(50001);
let file_bytes = create_test_file(&signing_key, author_id);
std::fs::write(&file_path, &file_bytes).unwrap();
let rules = show_current_rules(&file_path).unwrap();
assert_eq!(rules, b"initial rules content");
}
#[test]
fn should_show_version_history_single_version() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.aion");
let signing_key = SigningKey::generate();
let author_id = AuthorId::new(50001);
let file_bytes = create_test_file(&signing_key, author_id);
std::fs::write(&file_path, &file_bytes).unwrap();
let versions = show_version_history(&file_path).unwrap();
assert_eq!(versions.len(), 1);
assert_eq!(versions[0].version_number, 1);
assert_eq!(versions[0].author_id, author_id.as_u64());
assert_eq!(versions[0].message, "Genesis version");
assert!(versions[0].parent_hash.is_none()); }
#[test]
fn should_show_version_history_multiple_versions() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.aion");
let signing_key = SigningKey::generate();
let author_id = AuthorId::new(50001);
let initial_bytes = create_test_file(&signing_key, author_id);
std::fs::write(&file_path, &initial_bytes).unwrap();
let options = CommitOptions {
author_id,
signing_key: &signing_key,
message: "Version 2",
timestamp: Some(1700000001_000_000_000),
};
commit_version(
&file_path,
b"rules v2",
&options,
&test_reg(author_id, &signing_key),
)
.unwrap();
let options = CommitOptions {
author_id,
signing_key: &signing_key,
message: "Version 3",
timestamp: Some(1700000002_000_000_000),
};
commit_version(
&file_path,
b"rules v3",
&options,
&test_reg(author_id, &signing_key),
)
.unwrap();
let versions = show_version_history(&file_path).unwrap();
assert_eq!(versions.len(), 3);
assert_eq!(versions[0].version_number, 1);
assert_eq!(versions[0].message, "Genesis version");
assert!(versions[0].parent_hash.is_none());
assert_eq!(versions[1].version_number, 2);
assert_eq!(versions[1].message, "Version 2");
assert!(versions[1].parent_hash.is_some());
assert_eq!(versions[2].version_number, 3);
assert_eq!(versions[2].message, "Version 3");
assert!(versions[2].parent_hash.is_some());
}
#[test]
fn should_show_signatures_with_verification() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.aion");
let signing_key = SigningKey::generate();
let author_id = AuthorId::new(50001);
let file_bytes = create_test_file(&signing_key, author_id);
std::fs::write(&file_path, &file_bytes).unwrap();
let signatures =
show_signatures(&file_path, &test_reg(author_id, &signing_key)).unwrap();
assert_eq!(signatures.len(), 1);
assert_eq!(signatures[0].version_number, 1);
assert_eq!(signatures[0].author_id, author_id.as_u64());
assert!(signatures[0].verified);
assert!(signatures[0].error.is_none());
}
#[test]
fn should_show_signatures_with_multiple_versions() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.aion");
let signing_key = SigningKey::generate();
let author_id = AuthorId::new(50001);
let initial_bytes = create_test_file(&signing_key, author_id);
std::fs::write(&file_path, &initial_bytes).unwrap();
let options = CommitOptions {
author_id,
signing_key: &signing_key,
message: "Version 2",
timestamp: Some(1700000001_000_000_000),
};
commit_version(
&file_path,
b"rules v2",
&options,
&test_reg(author_id, &signing_key),
)
.unwrap();
let signatures =
show_signatures(&file_path, &test_reg(author_id, &signing_key)).unwrap();
assert_eq!(signatures.len(), 2);
assert!(signatures[0].verified);
assert!(signatures[1].verified);
assert!(signatures[0].error.is_none());
assert!(signatures[1].error.is_none());
}
#[test]
fn should_detect_invalid_signature_in_show() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.aion");
let signing_key = SigningKey::generate();
let author_id = AuthorId::new(50001);
let mut file_bytes = create_test_file(&signing_key, author_id);
let parser = AionParser::new(&file_bytes).unwrap();
let sig_offset = parser.header().signatures_offset as usize;
if sig_offset + 50 < file_bytes.len() {
file_bytes[sig_offset + 50] ^= 0x01;
}
std::fs::write(&file_path, &file_bytes).unwrap();
let signatures =
show_signatures(&file_path, &test_reg(author_id, &signing_key)).unwrap();
assert_eq!(signatures.len(), 1);
assert!(!signatures[0].verified);
assert!(signatures[0].error.is_some());
}
#[test]
fn should_show_complete_file_info() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.aion");
let signing_key = SigningKey::generate();
let author_id = AuthorId::new(50001);
let initial_bytes = create_test_file(&signing_key, author_id);
std::fs::write(&file_path, &initial_bytes).unwrap();
let options = CommitOptions {
author_id,
signing_key: &signing_key,
message: "Version 2",
timestamp: Some(1700000001_000_000_000),
};
commit_version(
&file_path,
b"rules v2",
&options,
&test_reg(author_id, &signing_key),
)
.unwrap();
let info = show_file_info(&file_path, &test_reg(author_id, &signing_key)).unwrap();
assert_eq!(info.version_count, 2);
assert_eq!(info.current_version, 2);
assert_eq!(info.versions.len(), 2);
assert_eq!(info.signatures.len(), 2);
for sig in &info.signatures {
assert!(sig.verified);
}
}
#[test]
fn should_show_current_rules_for_latest_version() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.aion");
let signing_key = SigningKey::generate();
let author_id = AuthorId::new(50001);
let initial_bytes = create_test_file(&signing_key, author_id);
std::fs::write(&file_path, &initial_bytes).unwrap();
let options = CommitOptions {
author_id,
signing_key: &signing_key,
message: "Updated rules",
timestamp: Some(1700000001_000_000_000),
};
let new_rules = b"these are the updated rules";
commit_version(
&file_path,
new_rules,
&options,
&test_reg(author_id, &signing_key),
)
.unwrap();
let rules = show_current_rules(&file_path).unwrap();
assert_eq!(rules, new_rules);
}
#[test]
fn should_handle_empty_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.aion");
std::fs::write(&file_path, b"").unwrap();
assert!(show_current_rules(&file_path).is_err());
assert!(show_version_history(&file_path).is_err());
assert!(show_signatures(&file_path, &KeyRegistry::new()).is_err());
assert!(show_file_info(&file_path, &KeyRegistry::new()).is_err());
}
#[test]
fn should_handle_nonexistent_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("nonexistent.aion");
assert!(show_current_rules(&file_path).is_err());
assert!(show_version_history(&file_path).is_err());
assert!(show_signatures(&file_path, &KeyRegistry::new()).is_err());
assert!(show_file_info(&file_path, &KeyRegistry::new()).is_err());
}
}
mod init_file_tests {
use super::*;
#[test]
fn should_create_new_file_successfully() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("new.aion");
let signing_key = SigningKey::generate();
let author_id = AuthorId::new(50001);
let options = InitOptions {
author_id,
signing_key: &signing_key,
message: "Initial version",
timestamp: Some(1700000000_000_000_000),
};
let rules = b"fraud_threshold: 1000\nrisk_level: medium";
let result = init_file(&file_path, rules, &options).unwrap();
assert_eq!(result.version.as_u64(), 1);
assert!(file_path.exists());
let loaded_rules = show_current_rules(&file_path).unwrap();
assert_eq!(loaded_rules, rules);
}
#[test]
fn should_create_file_with_correct_structure() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("structured.aion");
let signing_key = SigningKey::generate();
let author_id = AuthorId::new(50001);
let options = InitOptions {
author_id,
signing_key: &signing_key,
message: "Genesis",
timestamp: Some(1700000000_000_000_000),
};
let rules = b"test rules";
init_file(&file_path, rules, &options).unwrap();
let info = show_file_info(&file_path, &test_reg(author_id, &signing_key)).unwrap();
assert_eq!(info.version_count, 1);
assert_eq!(info.current_version, 1);
assert_eq!(info.versions.len(), 1);
assert_eq!(info.signatures.len(), 1);
assert_eq!(info.versions[0].version_number, 1);
assert_eq!(info.versions[0].author_id, author_id.as_u64());
assert_eq!(info.versions[0].message, "Genesis");
assert!(info.versions[0].parent_hash.is_none());
assert!(info.signatures[0].verified);
assert_eq!(info.signatures[0].author_id, author_id.as_u64());
}
#[test]
fn should_fail_if_file_already_exists() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("exists.aion");
let signing_key = SigningKey::generate();
let author_id = AuthorId::new(50001);
let options = InitOptions {
author_id,
signing_key: &signing_key,
message: "Initial version",
timestamp: Some(1700000000_000_000_000),
};
init_file(&file_path, b"rules", &options).unwrap();
let result = init_file(&file_path, b"new rules", &options);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), AionError::FileExists { .. }));
}
#[test]
fn should_generate_unique_file_ids() {
let temp_dir = TempDir::new().unwrap();
let path1 = temp_dir.path().join("file1.aion");
let path2 = temp_dir.path().join("file2.aion");
let signing_key = SigningKey::generate();
let author_id = AuthorId::new(50001);
let options = InitOptions {
author_id,
signing_key: &signing_key,
message: "Initial",
timestamp: Some(1700000000_000_000_000),
};
let result1 = init_file(&path1, b"rules1", &options).unwrap();
let result2 = init_file(&path2, b"rules2", &options).unwrap();
assert_ne!(result1.file_id.as_u64(), result2.file_id.as_u64());
}
#[test]
fn should_encrypt_rules_content() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("encrypted.aion");
let signing_key = SigningKey::generate();
let author_id = AuthorId::new(50001);
let options = InitOptions {
author_id,
signing_key: &signing_key,
message: "Initial",
timestamp: Some(1700000000_000_000_000),
};
let secret_rules = b"secret: fraud_detection_threshold_is_5000";
init_file(&file_path, secret_rules, &options).unwrap();
let file_bytes = std::fs::read(&file_path).unwrap();
let file_string = String::from_utf8_lossy(&file_bytes);
assert!(!file_string.contains("secret"));
assert!(!file_string.contains("fraud_detection_threshold"));
let decrypted = show_current_rules(&file_path).unwrap();
assert_eq!(decrypted, secret_rules);
}
#[test]
fn should_create_valid_signature() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("signed.aion");
let signing_key = SigningKey::generate();
let author_id = AuthorId::new(50001);
let options = InitOptions {
author_id,
signing_key: &signing_key,
message: "Initial",
timestamp: Some(1700000000_000_000_000),
};
init_file(&file_path, b"rules", &options).unwrap();
let report = verify_file(&file_path, &test_reg(author_id, &signing_key)).unwrap();
assert!(report.is_valid);
assert!(report.signatures_valid);
assert!(report.errors.is_empty());
}
#[test]
fn should_use_current_timestamp_when_none_provided() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("timestamped.aion");
let signing_key = SigningKey::generate();
let author_id = AuthorId::new(50001);
let options = InitOptions {
author_id,
signing_key: &signing_key,
message: "Initial",
timestamp: None, };
let before = current_timestamp_nanos();
init_file(&file_path, b"rules", &options).unwrap();
let after = current_timestamp_nanos();
let versions = show_version_history(&file_path).unwrap();
assert_eq!(versions.len(), 1);
assert!(versions[0].timestamp >= before);
assert!(versions[0].timestamp <= after);
}
#[test]
fn should_handle_empty_rules() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("empty.aion");
let signing_key = SigningKey::generate();
let author_id = AuthorId::new(50001);
let options = InitOptions {
author_id,
signing_key: &signing_key,
message: "Empty genesis",
timestamp: Some(1700000000_000_000_000),
};
let result = init_file(&file_path, b"", &options).unwrap();
assert_eq!(result.version.as_u64(), 1);
let rules = show_current_rules(&file_path).unwrap();
assert_eq!(rules, b"");
}
#[test]
fn should_handle_large_rules() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("large.aion");
let signing_key = SigningKey::generate();
let author_id = AuthorId::new(50001);
let options = InitOptions {
author_id,
signing_key: &signing_key,
message: "Large ruleset",
timestamp: Some(1700000000_000_000_000),
};
let large_rules = vec![b'X'; 1024 * 1024];
init_file(&file_path, &large_rules, &options).unwrap();
let decrypted = show_current_rules(&file_path).unwrap();
assert_eq!(decrypted.len(), large_rules.len());
assert_eq!(decrypted, large_rules);
}
#[test]
fn should_handle_long_commit_messages() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("longmsg.aion");
let signing_key = SigningKey::generate();
let author_id = AuthorId::new(50001);
let long_message = "A".repeat(1000);
let options = InitOptions {
author_id,
signing_key: &signing_key,
message: &long_message,
timestamp: Some(1700000000_000_000_000),
};
init_file(&file_path, b"rules", &options).unwrap();
let versions = show_version_history(&file_path).unwrap();
assert_eq!(versions[0].message, long_message);
}
}
mod exit_code_tests {
use super::*;
fn report_with(is_valid: bool) -> VerificationReport {
let mut r = VerificationReport::new(FileId::new(1), 1);
r.is_valid = is_valid;
r
}
#[test]
fn valid_report_maps_to_success() {
assert_eq!(
report_with(true).exit_code(),
std::process::ExitCode::SUCCESS
);
}
#[test]
fn invalid_report_maps_to_failure() {
let invalid = format!("{:?}", report_with(false).exit_code());
let failure = format!("{:?}", std::process::ExitCode::FAILURE);
assert_eq!(invalid, failure);
}
mod properties {
use super::*;
use hegel::generators as gs;
#[hegel::test]
fn prop_exit_code_reflects_verdict(tc: hegel::TestCase) {
let is_valid = tc.draw(gs::integers::<u8>()) % 2 == 1;
let report = report_with(is_valid);
let observed = format!("{:?}", report.exit_code());
let expected = format!(
"{:?}",
if is_valid {
std::process::ExitCode::SUCCESS
} else {
std::process::ExitCode::FAILURE
}
);
if observed != expected {
std::process::abort();
}
}
}
}
mod registry_precheck_tests {
use super::*;
use crate::crypto::SigningKey;
use crate::key_registry::KeyRegistry;
mod properties {
use super::*;
use hegel::generators as gs;
fn single_author_registry(author: AuthorId, op_key: &SigningKey) -> KeyRegistry {
let master = SigningKey::generate();
let mut reg = KeyRegistry::new();
reg.register_author(author, master.verifying_key(), op_key.verifying_key(), 0)
.unwrap_or_else(|_| std::process::abort());
reg
}
fn options(author: AuthorId, key: &SigningKey) -> CommitOptions<'_> {
CommitOptions {
author_id: author,
signing_key: key,
message: "",
timestamp: None,
}
}
#[hegel::test]
fn prop_unknown_author_rejects(tc: hegel::TestCase) {
let pinned_id = tc.draw(gs::integers::<u64>().min_value(1).max_value(1 << 40));
let probe_id = tc.draw(gs::integers::<u64>().min_value(1).max_value(1 << 40));
if pinned_id == probe_id {
return; }
let version = tc.draw(gs::integers::<u64>().min_value(1).max_value(1 << 30));
let pinned_key = SigningKey::generate();
let reg = single_author_registry(AuthorId::new(pinned_id), &pinned_key);
let probe_key = SigningKey::generate();
let opts = options(AuthorId::new(probe_id), &probe_key);
match preflight_registry_authz(&opts, VersionNumber(version), ®) {
Err(AionError::UnauthorizedSigner { .. }) => {}
_ => std::process::abort(),
}
}
#[hegel::test]
fn prop_pinned_matching_key_accepts(tc: hegel::TestCase) {
let author_id = tc.draw(gs::integers::<u64>().min_value(1).max_value(1 << 40));
let version = tc.draw(gs::integers::<u64>().min_value(1).max_value(1 << 30));
let author = AuthorId::new(author_id);
let op_key = SigningKey::generate();
let reg = single_author_registry(author, &op_key);
let opts = options(author, &op_key);
if preflight_registry_authz(&opts, VersionNumber(version), ®).is_err() {
std::process::abort();
}
}
#[hegel::test]
fn prop_pinned_wrong_key_rejects(tc: hegel::TestCase) {
let author_id = tc.draw(gs::integers::<u64>().min_value(1).max_value(1 << 40));
let version = tc.draw(gs::integers::<u64>().min_value(1).max_value(1 << 30));
let author = AuthorId::new(author_id);
let pinned_key = SigningKey::generate();
let reg = single_author_registry(author, &pinned_key);
let wrong_key = SigningKey::generate();
let opts = options(author, &wrong_key);
match preflight_registry_authz(&opts, VersionNumber(version), ®) {
Err(AionError::KeyMismatch { .. }) => {}
_ => std::process::abort(),
}
}
}
}
mod commit_head_verify_tests {
use super::*;
use crate::parser::SIGNATURE_ENTRY_SIZE;
#[allow(clippy::arithmetic_side_effects)] fn flip_byte_in_signature_at(bytes: &mut [u8], index: usize) {
let parser = AionParser::new(bytes).unwrap();
let sig_offset = parser.header().signatures_offset as usize;
let target = sig_offset + index * SIGNATURE_ENTRY_SIZE + 50;
assert!(target < bytes.len(), "tamper offset out of bounds");
bytes[target] ^= 0x01;
}
#[test]
fn commit_rejects_tampered_head_on_multi_version_chain() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("head_tamper.aion");
let signing_key = SigningKey::generate();
let author_id = AuthorId::new(70_001);
let registry = test_reg(author_id, &signing_key);
let init_opts = InitOptions {
author_id,
signing_key: &signing_key,
message: "v1",
timestamp: None,
};
init_file(&path, b"r1", &init_opts).unwrap();
for _ in 2..=3u64 {
let opts = CommitOptions {
author_id,
signing_key: &signing_key,
message: "amend",
timestamp: None,
};
commit_version(&path, b"r", &opts, ®istry).unwrap();
}
let mut bytes = std::fs::read(&path).unwrap();
flip_byte_in_signature_at(&mut bytes, 2);
std::fs::write(&path, &bytes).unwrap();
let next_opts = CommitOptions {
author_id,
signing_key: &signing_key,
message: "v4",
timestamp: None,
};
let result = commit_version(&path, b"r4", &next_opts, ®istry);
assert!(
result.is_err(),
"commit_version must reject when HEAD signature is tampered"
);
}
#[test]
fn commit_now_catches_non_head_tamper_at_write_time() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("non_head_tamper.aion");
let signing_key = SigningKey::generate();
let author_id = AuthorId::new(70_002);
let registry = test_reg(author_id, &signing_key);
let init_opts = InitOptions {
author_id,
signing_key: &signing_key,
message: "v1",
timestamp: None,
};
init_file(&path, b"r1", &init_opts).unwrap();
for _ in 2..=3u64 {
let opts = CommitOptions {
author_id,
signing_key: &signing_key,
message: "amend",
timestamp: None,
};
commit_version(&path, b"r", &opts, ®istry).unwrap();
}
let mut bytes = std::fs::read(&path).unwrap();
flip_byte_in_signature_at(&mut bytes, 0);
std::fs::write(&path, &bytes).unwrap();
let tampered = std::fs::read(&path).unwrap();
let next_opts = CommitOptions {
author_id,
signing_key: &signing_key,
message: "v4",
timestamp: None,
};
let result = commit_version(&path, b"r4", &next_opts, ®istry);
assert!(
result.is_err(),
"commit_version must reject non-head tamper at write time"
);
let post = std::fs::read(&path).unwrap();
assert_eq!(
tampered, post,
"refused commit must not mutate the tampered file"
);
let report = verify_file(&path, ®istry).unwrap();
assert!(
!report.is_valid,
"verify_file must reject after a non-head signature tamper"
);
}
#[test]
fn commit_succeeds_on_clean_chain_of_many_versions() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("many.aion");
let signing_key = SigningKey::generate();
let author_id = AuthorId::new(70_003);
let registry = test_reg(author_id, &signing_key);
let init_opts = InitOptions {
author_id,
signing_key: &signing_key,
message: "v1",
timestamp: None,
};
init_file(&path, b"v1", &init_opts).unwrap();
for _ in 2..=200u64 {
let opts = CommitOptions {
author_id,
signing_key: &signing_key,
message: "amend",
timestamp: None,
};
commit_version(&path, b"amend", &opts, ®istry).unwrap();
}
let report = verify_file(&path, ®istry).unwrap();
assert!(report.is_valid, "verify_file must accept the built chain");
assert_eq!(report.version_count, 200);
}
}
}