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
26pub(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
44pub(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 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#[derive(Debug)]
75pub struct ReadOnly;
76#[derive(Debug)]
77pub struct ReadWrite;
78
79#[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 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 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 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 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 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 pub fn path(&self) -> Option<&Path> {
190 self.path.as_deref()
191 }
192
193 #[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 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 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}