mod art;
mod bulk;
pub mod convert;
mod error;
pub mod limits;
mod models;
mod schema;
mod structural;
mod tags;
mod tracks;
pub use bulk::BulkWriter;
pub use error::{DbError, Result};
pub use models::{
Art, ArtMeta, BinaryTag, BinaryTagRow, Format, NewArt, NewTrack, StructuralBlock, Tag, Track,
TrackArt, TrackBounds,
};
pub use tracks::ChangelogRead;
use rusqlite::Connection;
use std::marker::PhantomData;
use std::path::{Path, PathBuf};
use std::time::Duration;
#[derive(Debug)]
pub struct ReadOnly;
#[derive(Debug)]
pub struct ReadWrite;
#[derive(Debug)]
pub struct Db<M = ReadWrite> {
conn: Connection,
path: Option<PathBuf>,
_mode: PhantomData<M>,
}
impl Db<ReadWrite> {
pub fn open<P: AsRef<Path>>(path: P) -> Result<Db> {
let p = path.as_ref().to_path_buf();
let mut conn = Connection::open(&p)?;
Self::configure(&mut conn, true)?;
Ok(Db {
conn,
path: Some(p),
_mode: PhantomData,
})
}
pub fn open_in_memory() -> Result<Db> {
let mut conn = Connection::open_in_memory()?;
Self::configure(&mut conn, false)?;
Ok(Db {
conn,
path: None,
_mode: PhantomData,
})
}
fn configure(conn: &mut Connection, wal: bool) -> Result<()> {
conn.busy_timeout(Duration::from_secs(5))?;
conn.pragma_update(None, "foreign_keys", true)?;
if wal {
let _: String = conn.query_row("PRAGMA journal_mode = WAL", [], |r| r.get(0))?;
}
schema::migrate(conn)?;
schema::validate_identity(conn)?;
Ok(())
}
pub fn into_read_only(self) -> Db<ReadOnly> {
Db {
conn: self.conn,
path: self.path,
_mode: PhantomData,
}
}
}
impl Db<ReadOnly> {
pub fn open_readonly<P: AsRef<Path>>(path: P) -> Result<Db<ReadOnly>> {
let p = path.as_ref().to_path_buf();
let conn = Connection::open_with_flags(&p, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?;
conn.busy_timeout(Duration::from_secs(5))?;
schema::validate_identity(&conn)?;
Ok(Db {
conn,
path: Some(p),
_mode: PhantomData,
})
}
}
impl<M> Db<M> {
pub fn user_version(&self) -> Result<i64> {
Ok(self
.conn
.pragma_query_value(None, "user_version", |r| r.get(0))?)
}
pub fn data_version(&self) -> Result<i64> {
Ok(self
.conn
.pragma_query_value(None, "data_version", |r| r.get(0))?)
}
pub fn path(&self) -> Option<&Path> {
self.path.as_deref()
}
#[cfg(feature = "fuzzing")]
pub fn with_raw_conn<R>(&self, f: impl FnOnce(&rusqlite::Connection) -> R) -> R {
f(&self.conn)
}
}
#[cfg(feature = "mutants")]
impl Default for Db {
fn default() -> Self {
let conn = Connection::open_in_memory().expect("in-memory sqlite open");
conn.busy_timeout(Duration::from_secs(5))
.expect("set busy_timeout");
conn.pragma_update(None, "foreign_keys", true)
.expect("enable foreign_keys");
Db {
conn,
path: None,
_mode: PhantomData,
}
}
}
#[cfg(test)]
mod tests {
use super::Db;
#[test]
fn open_uses_wal_and_busy_timeout() {
let dir = tempfile::tempdir().unwrap();
let db = Db::open(dir.path().join("t.db")).unwrap();
let mode: String = db
.conn
.pragma_query_value(None, "journal_mode", |r| r.get(0))
.unwrap();
assert_eq!(mode.to_lowercase(), "wal");
let timeout: i64 = db
.conn
.pragma_query_value(None, "busy_timeout", |r| r.get(0))
.unwrap();
assert_eq!(timeout, 5000);
}
#[test]
fn in_memory_sets_busy_timeout_without_wal() {
let db = Db::open_in_memory().unwrap();
let timeout: i64 = db
.conn
.pragma_query_value(None, "busy_timeout", |r| r.get(0))
.unwrap();
assert_eq!(timeout, 5000);
let mode: String = db
.conn
.pragma_query_value(None, "journal_mode", |r| r.get(0))
.unwrap();
assert_ne!(mode.to_lowercase(), "wal");
}
#[test]
fn open_readonly_can_read_a_file_db() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("m.db");
{
let w = Db::open(&path).unwrap();
assert!(w.path().is_some());
}
let r = Db::open_readonly(&path).unwrap();
assert!(r.data_version().is_ok());
assert_eq!(r.path().unwrap(), path.as_path());
}
#[test]
fn in_memory_has_no_path() {
let db = Db::open_in_memory().unwrap();
assert!(db.path().is_none());
}
#[test]
fn open_readonly_rejects_tampered_schema() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("t.db");
{
let db = Db::open(&path).unwrap();
db.conn.execute_batch("DROP TRIGGER tags_ai").unwrap();
}
let err = Db::open_readonly(&path).unwrap_err();
assert!(
matches!(err, crate::DbError::SchemaMismatch { .. }),
"tampered RO open must be rejected, got {err:?}"
);
}
#[test]
fn open_readonly_accepts_honest_schema() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("t.db");
Db::open(&path).unwrap();
Db::open_readonly(&path).unwrap();
}
#[test]
fn open_readonly_rejects_foreign_key_violation() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("t.db");
{
let db = Db::open(&path).unwrap();
db.conn
.execute_batch(
"PRAGMA foreign_keys=OFF; \
INSERT INTO art (sha256, mime, byte_len, data) \
VALUES ('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', \
'image/png', 1, X'00'); \
INSERT INTO track_art (track_id, art_id, picture_type, ordinal) \
VALUES (999, 1, 3, 0);",
)
.unwrap();
}
let err = Db::open_readonly(&path).unwrap_err();
match err {
crate::DbError::SchemaMismatch { object } => assert!(object.contains("foreign key")),
other => panic!("expected SchemaMismatch (fk) on RO open, got {other:?}"),
}
}
}
#[cfg(all(test, feature = "fuzzing"))]
mod fuzzing_accessor_tests {
use super::*;
use crate::models::NewTrack;
#[test]
fn with_raw_conn_plants_a_constraint_violating_row() {
let db = Db::open_in_memory().unwrap();
let id = db
.upsert_track(&NewTrack {
backing_path: "/x".to_string(),
format: Format::Flac,
audio_offset: 0,
audio_length: 0,
backing_size: 0,
backing_mtime_ns: 0,
backing_ctime_ns: 0,
})
.unwrap();
db.with_raw_conn(|conn| {
conn.execute_batch("PRAGMA ignore_check_constraints = ON")
.unwrap();
conn.execute(
"UPDATE tracks SET audio_offset = -1 WHERE id = ?1",
rusqlite::params![id],
)
.unwrap();
conn.execute_batch("PRAGMA ignore_check_constraints = OFF")
.unwrap();
});
let off: i64 = db.with_raw_conn(|conn| {
conn.query_row(
"SELECT audio_offset FROM tracks WHERE id = ?1",
rusqlite::params![id],
|r| r.get(0),
)
.unwrap()
});
assert_eq!(
off, -1,
"ignore_check_constraints let the negative offset land"
);
}
}