crates/signaltty-server/src/audit.rs

//! Bounded state-event journal, durable sequence reservations and replay evidence.

use crate::store::StoredEvent;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::BTreeMap;
use std::fs::{File, OpenOptions};
use std::io::{self, BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::sync::Mutex;

pub const CAP_BYTES: u64 = 8 * 1024 * 1024;
pub const REPLAY_CAP: usize = 4096;

use std::os::unix::fs::OpenOptionsExt;

const LEASE: u64 = 4096;

pub struct EventSequence {
    path: PathBuf,
    issued: u64,
    reserved: u64,
}

impl EventSequence {
    pub fn open(state_dir: &Path, audit_max: u64) -> io::Result<Self> {
        std::fs::create_dir_all(state_dir)?;
        let path = state_dir.join("event-sequence.json");
        let issued = match std::fs::read(&path) {
            Ok(bytes) => serde_json::from_slice::<u64>(&bytes).map_err(io::Error::other)?,
            Err(e) if e.kind() == io::ErrorKind::NotFound => audit_max,
            Err(e) => return Err(e),
        };
        if issued < audit_max {
            return Err(io::Error::other("sequence reservation is behind the audit"));
        }
        let mut sequence = Self {
            path,
            issued,
            reserved: issued,
        };
        sequence.reserve()?;
        Ok(sequence)
    }

    pub fn head(&self) -> u64 {
        self.issued
    }

    pub fn allocate(&mut self) -> io::Result<u64> {
        if self.issued == self.reserved {
            self.reserve()?;
        }
        self.issued = self
            .issued
            .checked_add(1)
            .ok_or_else(|| io::Error::other("event sequence exhausted"))?;
        Ok(self.issued)
    }

    fn reserve(&mut self) -> io::Result<()> {
        let next = self
            .reserved
            .checked_add(LEASE)
            .ok_or_else(|| io::Error::other("event sequence exhausted"))?;
        durable_write(
            &self.path,
            &serde_json::to_vec(&next).map_err(io::Error::other)?,
        )?;
        self.reserved = next;
        Ok(())
    }
}

pub fn durable_write(path: &Path, bytes: &[u8]) -> io::Result<()> {
    let tmp = path.with_extension("tmp");
    let mut file = OpenOptions::new()
        .create(true)
        .truncate(true)
        .write(true)
        .mode(0o600)
        .open(&tmp)?;
    file.write_all(bytes)?;
    file.sync_all()?;
    std::fs::rename(&tmp, path)?;
    File::open(
        path.parent()
            .ok_or_else(|| io::Error::other("missing parent"))?,
    )?
    .sync_all()
}

#[derive(Clone, Copy, Default, Serialize, Deserialize)]
struct Retention {
    retained_after: u64,
    predecessor: bool,
    #[serde(default)]
    current_bytes: u64,
    #[serde(default)]
    predecessor_bytes: u64,
    #[serde(default)]
    known_complete: bool,
}

pub struct AuditLog {
    path: PathBuf,
    cap_bytes: u64,
    file: Mutex<File>,
    len: Mutex<u64>,
    retention: Mutex<Retention>,
    available: bool,
}

pub struct History {
    pub events: Vec<StoredEvent>,
    pub retained_after: u64,
    pub available: bool,
}

impl AuditLog {
    pub fn open(state_dir: &Path) -> io::Result<Self> {
        Self::open_with_cap(state_dir, CAP_BYTES)
    }

    fn open_with_cap(state_dir: &Path, cap_bytes: u64) -> io::Result<Self> {
        std::fs::create_dir_all(state_dir)?;
        let path = state_dir.join("audit.jsonl");
        let retention_path = state_dir.join("audit-retention.json");
        let mut available;
        let retention = match std::fs::read(&retention_path) {
            Ok(bytes) => match serde_json::from_slice::<Retention>(&bytes) {
                Ok(retention) => {
                    available = retention.known_complete && lengths_match(&path, retention);
                    retention
                }
                Err(_) => {
                    available = false;
                    Retention::default()
                }
            },
            Err(e) if e.kind() == io::ErrorKind::NotFound => {
                let (events, valid) = read_files(&[path.with_extension("jsonl.1"), path.clone()]);
                available =
                    valid && events.is_empty() && !state_dir.join("event-sequence.json").exists();
                let retention = Retention {
                    retained_after: events.iter().map(|e| e.seq).max().unwrap_or(0),
                    predecessor: path.with_extension("jsonl.1").exists(),
                    current_bytes: std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0),
                    predecessor_bytes: std::fs::metadata(path.with_extension("jsonl.1"))
                        .map(|m| m.len())
                        .unwrap_or(0),
                    known_complete: available,
                };
                durable_write(
                    &retention_path,
                    &serde_json::to_vec(&retention).map_err(io::Error::other)?,
                )?;
                retention
            }
            Err(e) => return Err(e),
        };
        let mut file = OpenOptions::new()
            .create(true)
            .append(true)
            .mode(0o600)
            .open(&path)?;
        // Separate a torn tail from all future records; corruption remains explicit.
        let bytes = std::fs::read(&path)?;
        if !bytes.is_empty() && bytes.last() != Some(&b'\n') {
            file.write_all(b"\n")?;
            file.sync_all()?;
            available = false;
        }
        let len = file.metadata()?.len();
        Ok(Self {
            path,
            cap_bytes,
            file: Mutex::new(file),
            len: Mutex::new(len),
            retention: Mutex::new(retention),
            available,
        })
    }

    fn rotated_path(&self) -> PathBuf {
        self.path.with_extension("jsonl.1")
    }

    pub fn history(&self) -> History {
        let (events, valid) = read_files(&[self.rotated_path(), self.path.clone()]);
        let retention = *self.retention.lock().unwrap();
        History {
            events,
            retained_after: retention.retained_after,
            available: self.available
                && valid
                && retention.known_complete
                && lengths_match(&self.path, retention),
        }
    }

    pub fn max_seq(&self) -> u64 {
        self.history()
            .events
            .iter()
            .map(|e| e.seq)
            .max()
            .unwrap_or(0)
    }

    pub fn append(&self, seq: u64, name: &str, payload: &Value) -> io::Result<()> {
        {
            let mut retention = self.retention.lock().unwrap();
            if !lengths_match(&self.path, *retention) || !self.available {
                retention.known_complete = false;
            }
        }
        self.maybe_rotate()?;
        let mut retention = self.retention.lock().unwrap();
        if !lengths_match(&self.path, *retention) || !self.available {
            retention.known_complete = false;
        }
        // An unlinked open descriptor is not a replayable journal.
        std::fs::metadata(&self.path)?;
        let line = serde_json::to_vec(&json!({"seq":seq, "event":name, "payload":payload, "at":chrono::Utc::now().to_rfc3339()})).map_err(io::Error::other)?;
        let mut file = self.file.lock().unwrap();
        file.write_all(&line)?;
        file.write_all(b"\n")?;
        file.sync_data()?;
        *self.len.lock().unwrap() = file.metadata()?.len();
        retention.current_bytes = *self.len.lock().unwrap();
        durable_write(
            &self.path.with_file_name("audit-retention.json"),
            &serde_json::to_vec(&*retention).map_err(io::Error::other)?,
        )?;
        Ok(())
    }

    fn maybe_rotate(&self) -> io::Result<()> {
        if *self.len.lock().unwrap() < self.cap_bytes {
            return Ok(());
        }
        let rotated = self.rotated_path();
        let (discarded, valid) = read_files(std::slice::from_ref(&rotated));
        if !valid {
            return Err(io::Error::other("cannot prove discarded audit history"));
        }
        let mut retention = self.retention.lock().unwrap();
        retention.retained_after = retention
            .retained_after
            .max(discarded.iter().map(|e| e.seq).max().unwrap_or(0));
        retention.predecessor = true;
        retention.predecessor_bytes = *self.len.lock().unwrap();
        retention.current_bytes = 0;
        let retention_path = self.path.with_file_name("audit-retention.json");
        durable_write(
            &retention_path,
            &serde_json::to_vec(&*retention).map_err(io::Error::other)?,
        )?;
        // rename atomically replaces the predecessor; metadata already excludes it.
        std::fs::rename(&self.path, &rotated)?;
        let fresh = OpenOptions::new()
            .create(true)
            .append(true)
            .mode(0o600)
            .open(&self.path)?;
        fresh.sync_all()?;
        File::open(self.path.parent().unwrap())?.sync_all()?;
        *self.file.lock().unwrap() = fresh;
        *self.len.lock().unwrap() = 0;
        Ok(())
    }

    pub fn read_since(&self, from_seq: u64, limit: usize) -> Vec<StoredEvent> {
        self.history()
            .events
            .into_iter()
            .filter(|e| e.seq > from_seq)
            .take(limit)
            .collect()
    }
}

fn lengths_match(path: &Path, retention: Retention) -> bool {
    let current = std::fs::metadata(path).ok().map(|m| m.len());
    let previous = std::fs::metadata(path.with_extension("jsonl.1"))
        .ok()
        .map(|m| m.len());
    current == Some(retention.current_bytes)
        && if retention.predecessor {
            previous == Some(retention.predecessor_bytes)
        } else {
            previous.is_none()
        }
}

fn read_files(paths: &[PathBuf]) -> (Vec<StoredEvent>, bool) {
    let mut events = Vec::new();
    let mut valid = true;
    for path in paths {
        let file = match File::open(path) {
            Ok(file) => file,
            Err(e) if e.kind() == io::ErrorKind::NotFound => continue,
            Err(_) => {
                valid = false;
                continue;
            }
        };
        for line in BufReader::new(file).lines() {
            let parsed = line
                .ok()
                .and_then(|line| serde_json::from_str::<Value>(&line).ok());
            if let Some(value) = parsed {
                if let (Some(seq), Some(name), Some(payload)) = (
                    value["seq"].as_u64(),
                    value["event"].as_str(),
                    value.get("payload"),
                ) {
                    if events
                        .last()
                        .map(|e: &StoredEvent| seq <= e.seq)
                        .unwrap_or(false)
                    {
                        valid = false;
                    }
                    events.push(StoredEvent {
                        seq,
                        name: name.into(),
                        payload: payload.clone(),
                    });
                    continue;
                }
            }
            valid = false;
        }
    }
    (events, valid)
}

/// Merge all bounded retained records. Apply filters before the replay cap.
pub fn merge_replay(backfill: Vec<StoredEvent>, ring: Vec<StoredEvent>) -> Vec<StoredEvent> {
    let mut merged = BTreeMap::new();
    for ev in backfill.into_iter().chain(ring) {
        merged.insert(ev.seq, ev);
    }
    merged.into_values().collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    fn dir(tag: &str) -> PathBuf {
        let d = std::env::temp_dir().join(format!(
            "signaltty-audit-{}-{}-{}",
            tag,
            std::process::id(),
            signaltty_core::ids::new_pane_id()
        ));
        std::fs::create_dir_all(&d).unwrap();
        d
    }

    #[test]
    fn missing_all_history_with_a_surviving_reservation_is_not_fresh() {
        let d = dir("lost-all");
        let log = AuditLog::open(&d).unwrap();
        let mut sequence = EventSequence::open(&d, 0).unwrap();
        let seq = sequence.allocate().unwrap();
        log.append(seq, "event", &json!({})).unwrap();
        drop(log);
        for path in ["audit.jsonl", "audit-retention.json"] {
            std::fs::remove_file(d.join(path)).unwrap();
        }
        let reopened = AuditLog::open(&d).unwrap();
        assert!(!reopened.history().available);
        reopened
            .append(sequence.allocate().unwrap(), "new", &json!({}))
            .unwrap();
        drop(reopened);
        assert!(!AuditLog::open(&d).unwrap().history().available);
        std::fs::remove_dir_all(d).unwrap();
    }

    #[test]
    fn rotation_preserves_missing_predecessor_uncertainty() {
        let d = dir("rotation-loss");
        let log = AuditLog::open_with_cap(&d, 256).unwrap();
        let mut seq = 0;
        while !log.rotated_path().exists() || *log.len.lock().unwrap() < 256 {
            seq += 1;
            log.append(seq, "event", &json!({})).unwrap();
            assert!(seq < 30);
        }
        std::fs::remove_file(log.rotated_path()).unwrap();
        log.append(seq + 1, "event", &json!({})).unwrap();
        assert!(
            !log.history().available,
            "rotation must not erase loss evidence"
        );
        drop(log);
        assert!(
            !AuditLog::open_with_cap(&d, 256)
                .unwrap()
                .history()
                .available
        );
        std::fs::remove_dir_all(d).unwrap();
    }

    #[test]
    fn removed_or_validly_truncated_generations_cannot_prove_coverage() {
        let d = dir("missing");
        let log = AuditLog::open_with_cap(&d, 256).unwrap();
        for seq in 1..=8 {
            log.append(seq, "event", &json!({})).unwrap();
        }
        assert!(log.history().available);
        std::fs::remove_file(log.rotated_path()).unwrap();
        assert!(!log.history().available);
        drop(log);
        assert!(
            !AuditLog::open_with_cap(&d, 256)
                .unwrap()
                .history()
                .available
        );
        std::fs::remove_dir_all(d).unwrap();
        let d = dir("truncated");
        let log = AuditLog::open(&d).unwrap();
        log.append(1, "a", &json!({})).unwrap();
        let first = std::fs::read(&log.path).unwrap();
        log.append(2, "b", &json!({})).unwrap();
        std::fs::write(&log.path, first).unwrap(); // complete JSON lines, no malformed tail
        assert!(!log.history().available);
        drop(log);
        let reopened = AuditLog::open(&d).unwrap();
        assert!(!reopened.history().available);
        reopened.append(3, "c", &json!({})).unwrap();
        drop(reopened);
        assert!(
            !AuditLog::open(&d).unwrap().history().available,
            "uncertainty survives append and restart"
        );
        std::fs::remove_dir_all(d).unwrap();
    }

    #[test]
    fn legacy_journal_retains_uncertainty_after_migration() {
        let d = dir("legacy");
        std::fs::write(
            d.join("audit.jsonl"),
            b"{\"seq\":5,\"event\":\"old\",\"payload\":{}}\n",
        )
        .unwrap();
        let log = AuditLog::open(&d).unwrap();
        assert_eq!(log.max_seq(), 5);
        assert!(!log.history().available);
        log.append(6, "new", &json!({})).unwrap();
        drop(log);
        assert!(!AuditLog::open(&d).unwrap().history().available);
        std::fs::remove_dir_all(d).unwrap();
    }

    #[test]
    fn reservation_survives_crash_without_any_audit_event() {
        let d = dir("sequence");
        let mut sequence = EventSequence::open(&d, 0).unwrap();
        let first = sequence.allocate().unwrap();
        let transient = sequence.allocate().unwrap();
        drop(sequence);
        let mut restarted = EventSequence::open(&d, 0).unwrap();
        assert!(restarted.allocate().unwrap() > transient);
        assert!(transient > first);
        std::fs::write(d.join("event-sequence.json"), b"broken").unwrap();
        assert!(EventSequence::open(&d, 0).is_err());
        std::fs::remove_dir_all(d).unwrap();
    }

    #[test]
    fn reservation_failure_does_not_issue_an_unreserved_number() {
        let d = dir("sequence-failure");
        let mut sequence = EventSequence::open(&d, 0).unwrap();
        for _ in 0..LEASE {
            sequence.allocate().unwrap();
        }
        std::fs::remove_file(d.join("event-sequence.json")).unwrap();
        std::fs::create_dir(d.join("event-sequence.json")).unwrap();
        assert!(sequence.allocate().is_err());
        assert_eq!(sequence.head(), LEASE);
        std::fs::remove_dir_all(d).unwrap();
    }

    #[test]
    fn torn_tail_is_separated_and_coverage_is_unavailable() {
        let d = dir("unterminated");
        let log = AuditLog::open(&d).unwrap();
        log.append(1, "a", &json!({})).unwrap();
        drop(log);
        OpenOptions::new()
            .append(true)
            .open(d.join("audit.jsonl"))
            .unwrap()
            .write_all(b"{torn")
            .unwrap();
        let reopened = AuditLog::open(&d).unwrap();
        reopened.append(2, "b", &json!({})).unwrap();
        assert_eq!(
            reopened
                .read_since(0, 100)
                .iter()
                .map(|e| e.seq)
                .collect::<Vec<_>>(),
            vec![1, 2]
        );
        assert!(!reopened.history().available);
        std::fs::remove_dir_all(d).unwrap();
    }

    #[test]
    fn append_read_and_rotation_bound_disk() {
        let d = dir("rotate");
        let log = AuditLog::open_with_cap(&d, 256).unwrap();
        for i in 1..=20u64 {
            log.append(i, "test.event", &json!({"i": i})).unwrap();
        }
        // Rotation happened: current + one predecessor, bounded total.
        let total: u64 = [log.path.clone(), log.rotated_path()]
            .iter()
            .filter_map(|p| std::fs::metadata(p).ok())
            .map(|m| m.len())
            .sum();
        assert!(total < 256 * 3, "bounded: {total}");
        // Rotation drops old generations by design: survivors replay once,
        // ordered, with the newest seqs intact.
        let events = log.read_since(0, 100);
        let seqs: Vec<u64> = events.iter().map(|e| e.seq).collect();
        assert!(!seqs.is_empty());
        assert_eq!(
            seqs,
            {
                let mut s = seqs.clone();
                s.sort_unstable();
                s.dedup();
                s
            },
            "ordered, no dupes"
        );
        assert_eq!(*seqs.last().unwrap(), 20);
        assert!(log.read_since(20, 100).is_empty());
        let floor = log.history().retained_after;
        assert!(floor > 0);
        drop(log);
        let reopened = AuditLog::open_with_cap(&d, 256).unwrap();
        assert!(reopened.history().available);
        assert_eq!(reopened.history().retained_after, floor);
        std::fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn torn_lines_never_fail_replay() {
        let d = dir("torn");
        let log = AuditLog::open(&d).unwrap();
        log.append(1, "a", &json!({})).unwrap();
        // Torn tail line between two good appends.
        std::fs::write(log.rotated_path(), "not json\n").ok();
        use std::io::Write;
        std::fs::OpenOptions::new()
            .append(true)
            .open(&log.path)
            .unwrap()
            .write_all(b"{torn\n")
            .unwrap();
        log.append(2, "b", &json!({})).unwrap();
        let events = log.read_since(0, 100);
        // Torn lines are skipped everywhere; good events replay in order.
        let seqs: Vec<u64> = events.iter().map(|e| e.seq).collect();
        assert_eq!(seqs, vec![1, 2]);
        std::fs::remove_dir_all(&d).ok();
    }

    #[test]
    fn merge_dedupes_by_seq_and_caps() {
        let ev = |seq| StoredEvent {
            seq,
            name: "e".into(),
            payload: Value::Null,
        };
        let merged = merge_replay(vec![ev(1), ev(2), ev(3)], vec![ev(3), ev(4)]);
        let seqs: Vec<u64> = merged.iter().map(|e| e.seq).collect();
        assert_eq!(seqs, vec![1, 2, 3, 4]);
    }
}