Skip to main content

hennery_kernel/
db.rs

1//! SQLite helpers (kernel spec §1).
2
3use anyhow::{Context, Result, bail};
4use rusqlite::{Connection, OptionalExtension, TransactionBehavior};
5use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
6use std::path::Path;
7use std::sync::Arc;
8
9/// A SQLite connection retaining its process ownership. Dereferencing it
10/// exposes SQLite only while the ownership handle remains alive.
11#[derive(Debug)]
12pub struct OwnedConnection {
13    conn: Connection,
14    coordination: Arc<crate::coordination::Coordination>,
15}
16
17impl OwnedConnection {
18    pub fn coordination(&self) -> Arc<crate::coordination::Coordination> {
19        self.coordination.clone()
20    }
21}
22impl std::ops::Deref for OwnedConnection {
23    type Target = Connection;
24    fn deref(&self) -> &Connection {
25        &self.conn
26    }
27}
28impl std::ops::DerefMut for OwnedConnection {
29    fn deref_mut(&mut self) -> &mut Connection {
30        &mut self.conn
31    }
32}
33
34/// Store mutex with a fixed ordering: owner gate, then the connection.
35pub struct CoordinatedConnection {
36    inner: std::sync::Mutex<OwnedConnection>,
37    coordination: Arc<crate::coordination::Coordination>,
38}
39pub struct ConnectionGuard<'a> {
40    inner: std::sync::MutexGuard<'a, OwnedConnection>,
41    _gate: crate::coordination::GateGuard,
42}
43impl CoordinatedConnection {
44    pub fn new(conn: OwnedConnection) -> Self {
45        let coordination = conn.coordination();
46        Self {
47            inner: std::sync::Mutex::new(conn),
48            coordination,
49        }
50    }
51    pub fn coordination(&self) -> Arc<crate::coordination::Coordination> {
52        self.coordination.clone()
53    }
54    pub fn lock(&self) -> Result<ConnectionGuard<'_>, &'static str> {
55        let gate = self.coordination.gate.lock();
56        let inner = self.inner.lock().map_err(|_| "database connection poisoned")?;
57        Ok(ConnectionGuard { inner, _gate: gate })
58    }
59}
60impl std::ops::Deref for ConnectionGuard<'_> {
61    type Target = Connection;
62    fn deref(&self) -> &Connection {
63        &self.inner
64    }
65}
66impl std::ops::DerefMut for ConnectionGuard<'_> {
67    fn deref_mut(&mut self) -> &mut Connection {
68        &mut self.inner
69    }
70}
71
72/// Open (or create) a database with the pragmas every hennery database uses.
73///
74/// The database is private to its user (mode 0600), whatever the umask: it
75/// holds what only the collector may read (pairing-code hashes, decision 5,
76/// among them). SQLite gives a new `-wal` and `-shm` the database file's
77/// own mode, so the file is made private before it is opened. A `-wal` or
78/// `-shm` already there, from an install before this, is made private too.
79///
80/// None of the three is followed if it is a symlink: the mode is changed
81/// through the descriptor of the file itself, never through a path that
82/// could name some other file of this user's.
83pub fn open(path: &Path) -> Result<OwnedConnection> {
84    let file = std::fs::OpenOptions::new()
85        .read(true)
86        .write(true)
87        .create(true)
88        .truncate(false)
89        .mode(0o600)
90        .custom_flags(libc::O_NOFOLLOW)
91        .open(path)
92        .with_context(|| format!("open {} (a symlink is refused)", path.display()))?;
93    make_private(&file, path)?;
94    let coordination = crate::coordination::Coordination::acquire(file, path)?;
95    let conn = configure(Connection::open(path)?)?;
96    for suffix in ["-wal", "-shm"] {
97        let mut name = path.as_os_str().to_os_string();
98        name.push(suffix);
99        let sidecar = Path::new(&name);
100        match std::fs::OpenOptions::new()
101            .read(true)
102            .custom_flags(libc::O_NOFOLLOW)
103            .open(sidecar)
104        {
105            Ok(file) => make_private(&file, sidecar)?,
106            // Gone already is fine: the last connection to close removes them.
107            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
108            Err(err) => {
109                return Err(err).with_context(|| format!("open {} (a symlink is refused)", sidecar.display()));
110            }
111        }
112    }
113    Ok(OwnedConnection { conn, coordination })
114}
115
116/// `fchmod`, through the open file rather than its path.
117fn make_private(file: &std::fs::File, path: &Path) -> Result<()> {
118    file.set_permissions(std::fs::Permissions::from_mode(0o600))
119        .with_context(|| format!("make {} private", path.display()))
120}
121
122pub fn open_in_memory() -> Result<OwnedConnection> {
123    Ok(OwnedConnection {
124        conn: configure(Connection::open_in_memory()?)?,
125        coordination: Arc::default(),
126    })
127}
128
129/// Every transaction is `IMMEDIATE`: it takes the write lock when it
130/// begins, waiting out the busy timeout if another connection holds it.
131/// Several connections write to `hennery.db` (the sessions store, the host
132/// registry), and a deferred transaction that reads first
133/// fails on its first write, at once and without the busy timeout
134/// (`SQLITE_BUSY_SNAPSHOT`), whenever another connection committed after
135/// its read. Until one writer thread owns the database (kernel spec §1),
136/// this is what serialises them.
137fn configure(mut conn: Connection) -> Result<Connection> {
138    conn.pragma_update(None, "journal_mode", "WAL")?;
139    conn.pragma_update(None, "foreign_keys", "ON")?;
140    // What is deleted is overwritten with zeros, not left in free space
141    // for anyone who reads the file (plan 9a A8): a deleted session's
142    // title, cwd and timeline must not outlive the delete in the database.
143    // The WAL keeps old pages until a checkpoint, which a delete runs.
144    conn.pragma_update(None, "secure_delete", "ON")?;
145    conn.busy_timeout(std::time::Duration::from_secs(5))?;
146    conn.set_transaction_behavior(rusqlite::TransactionBehavior::Immediate);
147    Ok(conn)
148}
149
150/// Apply `migrations[user_version..]` in order, each in its own transaction.
151/// Refuses to run against a database newer than this binary.
152///
153/// Each step reads the version inside its transaction, which holds the
154/// write lock from its start: `IMMEDIATE`, asked for here rather than left
155/// to the connection's default (`configure` sets it; a plain connection
156/// does not). Another connection migrating the same file meanwhile has
157/// either committed the step, and it is skipped, or not begun it. A
158/// version read before the lock could be stale by then, and the step
159/// would run twice.
160pub fn migrate(conn: &mut Connection, migrations: &[&str]) -> Result<()> {
161    loop {
162        let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
163        let current: usize = tx.pragma_query_value(None, "user_version", |r| r.get::<_, i64>(0))? as usize;
164        if current > migrations.len() {
165            bail!(
166                "database schema version {current} is newer than this binary supports ({})",
167                migrations.len()
168            );
169        }
170        let Some(sql) = migrations.get(current) else {
171            return Ok(());
172        };
173        tx.execute_batch(sql)?;
174        tx.pragma_update(None, "user_version", (current + 1) as i64)?;
175        tx.commit()?;
176    }
177}
178
179/// Like `migrate`, for one component of a database that several own
180/// (kernel spec §1): the kernel's tables share `hennery.db` with the
181/// sessions module, which keeps `user_version` for itself. Each component's
182/// version is a row of `schema_versions`, and a component newer than this
183/// binary is refused the same way. Each step reads the version inside its
184/// transaction, as `migrate` does.
185pub fn migrate_component(conn: &mut Connection, component: &str, migrations: &[&str]) -> Result<()> {
186    loop {
187        let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
188        tx.execute_batch(
189            "CREATE TABLE IF NOT EXISTS schema_versions (component TEXT PRIMARY KEY, version INTEGER NOT NULL);",
190        )?;
191        let current: usize = tx
192            .query_row(
193                "SELECT version FROM schema_versions WHERE component = ?1",
194                [component],
195                |r| r.get::<_, i64>(0),
196            )
197            .optional()?
198            .unwrap_or(0) as usize;
199        if current > migrations.len() {
200            bail!(
201                "{component} schema version {current} is newer than this binary supports ({})",
202                migrations.len()
203            );
204        }
205        let Some(sql) = migrations.get(current) else {
206            // Commits the `schema_versions` table, if this made it.
207            tx.commit()?;
208            return Ok(());
209        };
210        tx.execute_batch(sql)?;
211        tx.execute(
212            "INSERT INTO schema_versions(component, version) VALUES (?1, ?2)
213             ON CONFLICT(component) DO UPDATE SET version = excluded.version",
214            rusqlite::params![component, (current + 1) as i64],
215        )?;
216        tx.commit()?;
217    }
218}
219
220/// Migrate the kernel's tables (`schema`), then return the database's
221/// owner (plan 3b-iii decisions 1 and 2): the oldest row of `owners`,
222/// which the kernel's migrations create at the first start. Every store on
223/// `hennery.db` binds to it when it opens, so they agree whichever opens
224/// first. This is the one query that does not filter by the owner: it
225/// finds the owner.
226pub fn kernel_owner(conn: &mut Connection) -> Result<String> {
227    migrate_component(conn, crate::schema::COMPONENT, crate::schema::MIGRATIONS)?;
228    conn.query_row("SELECT id FROM owners ORDER BY created_at, id LIMIT 1", [], |r| {
229        r.get(0)
230    })
231    .optional()?
232    .context("the database has no owner")
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use std::os::unix::fs::PermissionsExt;
239
240    fn mode(path: &Path) -> u32 {
241        std::fs::metadata(path).unwrap().permissions().mode() & 0o777
242    }
243
244    /// Final review I2, an install from before the fix: a database and its
245    /// WAL files left readable by others become private when opened.
246    #[test]
247    fn opening_makes_an_existing_database_and_its_wal_files_private() {
248        let dir = tempfile::tempdir().unwrap();
249        let path = dir.path().join("hennery.db");
250        // Held open, so the `-wal` and `-shm` stay there.
251        let first = open(&path).unwrap();
252        first
253            .execute_batch("CREATE TABLE t (x INTEGER); INSERT INTO t VALUES (1);")
254            .unwrap();
255        let files = [
256            path.clone(),
257            dir.path().join("hennery.db-wal"),
258            dir.path().join("hennery.db-shm"),
259        ];
260        for file in &files {
261            std::fs::set_permissions(file, std::fs::Permissions::from_mode(0o644)).unwrap();
262        }
263        let _second = open(&path).unwrap();
264        for file in &files {
265            assert_eq!(mode(file), 0o600, "{}", file.display());
266        }
267    }
268
269    /// Review of the fix wave: `open` changes the mode of the file it is
270    /// given, so it must never follow a symlink to some other file of its
271    /// user's and make that one 0600, or open it as a database.
272    #[test]
273    fn a_symlinked_database_is_refused_and_its_target_left_alone() {
274        let dir = tempfile::tempdir().unwrap();
275        let target = dir.path().join("elsewhere");
276        std::fs::write(&target, b"not a database").unwrap();
277        std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644)).unwrap();
278        let db = dir.path().join("hennery.db");
279        std::os::unix::fs::symlink(&target, &db).unwrap();
280        let err = open(&db).expect_err("a symlinked database was opened");
281        assert!(format!("{err:#}").contains("a symlink is refused"), "{err:#}");
282        assert_eq!(mode(&target), 0o644);
283        assert_eq!(std::fs::read(&target).unwrap(), b"not a database");
284    }
285
286    /// A transaction holds the write lock from its start, so
287    /// another connection's write waits for it (here, with no busy timeout,
288    /// is refused) instead of committing between the transaction's read and
289    /// its write and failing that write with `SQLITE_BUSY_SNAPSHOT`.
290    #[test]
291    fn a_transaction_holds_the_write_lock_from_its_start() {
292        let dir = tempfile::tempdir().unwrap();
293        let path = dir.path().join("hennery.db");
294        let mut first = open(&path).unwrap();
295        first.execute_batch("CREATE TABLE t (x INTEGER);").unwrap();
296        let second = open(&path).unwrap();
297        second.busy_timeout(std::time::Duration::ZERO).unwrap();
298
299        let tx = first.transaction().unwrap();
300        let count: i64 = tx.query_row("SELECT count(*) FROM t", [], |r| r.get(0)).unwrap();
301        assert_eq!(count, 0);
302        let err = second.execute("INSERT INTO t VALUES (1)", []).unwrap_err();
303        assert_eq!(
304            err.sqlite_error_code(),
305            Some(rusqlite::ErrorCode::DatabaseBusy),
306            "{err}"
307        );
308        tx.execute("INSERT INTO t VALUES (2)", []).unwrap();
309        tx.commit().unwrap();
310        second.execute("INSERT INTO t VALUES (3)", []).unwrap();
311    }
312
313    #[test]
314    fn migrations_apply_once_and_record_the_version() {
315        let mut conn = open_in_memory().unwrap();
316        let steps = ["CREATE TABLE a (x INTEGER);", "CREATE TABLE b (y INTEGER);"];
317        migrate(&mut conn, &steps).unwrap();
318        migrate(&mut conn, &steps).unwrap();
319        let v: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0)).unwrap();
320        assert_eq!(v, 2);
321    }
322
323    #[test]
324    fn a_newer_database_is_refused() {
325        let mut conn = open_in_memory().unwrap();
326        migrate(
327            &mut conn,
328            &["CREATE TABLE a (x INTEGER);", "CREATE TABLE b (y INTEGER);"],
329        )
330        .unwrap();
331        let err = migrate(&mut conn, &["CREATE TABLE a (x INTEGER);"]).unwrap_err();
332        assert!(err.to_string().contains("newer"), "{err}");
333    }
334
335    #[test]
336    fn components_keep_their_own_versions_beside_user_version() {
337        let mut conn = open_in_memory().unwrap();
338        migrate(&mut conn, &["CREATE TABLE a (x INTEGER);"]).unwrap();
339        migrate_component(
340            &mut conn,
341            "kernel",
342            &["CREATE TABLE k1 (x INTEGER);", "CREATE TABLE k2 (x INTEGER);"],
343        )
344        .unwrap();
345        migrate_component(
346            &mut conn,
347            "kernel",
348            &["CREATE TABLE k1 (x INTEGER);", "CREATE TABLE k2 (x INTEGER);"],
349        )
350        .unwrap();
351        migrate_component(&mut conn, "other", &["CREATE TABLE o1 (x INTEGER);"]).unwrap();
352        let user_version: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0)).unwrap();
353        let kernel: i64 = conn
354            .query_row(
355                "SELECT version FROM schema_versions WHERE component = 'kernel'",
356                [],
357                |r| r.get(0),
358            )
359            .unwrap();
360        assert_eq!((user_version, kernel), (1, 2));
361        // `migrate` still sees its own version, untouched by the components.
362        migrate(&mut conn, &["CREATE TABLE a (x INTEGER);"]).unwrap();
363    }
364
365    /// A connection `configure` never touched: a busy timeout, but no
366    /// `IMMEDIATE` default. The migration functions must take the write
367    /// lock themselves (3b-iii review, O1).
368    fn unconfigured(path: &Path) -> Connection {
369        let conn = Connection::open(path).unwrap();
370        conn.busy_timeout(std::time::Duration::from_secs(5)).unwrap();
371        conn
372    }
373
374    /// Two connections migrating one file at once, as two components of
375    /// one collector, or two collectors, can. `first` holds the write lock
376    /// and applies the step meanwhile, as the other migrator would.
377    /// `migrate`, on a connection of its own (`unconfigured`), must read
378    /// the version once it holds the lock, then see the step done and skip
379    /// it. A version read before it waits for the lock is stale, and the
380    /// step would run twice. If `migrate` reaches its read only after
381    /// `first` commits (under load, say), the test passes falsely; it never
382    /// fails falsely.
383    #[test]
384    fn a_migration_racing_another_connection_is_applied_once() {
385        let dir = tempfile::tempdir().unwrap();
386        let path = dir.path().join("hennery.db");
387        let steps = ["CREATE TABLE a (x INTEGER);"];
388        let mut first = open(&path).unwrap();
389        let tx = first.transaction().unwrap();
390        let racing = {
391            let path = path.clone();
392            std::thread::spawn(move || migrate(&mut unconfigured(&path), &steps))
393        };
394        std::thread::sleep(std::time::Duration::from_millis(200));
395        tx.execute_batch(steps[0]).unwrap();
396        tx.pragma_update(None, "user_version", 1).unwrap();
397        tx.commit().unwrap();
398        racing.join().unwrap().expect("the racing migration failed");
399        let v: i64 = first.pragma_query_value(None, "user_version", |r| r.get(0)).unwrap();
400        assert_eq!(v, 1);
401    }
402
403    /// The same race for a component's version (`schema_versions`).
404    #[test]
405    fn a_component_migration_racing_another_connection_is_applied_once() {
406        let dir = tempfile::tempdir().unwrap();
407        let path = dir.path().join("hennery.db");
408        let steps = ["CREATE TABLE k1 (x INTEGER);"];
409        let mut first = open(&path).unwrap();
410        migrate_component(&mut first, "kernel", &[]).unwrap();
411        let tx = first.transaction().unwrap();
412        let racing = {
413            let path = path.clone();
414            std::thread::spawn(move || migrate_component(&mut unconfigured(&path), "kernel", &steps))
415        };
416        std::thread::sleep(std::time::Duration::from_millis(200));
417        tx.execute_batch(steps[0]).unwrap();
418        tx.execute(
419            "INSERT INTO schema_versions(component, version) VALUES ('kernel', 1)",
420            [],
421        )
422        .unwrap();
423        tx.commit().unwrap();
424        racing.join().unwrap().expect("the racing migration failed");
425        let v: i64 = first
426            .query_row(
427                "SELECT version FROM schema_versions WHERE component = 'kernel'",
428                [],
429                |r| r.get(0),
430            )
431            .unwrap();
432        assert_eq!(v, 1);
433    }
434
435    #[test]
436    fn a_newer_component_is_refused() {
437        let mut conn = open_in_memory().unwrap();
438        migrate_component(
439            &mut conn,
440            "kernel",
441            &["CREATE TABLE k1 (x INTEGER);", "CREATE TABLE k2 (x INTEGER);"],
442        )
443        .unwrap();
444        let err = migrate_component(&mut conn, "kernel", &["CREATE TABLE k1 (x INTEGER);"]).unwrap_err();
445        assert!(err.to_string().contains("kernel schema version 2 is newer"), "{err}");
446    }
447}