1use 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)] use 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
51pub struct InitOptions<'a> {
57 pub author_id: AuthorId,
59 pub signing_key: &'a SigningKey,
61 pub message: &'a str,
63 pub timestamp: Option<u64>,
65}
66
67#[derive(Debug, Clone)]
69pub struct InitResult {
70 pub file_id: FileId,
72 pub version: VersionNumber,
74 pub rules_hash: [u8; 32],
76}
77
78pub 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
208pub struct CommitOptions<'a> {
214 pub author_id: AuthorId,
216 pub signing_key: &'a SigningKey,
218 pub message: &'a str,
220 pub timestamp: Option<u64>,
223}
224
225#[derive(Debug)]
227pub struct CommitResult {
228 pub version: VersionNumber,
230 pub version_hash: [u8; 32],
232 pub rules_hash: [u8; 32],
234}
235
236#[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#[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 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
411fn 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 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#[allow(clippy::cast_possible_truncation)] #[allow(clippy::arithmetic_side_effects)] fn 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#[allow(clippy::cast_possible_truncation)] #[allow(clippy::arithmetic_side_effects)] fn 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#[allow(clippy::arithmetic_side_effects)] fn encrypt_rules(
522 rules: &[u8],
523 file_id: FileId,
524 version: VersionNumber,
525) -> Result<(Vec<u8>, [u8; 32])> {
526 let rules_hash = hash(rules);
528
529 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 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 let nonce = generate_nonce();
546 let aad = version.as_u64().to_le_bytes();
547 let ciphertext = encrypt(&encryption_key, &nonce, rules, &aad)?;
548
549 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
557pub fn decrypt_rules(
610 encrypted_rules: &[u8],
611 file_id: FileId,
612 version: VersionNumber,
613 expected_hash: [u8; 32],
614) -> Result<Vec<u8>> {
615 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 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 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 let aad = version.as_u64().to_le_bytes();
655 let plaintext = decrypt(&encryption_key, &nonce, ciphertext, &aad)?;
656
657 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#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
674pub enum TemporalWarning {
675 NonMonotonicTimestamp {
677 version: u64,
679 timestamp: u64,
681 previous_timestamp: u64,
683 },
684 FutureTimestamp {
686 version: u64,
688 timestamp: u64,
690 current_time: u64,
692 },
693 ClockSkewDetected {
695 version: u64,
697 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#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
743#[allow(clippy::struct_excessive_bools)] pub struct VerificationReport {
745 pub file_id: FileId,
747 pub version_count: u64,
749 pub structure_valid: bool,
751 pub integrity_hash_valid: bool,
753 pub hash_chain_valid: bool,
755 pub signatures_valid: bool,
757 pub is_valid: bool,
759 pub errors: Vec<String>,
761 pub temporal_warnings: Vec<TemporalWarning>,
763}
764
765impl VerificationReport {
766 #[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 #[must_use]
784 pub fn has_temporal_warnings(&self) -> bool {
785 !self.temporal_warnings.is_empty()
786 }
787
788 #[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 std::process::ExitCode::FAILURE
804 }
805 }
806
807 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
817const CLOCK_SKEW_TOLERANCE_NANOS: u64 = 5 * 60 * 1_000_000_000;
824
825const FUTURE_TOLERANCE_NANOS: u64 = 60 * 1_000_000_000;
828
829fn 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 let current_time = current_timestamp_nanos();
861
862 for (i, version) in versions.iter().enumerate() {
864 let version_num = version.version_number;
865 let timestamp = version.timestamp;
866
867 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 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 let diff = prev_timestamp.saturating_sub(timestamp);
883
884 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 #[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
907pub 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
1006const 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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1087pub struct VersionInfo {
1088 pub version_number: u64,
1090 pub author_id: u64,
1092 pub timestamp: u64,
1094 pub message: String,
1096 pub rules_hash: [u8; 32],
1098 pub parent_hash: Option<[u8; 32]>,
1100}
1101
1102#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1104pub struct SignatureInfo {
1105 pub version_number: u64,
1107 pub author_id: u64,
1109 pub public_key: [u8; 32],
1111 pub verified: bool,
1113 pub error: Option<String>,
1115}
1116
1117#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1119pub struct FileInfo {
1120 pub file_id: u64,
1122 pub version_count: u64,
1124 pub current_version: u64,
1126 pub versions: Vec<VersionInfo>,
1128 pub signatures: Vec<SignatureInfo>,
1130}
1131
1132pub fn show_current_rules(path: &Path) -> Result<Vec<u8>> {
1164 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 #[allow(clippy::cast_possible_truncation)]
1183 #[allow(clippy::arithmetic_side_effects)] let latest_idx = (version_count - 1) as usize;
1185 let latest_version = parser.get_version_entry(latest_idx)?;
1186
1187 let encrypted_rules = parser.encrypted_rules_bytes()?;
1189
1190 decrypt_rules(
1192 encrypted_rules,
1193 file_id,
1194 VersionNumber(latest_version.version_number),
1195 latest_version.rules_hash,
1196 )
1197}
1198
1199pub fn show_version_history(path: &Path) -> Result<Vec<VersionInfo>> {
1232 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)] let mut versions = Vec::with_capacity(version_count as usize);
1244
1245 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 let message_offset = entry.message_offset as usize;
1254 let message_length = entry.message_length as usize;
1255
1256 #[allow(clippy::arithmetic_side_effects)] 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 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
1291pub fn show_signatures(
1332 path: &Path,
1333 registry: &crate::key_registry::KeyRegistry,
1334) -> Result<Vec<SignatureInfo>> {
1335 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 #[allow(clippy::cast_possible_truncation)] 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)] let mut signatures = Vec::with_capacity(sig_count as usize);
1356
1357 #[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 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
1389pub fn show_file_info(
1417 path: &Path,
1418 registry: &crate::key_registry::KeyRegistry,
1419) -> Result<FileInfo> {
1420 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
1443fn build_string_table_with_message(
1445 message: &str,
1446 parser: &AionParser<'_>,
1447) -> Result<(Vec<u8>, u64)> {
1448 let existing_table = parser.string_table_bytes()?;
1450
1451 let message_offset = existing_table.len() as u64;
1453
1454 let mut new_table = existing_table.to_vec();
1456 new_table.extend_from_slice(message.as_bytes());
1457 new_table.push(0); Ok((new_table, message_offset))
1460}
1461
1462#[allow(clippy::cast_possible_truncation)] fn 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#[allow(clippy::too_many_arguments)]
1473#[allow(clippy::cast_possible_truncation)] #[allow(clippy::arithmetic_side_effects)] fn 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 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 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 let genesis = create_genesis_version(rules_hash, author_id, timestamp, 0, 15);
1609
1610 let signature = sign_version(&genesis, signing_key);
1612
1613 let audit = AuditEntry::new(
1615 timestamp,
1616 author_id,
1617 ActionCode::CreateGenesis,
1618 0,
1619 0,
1620 [0u8; 32],
1621 );
1622
1623 let file_id = FileId::new(12345);
1625 let (encrypted_rules, _) = encrypt_rules(rules, file_id, VersionNumber::GENESIS).unwrap();
1626
1627 let (string_table, _) = AionSerializer::build_string_table(&["Genesis version"]);
1629
1630 let file = AionFile::builder()
1632 .file_id(file_id)
1633 .current_version(VersionNumber::GENESIS)
1634 .flags(0x0001) .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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(hash1, hash2);
1834
1835 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 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 let decrypted = decrypt_rules(&encrypted, file_id, version, expected_hash).unwrap();
1866
1867 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 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 let result = decrypt_rules(&encrypted, wrong_file_id, version, hash);
1898
1899 assert!(result.is_err());
1901 }
1902
1903 #[test]
1904 fn should_reject_decryption_with_wrong_version() {
1905 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 let result = decrypt_rules(&encrypted, file_id, wrong_version, hash);
1915
1916 assert!(result.is_err());
1918 }
1919
1920 #[test]
1921 fn should_reject_tampered_ciphertext() {
1922 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 if encrypted.len() > 20 {
1931 encrypted[20] ^= 0x01;
1932 }
1933
1934 let result = decrypt_rules(&encrypted, file_id, version, hash);
1935
1936 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 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 if !encrypted.is_empty() {
1954 encrypted[0] ^= 0x01;
1955 }
1956
1957 let result = decrypt_rules(&encrypted, file_id, version, hash);
1958
1959 assert!(result.is_err());
1961 }
1962
1963 #[test]
1964 fn should_reject_wrong_expected_hash() {
1965 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 let wrong_hash = [0u8; 32]; let result = decrypt_rules(&encrypted, file_id, version, wrong_hash);
1975
1976 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 let short_data = [0u8; 8]; let file_id = FileId::new(12345);
1988 let version = VersionNumber::GENESIS;
1989 let hash = [0u8; 32];
1990
1991 let result = decrypt_rules(&short_data, file_id, version, hash);
1993
1994 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 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 let decrypted = decrypt_rules(&encrypted, file_id, version, hash).unwrap();
2012
2013 assert_eq!(decrypted, rules);
2015 assert!(decrypted.is_empty());
2016 }
2017
2018 #[test]
2019 fn should_handle_large_rules_content() {
2020 let rules = vec![0xAB; 1024 * 1024]; 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 let decrypted = decrypt_rules(&encrypted, file_id, version, hash).unwrap();
2029
2030 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 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 assert_eq!(hash1, hash2);
2046
2047 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 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 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 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 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 #[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 let off = parser.header().version_chain_offset as usize + 130;
2121 bytes[off] ^= 0x55;
2122 std::fs::write(&file_path, &bytes).unwrap();
2123
2124 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let version_entry_size = 108; if version_offset + version_entry_size + 7 < file_bytes.len() {
2330 file_bytes[version_offset + version_entry_size] = 99; }
2332
2333 std::fs::write(&file_path, &file_bytes).unwrap();
2334
2335 let report = verify_file(&file_path, &test_reg(author_id, &signing_key)).unwrap();
2337
2338 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 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 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 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 std::fs::write(&file_path, b"not a valid aion file").unwrap();
2378
2379 let result = verify_file(&file_path, &KeyRegistry::new());
2381
2382 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 let result = verify_file(&file_path, &KeyRegistry::new());
2393
2394 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 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 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 let mut file_bytes = std::fs::read(&file_path).unwrap();
2426
2427 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 let len = file_bytes.len();
2439 if len > 32 {
2440 file_bytes[len - 10] ^= 0xFF;
2441 }
2442
2443 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; }
2448
2449 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 let report = verify_file(&file_path, &test_reg(author_id, &signing_key)).unwrap();
2458
2459 assert!(!report.is_valid);
2461 assert!(report.structure_valid); assert!(!report.integrity_hash_valid);
2463
2464 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 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 let report = verify_file(&file_path, &test_reg(author_id, &signing_key)).unwrap();
2481
2482 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 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 let rules = show_current_rules(&file_path).unwrap();
2504
2505 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 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 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()); }
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 std::fs::write(&file_path, b"").unwrap();
2753
2754 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 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 assert_eq!(result.version.as_u64(), 1);
2796 assert!(file_path.exists());
2797
2798 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 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 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 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 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 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 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 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 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 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, };
2947
2948 let before = current_timestamp_nanos();
2949 init_file(&file_path, b"rules", &options).unwrap();
2950 let after = current_timestamp_nanos();
2951
2952 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 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 let large_rules = vec![b'X'; 1024 * 1024];
2997 init_file(&file_path, &large_rules, &options).unwrap();
2998
2999 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 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 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 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 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 #[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; }
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 let probe_key = SigningKey::generate();
3124 let opts = options(AuthorId::new(probe_id), &probe_key);
3125
3126 match preflight_registry_authz(&opts, VersionNumber(version), ®) {
3127 Err(AionError::UnauthorizedSigner { .. }) => {}
3128 _ => std::process::abort(),
3129 }
3130 }
3131
3132 #[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), ®).is_err() {
3144 std::process::abort();
3145 }
3146 }
3147
3148 #[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), ®) {
3162 Err(AionError::KeyMismatch { .. }) => {}
3163 _ => std::process::abort(),
3164 }
3165 }
3166 }
3167 }
3168
3169 mod commit_head_verify_tests {
3174 use super::*;
3175 use crate::parser::SIGNATURE_ENTRY_SIZE;
3176
3177 #[allow(clippy::arithmetic_side_effects)] 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 #[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 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, ®istry).unwrap();
3218 }
3219
3220 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, ®istry);
3232 assert!(
3233 result.is_err(),
3234 "commit_version must reject when HEAD signature is tampered"
3235 );
3236 }
3237
3238 #[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 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, ®istry).unwrap();
3272 }
3273
3274 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 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, ®istry);
3290 assert!(
3291 result.is_err(),
3292 "commit_version must reject non-head tamper at write time"
3293 );
3294
3295 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 let report = verify_file(&path, ®istry).unwrap();
3304 assert!(
3305 !report.is_valid,
3306 "verify_file must reject after a non-head signature tamper"
3307 );
3308 }
3309
3310 #[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, ®istry).unwrap();
3338 }
3339
3340 let report = verify_file(&path, ®istry).unwrap();
3341 assert!(report.is_valid, "verify_file must accept the built chain");
3342 assert_eq!(report.version_count, 200);
3343 }
3344 }
3345}