csaf-models 0.3.4

CSAF 2.0/2.1 data models, SQLite management, and user models
Documentation
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026 Pierre Gronau, ndaal in Cologne

//! Audit log model for CSAF CRUD operations.
//!
//! Every create, update, and delete is logged with an ISO 8601 timestamp.

use rusqlite::{Connection, params};
use serde::{Deserialize, Serialize};

/// An audit log entry recording a CSAF operation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditLogEntry {
    /// Database ID.
    pub id: i64,
    /// Timestamp (ISO 8601 UTC).
    pub timestamp: String,
    /// Action performed.
    pub action: String,
    /// CSAF tracking ID.
    pub tracking_id: String,
    /// User ID who performed the action (if any).
    pub user_id: Option<i64>,
    /// Additional details (JSON string).
    pub details: Option<String>,
}

/// Valid audit log actions. Mirror the `valid_action` CHECK constraint
/// in `crates/csaf-models/src/db.rs` — any addition here must be mirrored
/// there or the insert will fail at runtime.
pub const VALID_ACTIONS: &[&str] = &[
    "create",
    "update",
    "delete",
    "import",
    "export",
    "settings_reset",
];

/// Record an audit log entry.
///
/// # Errors
///
/// Returns an error if the insert fails.
pub fn record(
    conn: &Connection,
    action: &str,
    tracking_id: &str,
    user_id: Option<i64>,
    details: Option<&str>,
) -> Result<i64, rusqlite::Error> {
    let timestamp = chrono::Utc::now()
        .format("%Y-%m-%dT%H:%M:%S%.3fZ")
        .to_string();

    conn.execute(
        "INSERT INTO audit_log (timestamp, action, tracking_id, user_id, details) \
         VALUES (?1, ?2, ?3, ?4, ?5)",
        params![timestamp, action, tracking_id, user_id, details],
    )?;

    Ok(conn.last_insert_rowid())
}

/// List audit log entries with optional filters.
///
/// # Errors
///
/// Returns a database error if the query fails.
pub fn list(
    conn: &Connection,
    tracking_id: Option<&str>,
    limit: usize,
    offset: usize,
) -> Result<Vec<AuditLogEntry>, rusqlite::Error> {
    let (sql, has_filter) = if tracking_id.is_some() {
        (
            "SELECT id, timestamp, action, tracking_id, user_id, details \
             FROM audit_log WHERE tracking_id = ?1 \
             ORDER BY timestamp DESC LIMIT ?2 OFFSET ?3",
            true,
        )
    } else {
        (
            "SELECT id, timestamp, action, tracking_id, user_id, details \
             FROM audit_log ORDER BY timestamp DESC LIMIT ?1 OFFSET ?2",
            false,
        )
    };

    let mut stmt = conn.prepare(sql)?;

    // `usize` → `i64` is safe at our scales (limits and offsets are
    // user-bounded and never exceed `i64::MAX`) but we still go
    // through `try_from` so the cast can never wrap on 64-bit targets.
    let limit_i64 = i64::try_from(limit).unwrap_or(i64::MAX);
    let offset_i64 = i64::try_from(offset).unwrap_or(i64::MAX);
    let rows = if has_filter {
        stmt.query_map(
            params![tracking_id.unwrap_or_default(), limit_i64, offset_i64],
            map_row,
        )?
    } else {
        stmt.query_map(params![limit_i64, offset_i64], map_row)?
    };

    rows.collect()
}

/// Count total audit log entries with optional filter.
///
/// # Errors
///
/// Returns a database error if the query fails.
pub fn count(conn: &Connection, tracking_id: Option<&str>) -> Result<usize, rusqlite::Error> {
    // SQLite `COUNT(*)` is always non-negative (sign loss is safe) and
    // fits in `usize` on every platform we ship to. We still go through
    // `try_from` + saturate so the cast can never truncate on 32-bit
    // targets.
    let to_usize = |n: i64| usize::try_from(n.max(0)).unwrap_or(usize::MAX);
    tracking_id.map_or_else(
        || {
            conn.query_row("SELECT COUNT(*) FROM audit_log", [], |row| {
                row.get::<_, i64>(0).map(to_usize)
            })
        },
        |tid| {
            conn.query_row(
                "SELECT COUNT(*) FROM audit_log WHERE tracking_id = ?1",
                params![tid],
                |row| row.get::<_, i64>(0).map(to_usize),
            )
        },
    )
}

/// Map a database row to an `AuditLogEntry`.
fn map_row(row: &rusqlite::Row<'_>) -> Result<AuditLogEntry, rusqlite::Error> {
    Ok(AuditLogEntry {
        id: row.get("id")?,
        timestamp: row.get("timestamp")?,
        action: row.get("action")?,
        tracking_id: row.get("tracking_id")?,
        user_id: row.get("user_id")?,
        details: row.get("details")?,
    })
}

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

    #[test]
    fn test_record_and_list() {
        let pool = DbPool::open_in_memory().expect("DB open failed");
        pool.with_conn(|conn| {
            record(
                conn,
                "create",
                "ndaal-sa-2026-001",
                None,
                Some(r#"{"source":"manual"}"#),
            )?;
            record(conn, "update", "ndaal-sa-2026-001", None, None)?;
            record(conn, "delete", "ndaal-sa-2026-002", None, None)?;

            let all = list(conn, None, 100, 0)?;
            assert_eq!(all.len(), 3);

            let filtered = list(conn, Some("ndaal-sa-2026-001"), 100, 0)?;
            assert_eq!(filtered.len(), 2);

            let total = count(conn, None)?;
            assert_eq!(total, 3);

            let filtered_count = count(conn, Some("ndaal-sa-2026-001"))?;
            assert_eq!(filtered_count, 2);

            Ok(())
        })
        .expect("DB operation failed");
    }

    #[test]
    fn test_iso_8601_timestamp() {
        let pool = DbPool::open_in_memory().expect("DB open failed");
        pool.with_conn(|conn| {
            record(conn, "create", "test-id", None, None)?;
            let entries = list(conn, None, 1, 0)?;
            let entry = &entries[0];

            // Verify ISO 8601 format with milliseconds
            assert!(
                entry.timestamp.contains('T'),
                "Timestamp should contain 'T': {}",
                entry.timestamp
            );
            assert!(
                entry.timestamp.ends_with('Z'),
                "Timestamp should end with 'Z': {}",
                entry.timestamp
            );

            Ok(())
        })
        .expect("DB operation failed");
    }
}