bux 0.9.0

Embedded micro-VM sandbox for running AI agents
//! Audit event system for observable VM lifecycle operations.
//!
//! Events are emitted at key lifecycle points (create, start, stop, exec,
//! snapshot, file copy) and delivered to registered [`EventListener`]
//! implementations.
//!
//! The built-in [`RingBufferListener`] stores the most recent N events
//! in a bounded, mutex-protected ring buffer for querying.

use std::sync::{Arc, Mutex};
use std::time::SystemTime;

/// Kinds of auditable events emitted by the bux runtime.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum AuditEventKind {
    /// A new VM was created.
    VmCreated {
        /// VM identifier.
        id: String,
        /// OCI image reference, if any.
        image: Option<String>,
    },
    /// A VM was started (or restarted).
    VmStarted {
        /// VM identifier.
        id: String,
    },
    /// A VM was stopped.
    VmStopped {
        /// VM identifier.
        id: String,
        /// Exit code, if available.
        exit_code: Option<i32>,
    },
    /// A VM was removed.
    VmRemoved {
        /// VM identifier.
        id: String,
    },
    /// A command execution was started inside a VM.
    ExecStarted {
        /// VM identifier.
        vm_id: String,
        /// Command that was executed.
        command: String,
        /// Unique execution identifier.
        exec_id: String,
    },
    /// A command execution completed inside a VM.
    ExecCompleted {
        /// VM identifier.
        vm_id: String,
        /// Unique execution identifier.
        exec_id: String,
        /// Exit code of the command.
        exit_code: i32,
        /// Wall-clock duration in milliseconds.
        duration_ms: u64,
    },
    /// A snapshot was created.
    SnapshotCreated {
        /// VM identifier.
        vm_id: String,
        /// Snapshot identifier.
        snapshot_id: String,
    },
    /// A VM was restored from a snapshot.
    SnapshotRestored {
        /// VM identifier.
        vm_id: String,
        /// Snapshot identifier.
        snapshot_id: String,
    },
    /// A file was copied into or out of a VM.
    FileCopied {
        /// VM identifier.
        vm_id: String,
        /// Direction of the copy.
        direction: CopyDirection,
        /// Path involved in the copy.
        path: String,
    },
}

/// Direction of a file copy operation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum CopyDirection {
    /// Host → guest.
    In,
    /// Guest → host.
    Out,
}

/// A timestamped audit event.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct AuditEvent {
    /// When the event occurred.
    pub timestamp: SystemTime,
    /// What happened.
    pub kind: AuditEventKind,
}

impl AuditEvent {
    /// Creates a new event with the current timestamp.
    #[must_use]
    pub fn now(kind: AuditEventKind) -> Self {
        Self {
            timestamp: SystemTime::now(),
            kind,
        }
    }
}

/// Trait for receiving audit events.
///
/// Implementations must be `Send + Sync` since events may be emitted
/// from any thread. The `on_event` method should return quickly —
/// perform any expensive processing (logging, network I/O) asynchronously.
pub trait EventListener: Send + Sync {
    /// Called when an auditable event occurs.
    fn on_event(&self, event: &AuditEvent);
}

/// A fan-out dispatcher that forwards events to multiple listeners.
#[derive(Default)]
pub struct EventDispatcher {
    /// Registered listeners.
    listeners: Mutex<Vec<Arc<dyn EventListener>>>,
}

impl std::fmt::Debug for EventDispatcher {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let count = self.listeners.lock().map_or(0, |l| l.len());
        f.debug_struct("EventDispatcher")
            .field("listener_count", &count)
            .finish()
    }
}

impl EventDispatcher {
    /// Creates a new dispatcher with no listeners.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Registers a listener that will receive all future events.
    pub fn add_listener(&self, listener: Arc<dyn EventListener>) {
        if let Ok(mut listeners) = self.listeners.lock() {
            listeners.push(listener);
        }
    }

    /// Emits an event to all registered listeners.
    #[allow(
        clippy::needless_pass_by_value,
        reason = "event is consumed after broadcast"
    )]
    pub fn emit(&self, event: AuditEvent) {
        if let Ok(listeners) = self.listeners.lock() {
            for listener in listeners.iter() {
                listener.on_event(&event);
            }
        }
    }
}

/// A bounded ring buffer that stores the most recent N audit events.
///
/// Thread-safe: all state is behind a single `Mutex`.
/// Suitable for in-process querying of recent activity.
pub struct RingBufferListener {
    /// Mutable state protected by a single lock.
    inner: Mutex<RingBuffer>,
    /// Buffer capacity (immutable after construction).
    capacity: usize,
}

/// Internal mutable state of a [`RingBufferListener`].
struct RingBuffer {
    /// Fixed-size event slots.
    slots: Vec<Option<AuditEvent>>,
    /// Current write position (wraps around).
    write_pos: usize,
    /// Total number of events ever recorded.
    total: u64,
}

impl std::fmt::Debug for RingBufferListener {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("RingBufferListener")
            .field("capacity", &self.capacity)
            .field("total_events", &self.total_events())
            .finish_non_exhaustive()
    }
}

impl RingBufferListener {
    /// Creates a ring buffer with the given capacity.
    #[must_use]
    pub fn new(capacity: usize) -> Self {
        let cap = capacity.max(1);
        Self {
            inner: Mutex::new(RingBuffer {
                slots: vec![None; cap],
                write_pos: 0,
                total: 0,
            }),
            capacity: cap,
        }
    }

    /// Total number of events ever recorded (may exceed capacity).
    #[must_use]
    pub fn total_events(&self) -> u64 {
        self.inner.lock().map_or(0, |g| g.total)
    }

    /// Returns the most recent events, up to `limit`.
    ///
    /// Events are returned in chronological order (oldest first).
    #[must_use]
    pub fn recent(&self, limit: usize) -> Vec<AuditEvent> {
        let Ok(guard) = self.inner.lock() else {
            return Vec::new();
        };
        #[allow(
            clippy::cast_possible_truncation,
            reason = "capacity is usize, so truncation is acceptable"
        )]
        let total = guard.total as usize;
        let available = total.min(self.capacity);
        let take = limit.min(available);

        if take == 0 {
            return Vec::new();
        }

        let write_pos = guard.write_pos;
        let mut events = Vec::with_capacity(take);

        let start = if write_pos >= take {
            write_pos - take
        } else {
            self.capacity - (take - write_pos)
        };

        for i in 0..take {
            let idx = (start + i) % self.capacity;
            if let Some(event) = guard.slots.get(idx).and_then(Option::as_ref) {
                events.push(event.clone());
            }
        }

        events
    }
}

impl EventListener for RingBufferListener {
    fn on_event(&self, event: &AuditEvent) {
        if let Ok(mut guard) = self.inner.lock() {
            let pos = guard.write_pos;
            if let Some(slot) = guard.slots.get_mut(pos % self.capacity) {
                *slot = Some(event.clone());
            }
            guard.write_pos = (pos + 1) % self.capacity;
            guard.total += 1;
        }
    }
}

#[cfg(test)]
#[allow(
    clippy::unwrap_used,
    clippy::indexing_slicing,
    reason = "test assertions use unwrap/indexing for clarity"
)]
mod tests {
    use super::*;

    fn make_event(id: &str) -> AuditEvent {
        AuditEvent::now(AuditEventKind::VmCreated {
            id: id.to_owned(),
            image: None,
        })
    }

    #[test]
    fn ring_buffer_stores_and_retrieves() {
        let ring = RingBufferListener::new(3);
        ring.on_event(&make_event("vm1"));
        ring.on_event(&make_event("vm2"));

        let events = ring.recent(10);
        assert_eq!(events.len(), 2);
        assert_eq!(ring.total_events(), 2);
    }

    #[test]
    fn ring_buffer_wraps_around() {
        let ring = RingBufferListener::new(2);
        ring.on_event(&make_event("vm1"));
        ring.on_event(&make_event("vm2"));
        ring.on_event(&make_event("vm3"));

        assert_eq!(ring.total_events(), 3);
        let events = ring.recent(10);
        assert_eq!(events.len(), 2);

        // Should have vm2 and vm3 (vm1 was evicted).
        if let AuditEventKind::VmCreated { ref id, .. } = events[0].kind {
            assert_eq!(id, "vm2");
        }
        if let AuditEventKind::VmCreated { ref id, .. } = events[1].kind {
            assert_eq!(id, "vm3");
        }
    }

    #[test]
    fn file_copied_variant_round_trip() {
        let event = AuditEvent::now(AuditEventKind::FileCopied {
            vm_id: "vm1".into(),
            direction: CopyDirection::In,
            path: "/tmp/x".into(),
        });
        assert!(matches!(
            event.kind,
            AuditEventKind::FileCopied {
                ref vm_id,
                direction: CopyDirection::In,
                ref path,
            } if vm_id == "vm1" && path == "/tmp/x"
        ));
    }

    #[test]
    fn snapshot_restored_variant_round_trip() {
        let event = AuditEvent::now(AuditEventKind::SnapshotRestored {
            vm_id: "vm1".into(),
            snapshot_id: "snap1".into(),
        });
        assert!(matches!(
            event.kind,
            AuditEventKind::SnapshotRestored {
                ref vm_id,
                ref snapshot_id,
            } if vm_id == "vm1" && snapshot_id == "snap1"
        ));
    }

    #[test]
    fn dispatcher_fans_out() {
        let dispatcher = EventDispatcher::new();
        let ring = Arc::new(RingBufferListener::new(10));
        #[allow(
            clippy::clone_on_ref_ptr,
            reason = "coercion to dyn trait requires .clone()"
        )]
        let listener: Arc<dyn EventListener> = ring.clone();
        dispatcher.add_listener(listener);

        dispatcher.emit(make_event("vm1"));
        dispatcher.emit(make_event("vm2"));

        assert_eq!(ring.total_events(), 2);
    }
}