use anyhow::{Context, Result, bail};
use rusqlite::{Connection, OptionalExtension, TransactionBehavior};
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::Path;
use std::sync::Arc;
#[derive(Debug)]
pub struct OwnedConnection {
conn: Connection,
coordination: Arc<crate::coordination::Coordination>,
}
impl OwnedConnection {
pub fn coordination(&self) -> Arc<crate::coordination::Coordination> {
self.coordination.clone()
}
}
impl std::ops::Deref for OwnedConnection {
type Target = Connection;
fn deref(&self) -> &Connection {
&self.conn
}
}
impl std::ops::DerefMut for OwnedConnection {
fn deref_mut(&mut self) -> &mut Connection {
&mut self.conn
}
}
pub struct CoordinatedConnection {
inner: std::sync::Mutex<OwnedConnection>,
coordination: Arc<crate::coordination::Coordination>,
}
pub struct ConnectionGuard<'a> {
inner: std::sync::MutexGuard<'a, OwnedConnection>,
_gate: crate::coordination::GateGuard,
}
impl CoordinatedConnection {
pub fn new(conn: OwnedConnection) -> Self {
let coordination = conn.coordination();
Self {
inner: std::sync::Mutex::new(conn),
coordination,
}
}
pub fn coordination(&self) -> Arc<crate::coordination::Coordination> {
self.coordination.clone()
}
pub fn lock(&self) -> Result<ConnectionGuard<'_>, &'static str> {
let gate = self.coordination.gate.lock();
let inner = self.inner.lock().map_err(|_| "database connection poisoned")?;
Ok(ConnectionGuard { inner, _gate: gate })
}
}
impl std::ops::Deref for ConnectionGuard<'_> {
type Target = Connection;
fn deref(&self) -> &Connection {
&self.inner
}
}
impl std::ops::DerefMut for ConnectionGuard<'_> {
fn deref_mut(&mut self) -> &mut Connection {
&mut self.inner
}
}
pub fn open(path: &Path) -> Result<OwnedConnection> {
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.custom_flags(libc::O_NOFOLLOW)
.open(path)
.with_context(|| format!("open {} (a symlink is refused)", path.display()))?;
make_private(&file, path)?;
let coordination = crate::coordination::Coordination::acquire(file, path)?;
let conn = configure(Connection::open(path)?)?;
for suffix in ["-wal", "-shm"] {
let mut name = path.as_os_str().to_os_string();
name.push(suffix);
let sidecar = Path::new(&name);
match std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW)
.open(sidecar)
{
Ok(file) => make_private(&file, sidecar)?,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => {
return Err(err).with_context(|| format!("open {} (a symlink is refused)", sidecar.display()));
}
}
}
Ok(OwnedConnection { conn, coordination })
}
fn make_private(file: &std::fs::File, path: &Path) -> Result<()> {
file.set_permissions(std::fs::Permissions::from_mode(0o600))
.with_context(|| format!("make {} private", path.display()))
}
pub fn open_in_memory() -> Result<OwnedConnection> {
Ok(OwnedConnection {
conn: configure(Connection::open_in_memory()?)?,
coordination: Arc::default(),
})
}
fn configure(mut conn: Connection) -> Result<Connection> {
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "foreign_keys", "ON")?;
conn.pragma_update(None, "secure_delete", "ON")?;
conn.busy_timeout(std::time::Duration::from_secs(5))?;
conn.set_transaction_behavior(rusqlite::TransactionBehavior::Immediate);
Ok(conn)
}
pub fn migrate(conn: &mut Connection, migrations: &[&str]) -> Result<()> {
loop {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let current: usize = tx.pragma_query_value(None, "user_version", |r| r.get::<_, i64>(0))? as usize;
if current > migrations.len() {
bail!(
"database schema version {current} is newer than this binary supports ({})",
migrations.len()
);
}
let Some(sql) = migrations.get(current) else {
return Ok(());
};
tx.execute_batch(sql)?;
tx.pragma_update(None, "user_version", (current + 1) as i64)?;
tx.commit()?;
}
}
pub fn migrate_component(conn: &mut Connection, component: &str, migrations: &[&str]) -> Result<()> {
loop {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
tx.execute_batch(
"CREATE TABLE IF NOT EXISTS schema_versions (component TEXT PRIMARY KEY, version INTEGER NOT NULL);",
)?;
let current: usize = tx
.query_row(
"SELECT version FROM schema_versions WHERE component = ?1",
[component],
|r| r.get::<_, i64>(0),
)
.optional()?
.unwrap_or(0) as usize;
if current > migrations.len() {
bail!(
"{component} schema version {current} is newer than this binary supports ({})",
migrations.len()
);
}
let Some(sql) = migrations.get(current) else {
tx.commit()?;
return Ok(());
};
tx.execute_batch(sql)?;
tx.execute(
"INSERT INTO schema_versions(component, version) VALUES (?1, ?2)
ON CONFLICT(component) DO UPDATE SET version = excluded.version",
rusqlite::params![component, (current + 1) as i64],
)?;
tx.commit()?;
}
}
pub fn kernel_owner(conn: &mut Connection) -> Result<String> {
migrate_component(conn, crate::schema::COMPONENT, crate::schema::MIGRATIONS)?;
conn.query_row("SELECT id FROM owners ORDER BY created_at, id LIMIT 1", [], |r| {
r.get(0)
})
.optional()?
.context("the database has no owner")
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
fn mode(path: &Path) -> u32 {
std::fs::metadata(path).unwrap().permissions().mode() & 0o777
}
#[test]
fn opening_makes_an_existing_database_and_its_wal_files_private() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("hennery.db");
let first = open(&path).unwrap();
first
.execute_batch("CREATE TABLE t (x INTEGER); INSERT INTO t VALUES (1);")
.unwrap();
let files = [
path.clone(),
dir.path().join("hennery.db-wal"),
dir.path().join("hennery.db-shm"),
];
for file in &files {
std::fs::set_permissions(file, std::fs::Permissions::from_mode(0o644)).unwrap();
}
let _second = open(&path).unwrap();
for file in &files {
assert_eq!(mode(file), 0o600, "{}", file.display());
}
}
#[test]
fn a_symlinked_database_is_refused_and_its_target_left_alone() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("elsewhere");
std::fs::write(&target, b"not a database").unwrap();
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644)).unwrap();
let db = dir.path().join("hennery.db");
std::os::unix::fs::symlink(&target, &db).unwrap();
let err = open(&db).expect_err("a symlinked database was opened");
assert!(format!("{err:#}").contains("a symlink is refused"), "{err:#}");
assert_eq!(mode(&target), 0o644);
assert_eq!(std::fs::read(&target).unwrap(), b"not a database");
}
#[test]
fn a_transaction_holds_the_write_lock_from_its_start() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("hennery.db");
let mut first = open(&path).unwrap();
first.execute_batch("CREATE TABLE t (x INTEGER);").unwrap();
let second = open(&path).unwrap();
second.busy_timeout(std::time::Duration::ZERO).unwrap();
let tx = first.transaction().unwrap();
let count: i64 = tx.query_row("SELECT count(*) FROM t", [], |r| r.get(0)).unwrap();
assert_eq!(count, 0);
let err = second.execute("INSERT INTO t VALUES (1)", []).unwrap_err();
assert_eq!(
err.sqlite_error_code(),
Some(rusqlite::ErrorCode::DatabaseBusy),
"{err}"
);
tx.execute("INSERT INTO t VALUES (2)", []).unwrap();
tx.commit().unwrap();
second.execute("INSERT INTO t VALUES (3)", []).unwrap();
}
#[test]
fn migrations_apply_once_and_record_the_version() {
let mut conn = open_in_memory().unwrap();
let steps = ["CREATE TABLE a (x INTEGER);", "CREATE TABLE b (y INTEGER);"];
migrate(&mut conn, &steps).unwrap();
migrate(&mut conn, &steps).unwrap();
let v: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0)).unwrap();
assert_eq!(v, 2);
}
#[test]
fn a_newer_database_is_refused() {
let mut conn = open_in_memory().unwrap();
migrate(
&mut conn,
&["CREATE TABLE a (x INTEGER);", "CREATE TABLE b (y INTEGER);"],
)
.unwrap();
let err = migrate(&mut conn, &["CREATE TABLE a (x INTEGER);"]).unwrap_err();
assert!(err.to_string().contains("newer"), "{err}");
}
#[test]
fn components_keep_their_own_versions_beside_user_version() {
let mut conn = open_in_memory().unwrap();
migrate(&mut conn, &["CREATE TABLE a (x INTEGER);"]).unwrap();
migrate_component(
&mut conn,
"kernel",
&["CREATE TABLE k1 (x INTEGER);", "CREATE TABLE k2 (x INTEGER);"],
)
.unwrap();
migrate_component(
&mut conn,
"kernel",
&["CREATE TABLE k1 (x INTEGER);", "CREATE TABLE k2 (x INTEGER);"],
)
.unwrap();
migrate_component(&mut conn, "other", &["CREATE TABLE o1 (x INTEGER);"]).unwrap();
let user_version: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0)).unwrap();
let kernel: i64 = conn
.query_row(
"SELECT version FROM schema_versions WHERE component = 'kernel'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!((user_version, kernel), (1, 2));
migrate(&mut conn, &["CREATE TABLE a (x INTEGER);"]).unwrap();
}
fn unconfigured(path: &Path) -> Connection {
let conn = Connection::open(path).unwrap();
conn.busy_timeout(std::time::Duration::from_secs(5)).unwrap();
conn
}
#[test]
fn a_migration_racing_another_connection_is_applied_once() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("hennery.db");
let steps = ["CREATE TABLE a (x INTEGER);"];
let mut first = open(&path).unwrap();
let tx = first.transaction().unwrap();
let racing = {
let path = path.clone();
std::thread::spawn(move || migrate(&mut unconfigured(&path), &steps))
};
std::thread::sleep(std::time::Duration::from_millis(200));
tx.execute_batch(steps[0]).unwrap();
tx.pragma_update(None, "user_version", 1).unwrap();
tx.commit().unwrap();
racing.join().unwrap().expect("the racing migration failed");
let v: i64 = first.pragma_query_value(None, "user_version", |r| r.get(0)).unwrap();
assert_eq!(v, 1);
}
#[test]
fn a_component_migration_racing_another_connection_is_applied_once() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("hennery.db");
let steps = ["CREATE TABLE k1 (x INTEGER);"];
let mut first = open(&path).unwrap();
migrate_component(&mut first, "kernel", &[]).unwrap();
let tx = first.transaction().unwrap();
let racing = {
let path = path.clone();
std::thread::spawn(move || migrate_component(&mut unconfigured(&path), "kernel", &steps))
};
std::thread::sleep(std::time::Duration::from_millis(200));
tx.execute_batch(steps[0]).unwrap();
tx.execute(
"INSERT INTO schema_versions(component, version) VALUES ('kernel', 1)",
[],
)
.unwrap();
tx.commit().unwrap();
racing.join().unwrap().expect("the racing migration failed");
let v: i64 = first
.query_row(
"SELECT version FROM schema_versions WHERE component = 'kernel'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(v, 1);
}
#[test]
fn a_newer_component_is_refused() {
let mut conn = open_in_memory().unwrap();
migrate_component(
&mut conn,
"kernel",
&["CREATE TABLE k1 (x INTEGER);", "CREATE TABLE k2 (x INTEGER);"],
)
.unwrap();
let err = migrate_component(&mut conn, "kernel", &["CREATE TABLE k1 (x INTEGER);"]).unwrap_err();
assert!(err.to_string().contains("kernel schema version 2 is newer"), "{err}");
}
}