Skip to main content

citadel/
audit.rs

1use std::fs::{self, File, OpenOptions};
2use std::io::{Read, Seek, SeekFrom, Write};
3use std::path::{Path, PathBuf};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use hmac::{Hmac, Mac};
7use sha2::Sha256;
8
9use citadel_core::{
10    AUDIT_ENTRY_MAGIC, AUDIT_HEADER_SIZE, AUDIT_LOG_MAGIC, AUDIT_LOG_VERSION, KEY_SIZE, MAC_SIZE,
11};
12
13type HmacSha256 = Hmac<Sha256>;
14
15/// Audit log configuration.
16#[derive(Debug, Clone)]
17pub struct AuditConfig {
18    pub enabled: bool,
19    pub max_file_size: u64,
20    pub max_rotated_files: u32,
21}
22
23impl Default for AuditConfig {
24    fn default() -> Self {
25        Self {
26            enabled: true,
27            max_file_size: 10 * 1024 * 1024, // 10 MB
28            max_rotated_files: 3,
29        }
30    }
31}
32
33/// Audit event types.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35#[repr(u16)]
36pub enum AuditEventType {
37    DatabaseCreated = 1,
38    DatabaseOpened = 2,
39    DatabaseClosed = 3,
40    PassphraseChanged = 4,
41    KeyBackupExported = 5,
42    BackupCreated = 6,
43    CompactionPerformed = 7,
44    IntegrityCheckPerformed = 8,
45}
46
47impl AuditEventType {
48    fn from_u16(v: u16) -> Option<Self> {
49        match v {
50            1 => Some(Self::DatabaseCreated),
51            2 => Some(Self::DatabaseOpened),
52            3 => Some(Self::DatabaseClosed),
53            4 => Some(Self::PassphraseChanged),
54            5 => Some(Self::KeyBackupExported),
55            6 => Some(Self::BackupCreated),
56            7 => Some(Self::CompactionPerformed),
57            8 => Some(Self::IntegrityCheckPerformed),
58            _ => None,
59        }
60    }
61}
62
63/// A single audit log entry.
64#[derive(Debug, Clone)]
65pub struct AuditEntry {
66    pub timestamp: u64,
67    pub sequence_no: u64,
68    pub event_type: AuditEventType,
69    pub detail: Vec<u8>,
70    pub hmac: [u8; MAC_SIZE],
71}
72
73/// Result of verifying an audit log's HMAC chain.
74#[derive(Debug)]
75pub struct AuditVerifyResult {
76    pub entries_verified: u64,
77    pub chain_valid: bool,
78    pub chain_break_at: Option<u64>,
79}
80
81/// Audit log file header (64 bytes).
82struct AuditHeader {
83    magic: u32,
84    version: u32,
85    file_id: u64,
86    created_at: u64,
87    entry_count: u64,
88    last_hmac: [u8; MAC_SIZE],
89}
90
91impl AuditHeader {
92    fn serialize(&self) -> [u8; AUDIT_HEADER_SIZE] {
93        let mut buf = [0u8; AUDIT_HEADER_SIZE];
94        buf[0..4].copy_from_slice(&self.magic.to_le_bytes());
95        buf[4..8].copy_from_slice(&self.version.to_le_bytes());
96        buf[8..16].copy_from_slice(&self.file_id.to_le_bytes());
97        buf[16..24].copy_from_slice(&self.created_at.to_le_bytes());
98        buf[24..32].copy_from_slice(&self.entry_count.to_le_bytes());
99        buf[32..64].copy_from_slice(&self.last_hmac);
100        buf
101    }
102
103    fn deserialize(buf: &[u8; AUDIT_HEADER_SIZE]) -> citadel_core::Result<Self> {
104        let magic = u32::from_le_bytes(buf[0..4].try_into().unwrap());
105        if magic != AUDIT_LOG_MAGIC {
106            return Err(citadel_core::Error::InvalidMagic {
107                expected: AUDIT_LOG_MAGIC,
108                found: magic,
109            });
110        }
111        let version = u32::from_le_bytes(buf[4..8].try_into().unwrap());
112        if version != AUDIT_LOG_VERSION {
113            return Err(citadel_core::Error::UnsupportedVersion(version));
114        }
115        let mut last_hmac = [0u8; MAC_SIZE];
116        last_hmac.copy_from_slice(&buf[32..64]);
117        Ok(Self {
118            magic,
119            version,
120            file_id: u64::from_le_bytes(buf[8..16].try_into().unwrap()),
121            created_at: u64::from_le_bytes(buf[16..24].try_into().unwrap()),
122            entry_count: u64::from_le_bytes(buf[24..32].try_into().unwrap()),
123            last_hmac,
124        })
125    }
126}
127
128fn now_nanos() -> u64 {
129    SystemTime::now()
130        .duration_since(UNIX_EPOCH)
131        .unwrap_or_default()
132        .as_nanos() as u64
133}
134
135fn compute_entry_hmac(
136    audit_key: &[u8; KEY_SIZE],
137    prev_hmac: &[u8; MAC_SIZE],
138    entry_data: &[u8],
139) -> [u8; MAC_SIZE] {
140    let mut mac = HmacSha256::new_from_slice(audit_key).expect("HMAC key size is always valid");
141    mac.update(prev_hmac);
142    mac.update(entry_data);
143    let result = mac.finalize().into_bytes();
144    let mut out = [0u8; MAC_SIZE];
145    out.copy_from_slice(&result);
146    out
147}
148
149fn serialize_entry_data(
150    timestamp: u64,
151    sequence_no: u64,
152    event_type: AuditEventType,
153    detail: &[u8],
154) -> Vec<u8> {
155    let detail_len = detail.len() as u16;
156    let entry_len = 4 + 8 + 8 + 2 + 2 + detail.len() + MAC_SIZE;
157    let mut buf = Vec::with_capacity(entry_len);
158    buf.extend_from_slice(&(entry_len as u32).to_le_bytes());
159    buf.extend_from_slice(&timestamp.to_le_bytes());
160    buf.extend_from_slice(&sequence_no.to_le_bytes());
161    buf.extend_from_slice(&(event_type as u16).to_le_bytes());
162    buf.extend_from_slice(&detail_len.to_le_bytes());
163    buf.extend_from_slice(detail);
164    buf
165}
166
167/// Internal audit log writer.
168pub(crate) struct AuditLog {
169    file: File,
170    audit_key: [u8; KEY_SIZE],
171    prev_hmac: [u8; MAC_SIZE],
172    sequence_no: u64,
173    entry_count: u64,
174    config: AuditConfig,
175    path: PathBuf,
176    file_id: u64,
177}
178
179impl AuditLog {
180    pub(crate) fn audit_key(&self) -> &[u8; KEY_SIZE] {
181        &self.audit_key
182    }
183
184    /// Create a new audit log file.
185    pub(crate) fn create(
186        path: &Path,
187        file_id: u64,
188        audit_key: [u8; KEY_SIZE],
189        config: AuditConfig,
190    ) -> citadel_core::Result<Self> {
191        let header = AuditHeader {
192            magic: AUDIT_LOG_MAGIC,
193            version: AUDIT_LOG_VERSION,
194            file_id,
195            created_at: now_nanos(),
196            entry_count: 0,
197            last_hmac: [0u8; MAC_SIZE],
198        };
199
200        let mut file = OpenOptions::new()
201            .read(true)
202            .write(true)
203            .create_new(true)
204            .open(path)?;
205
206        file.write_all(&header.serialize())?;
207        file.sync_data()?;
208
209        Ok(Self {
210            file,
211            audit_key,
212            prev_hmac: [0u8; MAC_SIZE],
213            sequence_no: 0,
214            entry_count: 0,
215            config,
216            path: path.to_path_buf(),
217            file_id,
218        })
219    }
220
221    /// Open an existing audit log file, seeking to the end for appending.
222    pub(crate) fn open_existing(
223        path: &Path,
224        file_id: u64,
225        audit_key: [u8; KEY_SIZE],
226        config: AuditConfig,
227    ) -> citadel_core::Result<Self> {
228        let mut file = OpenOptions::new().read(true).write(true).open(path)?;
229
230        let mut header_buf = [0u8; AUDIT_HEADER_SIZE];
231        file.read_exact(&mut header_buf)?;
232        let header = AuditHeader::deserialize(&header_buf)?;
233
234        if header.file_id != file_id {
235            return Err(citadel_core::Error::KeyFileMismatch);
236        }
237
238        let mut prev_hmac = [0u8; MAC_SIZE];
239        let mut sequence_no = 0u64;
240        let mut entry_count = 0u64;
241
242        loop {
243            let mut magic_buf = [0u8; 4];
244            match file.read_exact(&mut magic_buf) {
245                Ok(()) => {}
246                Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
247                Err(e) => return Err(e.into()),
248            }
249            if u32::from_le_bytes(magic_buf) != AUDIT_ENTRY_MAGIC {
250                break;
251            }
252
253            let mut len_buf = [0u8; 4];
254            match file.read_exact(&mut len_buf) {
255                Ok(()) => {}
256                Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
257                Err(e) => return Err(e.into()),
258            }
259
260            let entry_len = u32::from_le_bytes(len_buf) as usize;
261            if entry_len < 56 {
262                break;
263            }
264
265            let remaining = entry_len - 4;
266            let mut entry_buf = vec![0u8; remaining];
267            match file.read_exact(&mut entry_buf) {
268                Ok(()) => {}
269                Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
270                Err(e) => return Err(e.into()),
271            }
272
273            sequence_no = u64::from_le_bytes(entry_buf[8..16].try_into().unwrap());
274            prev_hmac.copy_from_slice(&entry_buf[remaining - MAC_SIZE..]);
275            entry_count += 1;
276        }
277
278        file.seek(SeekFrom::End(0))?;
279
280        Ok(Self {
281            file,
282            audit_key,
283            prev_hmac,
284            sequence_no,
285            entry_count,
286            config,
287            path: path.to_path_buf(),
288            file_id,
289        })
290    }
291
292    /// Log an audit event.
293    pub(crate) fn log(
294        &mut self,
295        event_type: AuditEventType,
296        detail: &[u8],
297    ) -> citadel_core::Result<()> {
298        self.rotate_if_needed()?;
299
300        self.sequence_no += 1;
301        let timestamp = now_nanos();
302        let entry_data = serialize_entry_data(timestamp, self.sequence_no, event_type, detail);
303        let hmac = compute_entry_hmac(&self.audit_key, &self.prev_hmac, &entry_data);
304
305        self.file.write_all(&AUDIT_ENTRY_MAGIC.to_le_bytes())?;
306        self.file.write_all(&entry_data)?;
307        self.file.write_all(&hmac)?;
308        self.file.sync_data()?;
309
310        self.prev_hmac = hmac;
311        self.entry_count += 1;
312
313        self.update_header()?;
314
315        Ok(())
316    }
317
318    fn update_header(&mut self) -> citadel_core::Result<()> {
319        let pos = self.file.stream_position()?;
320
321        self.file.seek(SeekFrom::Start(24))?;
322        self.file.write_all(&self.entry_count.to_le_bytes())?;
323        self.file.write_all(&self.prev_hmac)?;
324        self.file.seek(SeekFrom::Start(pos))?;
325        Ok(())
326    }
327
328    fn rotate_if_needed(&mut self) -> citadel_core::Result<()> {
329        let file_size = self.file.seek(SeekFrom::End(0))?;
330        if file_size < self.config.max_file_size {
331            return Ok(());
332        }
333
334        self.file.sync_data()?;
335
336        // Shift rotated files: .N → delete, .N-1 → .N, ..., current → .1
337        for i in (1..=self.config.max_rotated_files).rev() {
338            let src = if i == 1 {
339                self.path.clone()
340            } else {
341                rotated_path(&self.path, i - 1)
342            };
343            let dst = rotated_path(&self.path, i);
344
345            if i == self.config.max_rotated_files {
346                let _ = fs::remove_file(&dst);
347            }
348            if src.exists() {
349                let _ = fs::rename(&src, &dst);
350            }
351        }
352
353        let header = AuditHeader {
354            magic: AUDIT_LOG_MAGIC,
355            version: AUDIT_LOG_VERSION,
356            file_id: self.file_id,
357            created_at: now_nanos(),
358            entry_count: 0,
359            last_hmac: self.prev_hmac,
360        };
361
362        let mut file = OpenOptions::new()
363            .read(true)
364            .write(true)
365            .create_new(true)
366            .open(&self.path)?;
367
368        file.write_all(&header.serialize())?;
369        file.sync_data()?;
370
371        self.file = file;
372        self.entry_count = 0;
373
374        Ok(())
375    }
376}
377
378fn rotated_path(base: &Path, index: u32) -> PathBuf {
379    let mut name = base.as_os_str().to_os_string();
380    name.push(format!(".{index}"));
381    PathBuf::from(name)
382}
383
384/// Resolve the audit log path for a database file.
385pub(crate) fn resolve_audit_path(data_path: &Path) -> PathBuf {
386    let mut name = data_path.as_os_str().to_os_string();
387    name.push(".citadel-audit");
388    PathBuf::from(name)
389}
390
391/// Read all entries from an audit log file (no key needed).
392pub fn read_audit_log(path: &Path) -> citadel_core::Result<Vec<AuditEntry>> {
393    let mut file = File::open(path)?;
394
395    let mut header_buf = [0u8; AUDIT_HEADER_SIZE];
396    file.read_exact(&mut header_buf)?;
397    let _header = AuditHeader::deserialize(&header_buf)?;
398
399    let mut entries = Vec::new();
400
401    loop {
402        let mut magic_buf = [0u8; 4];
403        match file.read_exact(&mut magic_buf) {
404            Ok(()) => {}
405            Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
406            Err(e) => return Err(e.into()),
407        }
408        if u32::from_le_bytes(magic_buf) != AUDIT_ENTRY_MAGIC {
409            break;
410        }
411
412        let mut len_buf = [0u8; 4];
413        match file.read_exact(&mut len_buf) {
414            Ok(()) => {}
415            Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
416            Err(e) => return Err(e.into()),
417        }
418
419        let entry_len = u32::from_le_bytes(len_buf) as usize;
420        if entry_len < 56 {
421            break;
422        }
423
424        let remaining = entry_len - 4;
425        let mut entry_buf = vec![0u8; remaining];
426        match file.read_exact(&mut entry_buf) {
427            Ok(()) => {}
428            Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
429            Err(e) => return Err(e.into()),
430        }
431
432        let timestamp = u64::from_le_bytes(entry_buf[0..8].try_into().unwrap());
433        let sequence_no = u64::from_le_bytes(entry_buf[8..16].try_into().unwrap());
434        let event_type_raw = u16::from_le_bytes(entry_buf[16..18].try_into().unwrap());
435        let detail_len = u16::from_le_bytes(entry_buf[18..20].try_into().unwrap()) as usize;
436
437        let event_type = match AuditEventType::from_u16(event_type_raw) {
438            Some(et) => et,
439            None => break,
440        };
441
442        if 20 + detail_len + MAC_SIZE != remaining {
443            break;
444        }
445
446        let detail = entry_buf[20..20 + detail_len].to_vec();
447        let mut hmac = [0u8; MAC_SIZE];
448        hmac.copy_from_slice(&entry_buf[remaining - MAC_SIZE..]);
449
450        entries.push(AuditEntry {
451            timestamp,
452            sequence_no,
453            event_type,
454            detail,
455            hmac,
456        });
457    }
458
459    Ok(entries)
460}
461
462/// Verify the HMAC chain of an audit log file.
463pub fn verify_audit_log(
464    path: &Path,
465    audit_key: &[u8; KEY_SIZE],
466) -> citadel_core::Result<AuditVerifyResult> {
467    let mut file = File::open(path)?;
468
469    let mut header_buf = [0u8; AUDIT_HEADER_SIZE];
470    file.read_exact(&mut header_buf)?;
471    let _header = AuditHeader::deserialize(&header_buf)?;
472
473    let mut prev_hmac = [0u8; MAC_SIZE];
474    let mut entries_verified = 0u64;
475
476    loop {
477        let mut magic_buf = [0u8; 4];
478        match file.read_exact(&mut magic_buf) {
479            Ok(()) => {}
480            Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
481            Err(e) => return Err(e.into()),
482        }
483        if u32::from_le_bytes(magic_buf) != AUDIT_ENTRY_MAGIC {
484            break;
485        }
486
487        let mut len_buf = [0u8; 4];
488        match file.read_exact(&mut len_buf) {
489            Ok(()) => {}
490            Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
491            Err(e) => return Err(e.into()),
492        }
493
494        let entry_len = u32::from_le_bytes(len_buf) as usize;
495        if entry_len < 56 {
496            break;
497        }
498
499        let remaining = entry_len - 4;
500        let mut entry_buf = vec![0u8; remaining];
501        match file.read_exact(&mut entry_buf) {
502            Ok(()) => {}
503            Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
504            Err(e) => return Err(e.into()),
505        }
506
507        let sequence_no = u64::from_le_bytes(entry_buf[8..16].try_into().unwrap());
508
509        let data_len = remaining - MAC_SIZE;
510        let mut entry_data = Vec::with_capacity(4 + data_len);
511        entry_data.extend_from_slice(&len_buf);
512        entry_data.extend_from_slice(&entry_buf[..data_len]);
513
514        let stored_hmac = &entry_buf[remaining - MAC_SIZE..];
515        let expected_hmac = compute_entry_hmac(audit_key, &prev_hmac, &entry_data);
516
517        if stored_hmac != expected_hmac {
518            return Ok(AuditVerifyResult {
519                entries_verified,
520                chain_valid: false,
521                chain_break_at: Some(sequence_no),
522            });
523        }
524
525        prev_hmac.copy_from_slice(stored_hmac);
526        entries_verified += 1;
527    }
528
529    Ok(AuditVerifyResult {
530        entries_verified,
531        chain_valid: true,
532        chain_break_at: None,
533    })
534}
535
536/// Scan a corrupted audit log, recovering entries past damaged regions
537/// by scanning for per-entry sentinel markers.
538pub fn scan_corrupted_audit_log(path: &Path) -> citadel_core::Result<ScanResult> {
539    let data = fs::read(path)?;
540
541    if data.len() < AUDIT_HEADER_SIZE {
542        return Err(citadel_core::Error::Io(std::io::Error::new(
543            std::io::ErrorKind::InvalidData,
544            "audit file too small for header",
545        )));
546    }
547    let header_buf: [u8; AUDIT_HEADER_SIZE] = data[..AUDIT_HEADER_SIZE].try_into().unwrap();
548    let _header = AuditHeader::deserialize(&header_buf)?;
549
550    let magic_bytes = AUDIT_ENTRY_MAGIC.to_le_bytes();
551    let mut entries = Vec::new();
552    let mut corruption_offsets = Vec::new();
553    let mut offset = AUDIT_HEADER_SIZE;
554    let mut in_corruption = false;
555
556    while offset + 4 <= data.len() {
557        if data[offset..offset + 4] != magic_bytes {
558            if !in_corruption {
559                corruption_offsets.push(offset as u64);
560                in_corruption = true;
561            }
562            offset += 1;
563            continue;
564        }
565
566        if offset + 8 > data.len() {
567            break;
568        }
569        let entry_len =
570            u32::from_le_bytes(data[offset + 4..offset + 8].try_into().unwrap()) as usize;
571        if entry_len < 56 || offset + 4 + entry_len > data.len() {
572            if !in_corruption {
573                corruption_offsets.push(offset as u64);
574                in_corruption = true;
575            }
576            offset += 1;
577            continue;
578        }
579
580        let entry_start = offset + 8;
581        let remaining = entry_len - 4;
582
583        let event_type_raw =
584            u16::from_le_bytes(data[entry_start + 16..entry_start + 18].try_into().unwrap());
585        let detail_len =
586            u16::from_le_bytes(data[entry_start + 18..entry_start + 20].try_into().unwrap())
587                as usize;
588
589        if AuditEventType::from_u16(event_type_raw).is_none()
590            || 20 + detail_len + MAC_SIZE != remaining
591        {
592            if !in_corruption {
593                corruption_offsets.push(offset as u64);
594                in_corruption = true;
595            }
596            offset += 1;
597            continue;
598        }
599
600        let timestamp = u64::from_le_bytes(data[entry_start..entry_start + 8].try_into().unwrap());
601        let sequence_no =
602            u64::from_le_bytes(data[entry_start + 8..entry_start + 16].try_into().unwrap());
603        let event_type = AuditEventType::from_u16(event_type_raw).unwrap();
604        let detail = data[entry_start + 20..entry_start + 20 + detail_len].to_vec();
605        let mut hmac = [0u8; MAC_SIZE];
606        hmac.copy_from_slice(&data[entry_start + remaining - MAC_SIZE..entry_start + remaining]);
607
608        entries.push(AuditEntry {
609            timestamp,
610            sequence_no,
611            event_type,
612            detail,
613            hmac,
614        });
615
616        in_corruption = false;
617        offset = offset + 4 + entry_len;
618    }
619
620    Ok(ScanResult {
621        entries,
622        corruption_offsets,
623    })
624}
625
626#[derive(Debug)]
627pub struct ScanResult {
628    pub entries: Vec<AuditEntry>,
629    pub corruption_offsets: Vec<u64>,
630}
631
632#[cfg(test)]
633mod tests {
634    use super::*;
635
636    #[test]
637    fn header_serialize_deserialize_roundtrip() {
638        let header = AuditHeader {
639            magic: AUDIT_LOG_MAGIC,
640            version: AUDIT_LOG_VERSION,
641            file_id: 0xDEAD_BEEF,
642            created_at: 1234567890,
643            entry_count: 42,
644            last_hmac: [0xAB; MAC_SIZE],
645        };
646        let buf = header.serialize();
647        let h2 = AuditHeader::deserialize(&buf).unwrap();
648        assert_eq!(h2.magic, AUDIT_LOG_MAGIC);
649        assert_eq!(h2.version, AUDIT_LOG_VERSION);
650        assert_eq!(h2.file_id, 0xDEAD_BEEF);
651        assert_eq!(h2.created_at, 1234567890);
652        assert_eq!(h2.entry_count, 42);
653        assert_eq!(h2.last_hmac, [0xAB; MAC_SIZE]);
654    }
655
656    #[test]
657    fn header_invalid_magic_rejected() {
658        let mut buf = [0u8; AUDIT_HEADER_SIZE];
659        buf[0..4].copy_from_slice(&0xDEADBEEFu32.to_le_bytes());
660        let result = AuditHeader::deserialize(&buf);
661        assert!(matches!(
662            result,
663            Err(citadel_core::Error::InvalidMagic { .. })
664        ));
665    }
666
667    #[test]
668    fn entry_serialization_roundtrip() {
669        let data = serialize_entry_data(999, 1, AuditEventType::DatabaseCreated, &[0x01, 0x02]);
670        assert_eq!(data.len(), 4 + 8 + 8 + 2 + 2 + 2);
671        let entry_len = u32::from_le_bytes(data[0..4].try_into().unwrap()) as usize;
672        assert_eq!(entry_len, 26 + MAC_SIZE);
673    }
674
675    #[test]
676    fn hmac_chain_deterministic() {
677        let key = [0x42u8; KEY_SIZE];
678        let prev = [0u8; MAC_SIZE];
679        let data = b"test data";
680        let h1 = compute_entry_hmac(&key, &prev, data);
681        let h2 = compute_entry_hmac(&key, &prev, data);
682        assert_eq!(h1, h2);
683    }
684
685    #[test]
686    fn hmac_chain_changes_with_prev() {
687        let key = [0x42u8; KEY_SIZE];
688        let prev1 = [0u8; MAC_SIZE];
689        let prev2 = [0x01u8; MAC_SIZE];
690        let data = b"test data";
691        let h1 = compute_entry_hmac(&key, &prev1, data);
692        let h2 = compute_entry_hmac(&key, &prev2, data);
693        assert_ne!(h1, h2);
694    }
695
696    #[test]
697    fn event_type_roundtrip() {
698        for code in 1..=8u16 {
699            let et = AuditEventType::from_u16(code).unwrap();
700            assert_eq!(et as u16, code);
701        }
702        assert!(AuditEventType::from_u16(0).is_none());
703        assert!(AuditEventType::from_u16(9).is_none());
704    }
705
706    #[test]
707    fn create_and_log_entry() {
708        let dir = tempfile::tempdir().unwrap();
709        let path = dir.path().join("test.citadel-audit");
710        let key = [0x42u8; KEY_SIZE];
711
712        let mut log = AuditLog::create(&path, 123, key, AuditConfig::default()).unwrap();
713        log.log(AuditEventType::DatabaseCreated, &[0x00, 0x00])
714            .unwrap();
715        log.log(AuditEventType::DatabaseOpened, &[]).unwrap();
716        drop(log);
717
718        let entries = read_audit_log(&path).unwrap();
719        assert_eq!(entries.len(), 2);
720        assert_eq!(entries[0].event_type, AuditEventType::DatabaseCreated);
721        assert_eq!(entries[0].sequence_no, 1);
722        assert_eq!(entries[0].detail, vec![0x00, 0x00]);
723        assert_eq!(entries[1].event_type, AuditEventType::DatabaseOpened);
724        assert_eq!(entries[1].sequence_no, 2);
725    }
726
727    #[test]
728    fn verify_valid_chain() {
729        let dir = tempfile::tempdir().unwrap();
730        let path = dir.path().join("test.citadel-audit");
731        let key = [0x42u8; KEY_SIZE];
732
733        let mut log = AuditLog::create(&path, 123, key, AuditConfig::default()).unwrap();
734        log.log(AuditEventType::DatabaseCreated, &[]).unwrap();
735        log.log(AuditEventType::DatabaseOpened, &[]).unwrap();
736        log.log(AuditEventType::PassphraseChanged, &[]).unwrap();
737        drop(log);
738
739        let result = verify_audit_log(&path, &key).unwrap();
740        assert!(result.chain_valid);
741        assert_eq!(result.entries_verified, 3);
742        assert!(result.chain_break_at.is_none());
743    }
744
745    #[test]
746    fn verify_tamper_detected() {
747        let dir = tempfile::tempdir().unwrap();
748        let path = dir.path().join("test.citadel-audit");
749        let key = [0x42u8; KEY_SIZE];
750
751        let mut log = AuditLog::create(&path, 123, key, AuditConfig::default()).unwrap();
752        log.log(AuditEventType::DatabaseCreated, &[]).unwrap();
753        log.log(AuditEventType::DatabaseOpened, &[]).unwrap();
754        drop(log);
755
756        let mut data = fs::read(&path).unwrap();
757        data[AUDIT_HEADER_SIZE + 4 + 5] ^= 0x01;
758        fs::write(&path, &data).unwrap();
759
760        let result = verify_audit_log(&path, &key).unwrap();
761        assert!(!result.chain_valid);
762        assert_eq!(result.chain_break_at, Some(1));
763    }
764
765    #[test]
766    fn verify_wrong_key_fails() {
767        let dir = tempfile::tempdir().unwrap();
768        let path = dir.path().join("test.citadel-audit");
769        let key = [0x42u8; KEY_SIZE];
770
771        let mut log = AuditLog::create(&path, 123, key, AuditConfig::default()).unwrap();
772        log.log(AuditEventType::DatabaseCreated, &[]).unwrap();
773        drop(log);
774
775        let wrong_key = [0xFF; KEY_SIZE];
776        let result = verify_audit_log(&path, &wrong_key).unwrap();
777        assert!(!result.chain_valid);
778    }
779
780    #[test]
781    fn open_existing_appends() {
782        let dir = tempfile::tempdir().unwrap();
783        let path = dir.path().join("test.citadel-audit");
784        let key = [0x42u8; KEY_SIZE];
785
786        let mut log = AuditLog::create(&path, 123, key, AuditConfig::default()).unwrap();
787        log.log(AuditEventType::DatabaseCreated, &[]).unwrap();
788        drop(log);
789
790        let mut log = AuditLog::open_existing(&path, 123, key, AuditConfig::default()).unwrap();
791        log.log(AuditEventType::DatabaseOpened, &[]).unwrap();
792        drop(log);
793
794        let entries = read_audit_log(&path).unwrap();
795        assert_eq!(entries.len(), 2);
796        assert_eq!(entries[0].sequence_no, 1);
797        assert_eq!(entries[1].sequence_no, 2);
798
799        let result = verify_audit_log(&path, &key).unwrap();
800        assert!(result.chain_valid);
801        assert_eq!(result.entries_verified, 2);
802    }
803
804    #[test]
805    fn rotation_triggers() {
806        let dir = tempfile::tempdir().unwrap();
807        let path = dir.path().join("test.citadel-audit");
808        let key = [0x42u8; KEY_SIZE];
809
810        let config = AuditConfig {
811            enabled: true,
812            max_file_size: 200,
813            max_rotated_files: 2,
814        };
815
816        let mut log = AuditLog::create(&path, 123, key, config).unwrap();
817        for _ in 0..10 {
818            log.log(AuditEventType::DatabaseOpened, &[0u8; 50]).unwrap();
819        }
820        drop(log);
821
822        let rotated = rotated_path(&path, 1);
823        assert!(rotated.exists());
824        assert!(path.exists());
825    }
826
827    #[test]
828    fn file_format_magic() {
829        let dir = tempfile::tempdir().unwrap();
830        let path = dir.path().join("test.citadel-audit");
831        let key = [0x42u8; KEY_SIZE];
832
833        let log = AuditLog::create(&path, 123, key, AuditConfig::default()).unwrap();
834        drop(log);
835
836        let data = fs::read(&path).unwrap();
837        let magic = u32::from_le_bytes(data[0..4].try_into().unwrap());
838        assert_eq!(magic, 0x4155_4454);
839    }
840}