Skip to main content

musefs_db/
lib.rs

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