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#[derive(Debug)]
28pub struct ReadOnly;
29#[derive(Debug)]
30pub struct ReadWrite;
31
32#[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 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 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 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 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 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 pub fn path(&self) -> Option<&Path> {
143 self.path.as_deref()
144 }
145
146 #[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 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 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}