Skip to main content

csaf_models/
db.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 Pierre Gronau, ndaal in Cologne
3
4//! Embedded SQLite database management using rusqlite.
5//!
6//! Provides a thread-safe connection pool wrapper and schema migration.
7//! Adapted from the vulnerability-lookup-rs `DbPool` pattern.
8
9use std::path::Path;
10use std::sync::{Arc, Mutex};
11
12use rusqlite::Connection;
13
14/// Thread-safe SQLite connection pool (single writer via Mutex).
15///
16/// For a web server workload the single-writer approach is acceptable because
17/// SQLite WAL mode allows concurrent readers. All writes are serialised
18/// through the `Mutex`.
19#[derive(Clone)]
20pub struct DbPool {
21    conn: Arc<Mutex<Connection>>,
22}
23
24impl DbPool {
25    /// Open or create the SQLite database at the given path and run migrations.
26    ///
27    /// # Errors
28    ///
29    /// Returns an error if the database cannot be opened or migrations fail.
30    pub fn open(db_path: &Path) -> Result<Self, rusqlite::Error> {
31        if let Some(parent) = db_path.parent() {
32            std::fs::create_dir_all(parent).ok();
33        }
34
35        let conn = Connection::open(db_path)?;
36
37        conn.execute_batch("PRAGMA journal_mode=WAL;")?;
38        conn.execute_batch("PRAGMA foreign_keys=ON;")?;
39        conn.execute_batch("PRAGMA busy_timeout=5000;")?;
40
41        let pool = Self {
42            conn: Arc::new(Mutex::new(conn)),
43        };
44
45        pool.run_migrations()?;
46
47        Ok(pool)
48    }
49
50    /// Open an in-memory database (useful for tests).
51    ///
52    /// # Errors
53    ///
54    /// Returns an error if the in-memory database cannot be created.
55    pub fn open_in_memory() -> Result<Self, rusqlite::Error> {
56        let conn = Connection::open_in_memory()?;
57        conn.execute_batch("PRAGMA foreign_keys=ON;")?;
58
59        let pool = Self {
60            conn: Arc::new(Mutex::new(conn)),
61        };
62        pool.run_migrations()?;
63        Ok(pool)
64    }
65
66    /// Execute a closure with an exclusive lock on the connection.
67    ///
68    /// # Errors
69    ///
70    /// Returns any error produced by the closure. If the internal mutex
71    /// is poisoned, recovers the guard and continues to serve requests.
72    pub fn with_conn<F, T>(&self, f: F) -> Result<T, rusqlite::Error>
73    where
74        F: FnOnce(&Connection) -> Result<T, rusqlite::Error>,
75    {
76        let conn = self
77            .conn
78            .lock()
79            .unwrap_or_else(std::sync::PoisonError::into_inner);
80        let result = f(&conn);
81        drop(conn);
82        result
83    }
84
85    /// Run all schema migrations.
86    fn run_migrations(&self) -> Result<(), rusqlite::Error> {
87        let conn = self
88            .conn
89            .lock()
90            .unwrap_or_else(std::sync::PoisonError::into_inner);
91        conn.execute_batch(INITIAL_MIGRATION)?;
92        drop(conn);
93        Ok(())
94    }
95}
96
97/// Initial SQLite schema for the CSAF CRUD application.
98const INITIAL_MIGRATION: &str = r"
99-- Users
100CREATE TABLE IF NOT EXISTS users (
101    id              INTEGER PRIMARY KEY AUTOINCREMENT,
102    uuid            TEXT NOT NULL UNIQUE,
103    login           TEXT NOT NULL UNIQUE,
104    name            TEXT NOT NULL,
105    email           TEXT NOT NULL UNIQUE,
106    pwdhash         TEXT NOT NULL,
107    apikey          TEXT NOT NULL,
108    is_active       INTEGER NOT NULL DEFAULT 1,
109    is_admin        INTEGER NOT NULL DEFAULT 0,
110    created_at      TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
111    last_seen       TEXT
112);
113CREATE INDEX IF NOT EXISTS idx_users_uuid ON users (uuid);
114CREATE INDEX IF NOT EXISTS idx_users_apikey ON users (apikey);
115CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);
116
117-- Sessions
118CREATE TABLE IF NOT EXISTS sessions (
119    id              TEXT PRIMARY KEY,
120    user_id         INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
121    created_at      TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
122    expires_at      TEXT NOT NULL
123);
124CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions (user_id);
125
126-- Audit log (ISO 8601 timestamps)
127CREATE TABLE IF NOT EXISTS audit_log (
128    id              INTEGER PRIMARY KEY AUTOINCREMENT,
129    timestamp       TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S.000Z', 'now')),
130    action          TEXT NOT NULL,
131    tracking_id     TEXT NOT NULL,
132    user_id         INTEGER REFERENCES users(id),
133    details         TEXT,
134    CONSTRAINT valid_action CHECK (action IN ('create', 'update', 'delete', 'import', 'export', 'settings_reset'))
135);
136CREATE INDEX IF NOT EXISTS idx_audit_log_tracking ON audit_log(tracking_id);
137CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp ON audit_log(timestamp);
138";
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn test_open_in_memory() {
146        let pool = DbPool::open_in_memory().expect("Failed to open in-memory DB");
147        pool.with_conn(|conn| {
148            conn.execute(
149                "INSERT INTO users (uuid, login, name, email, pwdhash, apikey) \
150                 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
151                rusqlite::params![
152                    "test-uuid",
153                    "admin",
154                    "Admin",
155                    "admin@test.com",
156                    "hash",
157                    "key"
158                ],
159            )?;
160            let count: i64 = conn.query_row("SELECT COUNT(*) FROM users", [], |row| row.get(0))?;
161            assert_eq!(count, 1);
162            Ok(())
163        })
164        .expect("DB operation failed");
165    }
166
167    #[test]
168    fn test_audit_log_constraint() {
169        let pool = DbPool::open_in_memory().expect("Failed to open in-memory DB");
170        let result = pool.with_conn(|conn| {
171            conn.execute(
172                "INSERT INTO audit_log (action, tracking_id) VALUES (?1, ?2)",
173                rusqlite::params!["invalid_action", "test-id"],
174            )?;
175            Ok(())
176        });
177        assert!(result.is_err(), "Invalid action should fail constraint");
178    }
179
180    #[test]
181    fn test_audit_log_valid_actions() {
182        let pool = DbPool::open_in_memory().expect("Failed to open in-memory DB");
183        for action in &["create", "update", "delete", "import", "export"] {
184            pool.with_conn(|conn| {
185                conn.execute(
186                    "INSERT INTO audit_log (action, tracking_id) VALUES (?1, ?2)",
187                    rusqlite::params![action, format!("test-{action}")],
188                )?;
189                Ok(())
190            })
191            .unwrap_or_else(|e| panic!("Action '{action}' should be valid: {e}"));
192        }
193    }
194
195    #[test]
196    fn test_foreign_key_enforcement() {
197        let pool = DbPool::open_in_memory().expect("Failed to open in-memory DB");
198        let result = pool.with_conn(|conn| {
199            conn.execute(
200                "INSERT INTO sessions (id, user_id, expires_at) VALUES (?1, ?2, ?3)",
201                rusqlite::params!["session-1", 999, "2026-12-31T00:00:00Z"],
202            )?;
203            Ok(())
204        });
205        assert!(result.is_err(), "FK to nonexistent user should fail");
206    }
207}