Skip to main content

musefs_db/
lib.rs

1mod art;
2mod bulk;
3pub mod convert;
4mod error;
5pub mod limits;
6mod maintenance;
7mod models;
8mod schema;
9mod structural;
10mod tags;
11mod tracks;
12
13pub use bulk::BulkWriter;
14pub use error::{DbError, Result};
15pub use models::{
16    Art, ArtMeta, BinaryTag, BinaryTagRow, Format, NewArt, NewTrack, StructuralBlock, Tag, Track,
17    TrackArt, TrackBounds,
18};
19pub use tracks::ChangelogRead;
20
21use rusqlite::Connection;
22use std::marker::PhantomData;
23use std::path::{Path, PathBuf};
24use std::time::Duration;
25
26/// Run a single-row query, mapping the row with `map` if present. The shared
27/// shape behind every `Result<Option<T>>` point read (`get_art`,
28/// `track_version_and_path`, the `Track` readers, …): prepare-cached, step once,
29/// map-or-`None`.
30pub(crate) fn query_optional<T>(
31    conn: &Connection,
32    sql: &str,
33    params: impl rusqlite::Params,
34    map: impl FnOnce(&rusqlite::Row) -> Result<T>,
35) -> Result<Option<T>> {
36    let mut stmt = conn.prepare_cached(sql)?;
37    let mut rows = stmt.query(params)?;
38    match rows.next()? {
39        Some(r) => Ok(Some(map(r)?)),
40        None => Ok(None),
41    }
42}
43
44/// Run an `IN (?,?,…)` query over `items` split into chunks within SQLite's bound
45/// variable limit. `make_sql` receives the comma-joined placeholder list for the
46/// chunk; `consume` drains the resulting rows (a chunk's items bind positionally
47/// in order).
48pub(crate) fn query_in_chunks<T: rusqlite::ToSql>(
49    conn: &Connection,
50    items: &[T],
51    make_sql: impl Fn(&str) -> String,
52    mut consume: impl FnMut(&mut rusqlite::Rows) -> Result<()>,
53) -> Result<()> {
54    const CHUNK: usize = 900;
55    // Full chunks share one placeholder list (and thus one cached statement);
56    // only the trailing partial chunk allocates its own.
57    let mut full_placeholders: Option<String> = None;
58    for chunk in items.chunks(CHUNK) {
59        let sql = if chunk.len() == CHUNK {
60            let ph = full_placeholders.get_or_insert_with(|| vec!["?"; CHUNK].join(","));
61            make_sql(ph)
62        } else {
63            make_sql(&vec!["?"; chunk.len()].join(","))
64        };
65        let mut stmt = conn.prepare_cached(&sql)?;
66        let mut rows = stmt.query(rusqlite::params_from_iter(chunk.iter()))?;
67        consume(&mut rows)?;
68    }
69    Ok(())
70}
71
72/// Type-state markers for [`Db`]: the connection's write capability, at the
73/// type level. Write APIs exist only on `Db<ReadWrite>`.
74#[derive(Debug)]
75pub struct ReadOnly;
76#[derive(Debug)]
77pub struct ReadWrite;
78
79/// A SQLite connection whose mode parameter says whether write APIs resolve.
80///
81/// Read methods are available in both modes; write methods only on
82/// `Db<ReadWrite>` (the default, so `Db` spelled bare means writable):
83///
84/// ```
85/// let db = musefs_db::Db::open_in_memory().unwrap().into_read_only();
86/// db.data_version().unwrap();
87/// ```
88///
89/// ```compile_fail
90/// let db = musefs_db::Db::open_in_memory().unwrap().into_read_only();
91/// db.upsert_track(unimplemented!());
92/// ```
93#[derive(Debug)]
94pub struct Db<M = ReadWrite> {
95    conn: Connection,
96    path: Option<PathBuf>,
97    _mode: PhantomData<M>,
98}
99
100impl Db<ReadWrite> {
101    pub fn open<P: AsRef<Path>>(path: P) -> Result<Db> {
102        let p = path.as_ref().to_path_buf();
103        let mut conn = Connection::open(&p)?;
104        Self::configure(&mut conn, true)?;
105        Ok(Db {
106            conn,
107            path: Some(p),
108            _mode: PhantomData,
109        })
110    }
111
112    pub fn open_in_memory() -> Result<Db> {
113        let mut conn = Connection::open_in_memory()?;
114        Self::configure(&mut conn, false)?;
115        Ok(Db {
116            conn,
117            path: None,
118            _mode: PhantomData,
119        })
120    }
121
122    /// Apply shared connection pragmas, then migrate. `wal` enables write-ahead
123    /// logging (file-backed DBs only) so a reader (the FUSE mount) and a writer
124    /// (e.g. a beets-plugin sync) don't block each other; the busy timeout lets
125    /// brief lock contention retry instead of failing immediately with
126    /// SQLITE_BUSY.
127    fn configure(conn: &mut Connection, wal: bool) -> Result<()> {
128        conn.busy_timeout(Duration::from_secs(5))?;
129        conn.pragma_update(None, "foreign_keys", true)?;
130        if wal {
131            // journal_mode returns the resulting mode; query_row consumes it
132            // (pragma_update would error on a result-returning pragma).
133            let _: String = conn.query_row("PRAGMA journal_mode = WAL", [], |r| r.get(0))?;
134        }
135        schema::migrate(conn)?;
136        schema::validate_identity(conn)?;
137        Ok(())
138    }
139
140    /// Degrade to the read-only surface, keeping the same connection. The
141    /// change is type-level only — runtime behavior is unchanged. The only
142    /// intended caller is `musefs_core`'s `DbPool::new`, which strips write
143    /// access from the mount connection before the serve path can see it.
144    pub fn into_read_only(self) -> Db<ReadOnly> {
145        Db {
146            conn: self.conn,
147            path: self.path,
148            _mode: PhantomData,
149        }
150    }
151}
152
153impl Db<ReadOnly> {
154    /// Open an additional read-only connection to an existing file-backed DB.
155    /// WAL (set by the writer) lets these run concurrently without blocking.
156    /// No migration is run — the schema already exists and the connection is RO.
157    /// Note: even with `SQLITE_OPEN_READ_ONLY`, SQLite needs write access to the
158    /// directory (to create/use the `-shm` wal-index) when the DB is in WAL mode;
159    /// a strictly read-only DB directory will make this fail.
160    pub fn open_readonly<P: AsRef<Path>>(path: P) -> Result<Db<ReadOnly>> {
161        let p = path.as_ref().to_path_buf();
162        let conn = Connection::open_with_flags(&p, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?;
163        // No configure()/migrate and no foreign_keys pragma: the schema already
164        // exists and no writes are possible on a read-only connection.
165        conn.busy_timeout(Duration::from_secs(5))?;
166        schema::validate_identity(&conn)?;
167        Ok(Db {
168            conn,
169            path: Some(p),
170            _mode: PhantomData,
171        })
172    }
173}
174
175impl<M> Db<M> {
176    pub fn user_version(&self) -> Result<i64> {
177        Ok(self
178            .conn
179            .pragma_query_value(None, "user_version", |r| r.get(0))?)
180    }
181
182    pub fn data_version(&self) -> Result<i64> {
183        Ok(self
184            .conn
185            .pragma_query_value(None, "data_version", |r| r.get(0))?)
186    }
187
188    /// The backing file path, or `None` for an in-memory database.
189    pub fn path(&self) -> Option<&Path> {
190        self.path.as_deref()
191    }
192
193    /// TEST/FUZZ ONLY (the `fuzzing` feature). Hands the raw rusqlite connection
194    /// to `f` so fuzz harnesses can plant rows the validating public API cannot
195    /// produce — e.g. negative geometry under `PRAGMA ignore_check_constraints`,
196    /// or orphaned `track_art` under `PRAGMA foreign_keys = OFF`. Never compiled
197    /// in production: the `fuzzing` feature is enabled only by the out-of-workspace
198    /// fuzz crate.
199    #[cfg(feature = "fuzzing")]
200    pub fn with_raw_conn<R>(&self, f: impl FnOnce(&rusqlite::Connection) -> R) -> R {
201        f(&self.conn)
202    }
203}
204
205#[cfg(feature = "mutants")]
206impl Default for Db {
207    /// Test-only (the `mutants` feature). An in-memory, **unmigrated** connection
208    /// (so `user_version == 0`, distinct from the always-migrated `1`). Sets the
209    /// FK/busy-timeout pragmas like a real connection, but runs no migration, so it
210    /// has **no schema**. Use only for the version-0 kill and to let
211    /// `Ok(Default::default())` mutants compile; behavioral tests use
212    /// `open_in_memory()`.
213    fn default() -> Self {
214        let conn = Connection::open_in_memory().expect("in-memory sqlite open");
215        conn.busy_timeout(Duration::from_secs(5))
216            .expect("set busy_timeout");
217        conn.pragma_update(None, "foreign_keys", true)
218            .expect("enable foreign_keys");
219        Db {
220            conn,
221            path: None,
222            _mode: PhantomData,
223        }
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::Db;
230
231    #[test]
232    fn open_uses_wal_and_busy_timeout() {
233        let dir = tempfile::tempdir().unwrap();
234        let db = Db::open(dir.path().join("t.db")).unwrap();
235        let mode: String = db
236            .conn
237            .pragma_query_value(None, "journal_mode", |r| r.get(0))
238            .unwrap();
239        assert_eq!(mode.to_lowercase(), "wal");
240        let timeout: i64 = db
241            .conn
242            .pragma_query_value(None, "busy_timeout", |r| r.get(0))
243            .unwrap();
244        assert_eq!(timeout, 5000);
245    }
246
247    #[test]
248    fn in_memory_sets_busy_timeout_without_wal() {
249        let db = Db::open_in_memory().unwrap();
250        let timeout: i64 = db
251            .conn
252            .pragma_query_value(None, "busy_timeout", |r| r.get(0))
253            .unwrap();
254        assert_eq!(timeout, 5000);
255        let mode: String = db
256            .conn
257            .pragma_query_value(None, "journal_mode", |r| r.get(0))
258            .unwrap();
259        assert_ne!(mode.to_lowercase(), "wal");
260    }
261
262    #[test]
263    fn open_readonly_can_read_a_file_db() {
264        let dir = tempfile::tempdir().unwrap();
265        let path = dir.path().join("m.db");
266        {
267            let w = Db::open(&path).unwrap();
268            assert!(w.path().is_some());
269        }
270        let r = Db::open_readonly(&path).unwrap();
271        // A read-only connection can run a read pragma without error.
272        assert!(r.data_version().is_ok());
273        assert_eq!(r.path().unwrap(), path.as_path());
274    }
275
276    #[test]
277    fn in_memory_has_no_path() {
278        let db = Db::open_in_memory().unwrap();
279        assert!(db.path().is_none());
280    }
281
282    #[test]
283    fn open_readonly_rejects_tampered_schema() {
284        let dir = tempfile::tempdir().unwrap();
285        let path = dir.path().join("t.db");
286        {
287            let db = Db::open(&path).unwrap();
288            db.conn.execute_batch("DROP TRIGGER tags_ai").unwrap();
289        }
290        let err = Db::open_readonly(&path).unwrap_err();
291        assert!(
292            matches!(err, crate::DbError::SchemaMismatch { .. }),
293            "tampered RO open must be rejected, got {err:?}"
294        );
295    }
296
297    #[test]
298    fn open_readonly_accepts_honest_schema() {
299        let dir = tempfile::tempdir().unwrap();
300        let path = dir.path().join("t.db");
301        Db::open(&path).unwrap();
302        Db::open_readonly(&path).unwrap();
303    }
304
305    #[test]
306    fn open_readonly_rejects_foreign_key_violation() {
307        let dir = tempfile::tempdir().unwrap();
308        let path = dir.path().join("t.db");
309        {
310            let db = Db::open(&path).unwrap();
311            db.conn
312                .execute_batch(
313                    "PRAGMA foreign_keys=OFF; \
314                     INSERT INTO art (sha256, mime, byte_len, data) \
315                     VALUES ('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', \
316                             'image/png', 1, X'00'); \
317                     INSERT INTO track_art (track_id, art_id, picture_type, ordinal) \
318                     VALUES (999, 1, 3, 0);",
319                )
320                .unwrap();
321        }
322        let err = Db::open_readonly(&path).unwrap_err();
323        match err {
324            crate::DbError::SchemaMismatch { object } => assert!(object.contains("foreign key")),
325            other => panic!("expected SchemaMismatch (fk) on RO open, got {other:?}"),
326        }
327    }
328}
329
330#[cfg(all(test, feature = "fuzzing"))]
331mod fuzzing_accessor_tests {
332    use super::*;
333    use crate::models::NewTrack;
334
335    #[test]
336    fn with_raw_conn_plants_a_constraint_violating_row() {
337        let db = Db::open_in_memory().unwrap();
338        let id = db
339            .upsert_track(&NewTrack {
340                backing_path: "/x".to_string(),
341                format: Format::Flac,
342                audio_offset: 0,
343                audio_length: 0,
344                backing_size: 0,
345                backing_mtime_ns: 0,
346                backing_ctime_ns: 0,
347            })
348            .unwrap();
349
350        db.with_raw_conn(|conn| {
351            conn.execute_batch("PRAGMA ignore_check_constraints = ON")
352                .unwrap();
353            conn.execute(
354                "UPDATE tracks SET audio_offset = -1 WHERE id = ?1",
355                rusqlite::params![id],
356            )
357            .unwrap();
358            conn.execute_batch("PRAGMA ignore_check_constraints = OFF")
359                .unwrap();
360        });
361
362        let off: i64 = db.with_raw_conn(|conn| {
363            conn.query_row(
364                "SELECT audio_offset FROM tracks WHERE id = ?1",
365                rusqlite::params![id],
366                |r| r.get(0),
367            )
368            .unwrap()
369        });
370        assert_eq!(
371            off, -1,
372            "ignore_check_constraints let the negative offset land"
373        );
374    }
375}