Skip to main content

musefs_db/
art.rs

1use crate::error::{check_art_count, check_field_len};
2use crate::limits::{MAX_ART_DESCRIPTION_LEN, MAX_ART_MIME_LEN};
3use crate::models::{Art, ArtMeta, NewArt, TrackArt};
4use crate::{Db, ReadWrite, Result};
5use rusqlite::params;
6use sha2::{Digest, Sha256};
7
8pub(crate) fn sha256_hex(data: &[u8]) -> String {
9    format!("{:x}", base16ct::HexDisplay(&Sha256::digest(data)))
10}
11
12impl<M> Db<M> {
13    pub fn get_art(&self, id: i64) -> Result<Option<Art>> {
14        crate::query_optional(
15            &self.conn,
16            "SELECT id, sha256, mime, width, height, byte_len, data FROM art WHERE id = ?1",
17            params![id],
18            |r| {
19                Ok(Art {
20                    id: r.get(0)?,
21                    sha256: r.get(1)?,
22                    mime: r.get(2)?,
23                    width: r.get(3)?,
24                    height: r.get(4)?,
25                    byte_len: r.get(5)?,
26                    data: r.get(6)?,
27                })
28            },
29        )
30    }
31
32    /// Art row metadata without loading the image blob — used to build synthesis
33    /// inputs at resolve time without materializing art in memory.
34    pub fn get_art_meta(&self, id: i64) -> Result<Option<ArtMeta>> {
35        crate::query_optional(
36            &self.conn,
37            "SELECT length(mime), mime, width, height, byte_len FROM art WHERE id = ?1",
38            params![id],
39            |r| {
40                check_field_len("art", "mime", r.get(0)?, MAX_ART_MIME_LEN)?;
41                Ok(ArtMeta {
42                    mime: r.get(1)?,
43                    width: r.get(2)?,
44                    height: r.get(3)?,
45                    byte_len: r.get(4)?,
46                })
47            },
48        )
49    }
50
51    /// Stream art-blob bytes at `offset` directly into `buf` via SQLite incremental
52    /// blob I/O — no intermediate allocation (#70). A short read means the row no
53    /// longer matches the layout; `read_at_exact` surfaces that as an error rather
54    /// than silently zero-filling.
55    pub fn read_art_chunk_into(&self, art_id: i64, offset: u64, buf: &mut [u8]) -> Result<()> {
56        let blob = self.conn.blob_open("main", "art", "data", art_id, true)?;
57        blob.read_at_exact(buf, crate::convert::usize_from(offset))?;
58        Ok(())
59    }
60
61    /// Allocating convenience form of `read_art_chunk_into` (non-hot-path callers).
62    pub fn read_art_chunk(&self, art_id: i64, offset: u64, len: usize) -> Result<Vec<u8>> {
63        let mut buf = vec![0u8; len];
64        self.read_art_chunk_into(art_id, offset, &mut buf)?;
65        Ok(buf)
66    }
67
68    pub fn get_track_art(&self, track_id: i64) -> Result<Vec<TrackArt>> {
69        let mut stmt = self.conn.prepare_cached(
70            "SELECT length(description), art_id, picture_type, description, ordinal
71             FROM track_art WHERE track_id = ?1 ORDER BY ordinal",
72        )?;
73        let mut rows = stmt.query(params![track_id])?;
74        let mut out = Vec::new();
75        while let Some(r) = rows.next()? {
76            check_field_len(
77                "track_art",
78                "description",
79                r.get(0)?,
80                MAX_ART_DESCRIPTION_LEN,
81            )?;
82            out.push(TrackArt {
83                art_id: r.get(1)?,
84                picture_type: r.get(2)?,
85                description: r.get(3)?,
86                ordinal: r.get(4)?,
87            });
88            check_art_count(track_id, out.len())?;
89        }
90        Ok(out)
91    }
92
93    /// A track's `track_art` links joined with their `art` row metadata (no
94    /// image blob), in one query — collapses the former N+1 (`get_track_art`
95    /// plus one `get_art_meta` per row) on the resolve hot path. The `art` side
96    /// is `None` for an orphaned link: SQLite FK enforcement is per-connection,
97    /// so an external writer can leave a `track_art` row dangling, and the
98    /// caller surfaces that rather than silently dropping the art.
99    pub fn get_track_art_with_meta(
100        &self,
101        track_id: i64,
102    ) -> Result<Vec<(TrackArt, Option<ArtMeta>)>> {
103        let mut stmt = self.conn.prepare_cached(
104            "SELECT length(ta.description), ta.art_id, ta.picture_type, ta.description, ta.ordinal, \
105             length(a.mime), a.mime, a.width, a.height, a.byte_len \
106             FROM track_art ta LEFT JOIN art a ON a.id = ta.art_id \
107             WHERE ta.track_id = ?1 ORDER BY ta.ordinal",
108        )?;
109        let mut rows = stmt.query(params![track_id])?;
110        let mut out = Vec::new();
111        while let Some(r) = rows.next()? {
112            check_field_len(
113                "track_art",
114                "description",
115                r.get(0)?,
116                MAX_ART_DESCRIPTION_LEN,
117            )?;
118            let track_art = TrackArt {
119                art_id: r.get(1)?,
120                picture_type: r.get(2)?,
121                description: r.get(3)?,
122                ordinal: r.get(4)?,
123            };
124            // A NULL `length(a.mime)` means the LEFT JOIN found no `art` row
125            // (orphaned link); `mime` is NOT NULL in the schema, so the length
126            // column is a reliable presence sentinel — and checking it lets us
127            // reject an over-cap mime before the string is ever materialized
128            // (the allocation-free guarantee, spec N13).
129            let meta = match r.get::<_, Option<i64>>(5)? {
130                Some(mime_len) => {
131                    check_field_len("art", "mime", mime_len, MAX_ART_MIME_LEN)?;
132                    Some(ArtMeta {
133                        mime: r.get(6)?,
134                        width: r.get(7)?,
135                        height: r.get(8)?,
136                        byte_len: r.get(9)?,
137                    })
138                }
139                None => None,
140            };
141            out.push((track_art, meta));
142            check_art_count(track_id, out.len())?;
143        }
144        Ok(out)
145    }
146}
147
148/// Insert `a` (deduplicated by content sha256) and return its `art` id. Runs on
149/// `conn` so `Db<ReadWrite>` and `BulkWriter` share one body.
150pub(crate) fn upsert_art_in(conn: &rusqlite::Connection, a: &NewArt) -> Result<i64> {
151    let sha = sha256_hex(&a.data);
152    conn.execute(
153        "INSERT INTO art (sha256, mime, width, height, byte_len, data)
154         VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT(sha256) DO NOTHING",
155        params![sha, a.mime, a.width, a.height, a.data.len() as u64, a.data],
156    )?;
157    Ok(
158        conn.query_row("SELECT id FROM art WHERE sha256 = ?1", params![sha], |r| {
159            r.get(0)
160        })?,
161    )
162}
163
164/// Replace a track's `track_art` links. Runs on `conn` so `Db<ReadWrite>` (own
165/// transaction) and `BulkWriter` (caller-held transaction) share one body.
166pub(crate) fn set_track_art_in(
167    conn: &rusqlite::Connection,
168    track_id: i64,
169    items: &[TrackArt],
170) -> Result<()> {
171    conn.execute(
172        "DELETE FROM track_art WHERE track_id = ?1",
173        params![track_id],
174    )?;
175    let mut stmt = conn.prepare_cached(
176        "INSERT INTO track_art (track_id, art_id, picture_type, description, ordinal)
177         VALUES (?1, ?2, ?3, ?4, ?5)",
178    )?;
179    for it in items {
180        stmt.execute(params![
181            track_id,
182            it.art_id,
183            it.picture_type,
184            it.description,
185            it.ordinal
186        ])?;
187    }
188    Ok(())
189}
190
191impl Db<ReadWrite> {
192    pub fn upsert_art(&self, a: &NewArt) -> Result<i64> {
193        upsert_art_in(&self.conn, a)
194    }
195
196    pub fn set_track_art(&self, track_id: i64, items: &[TrackArt]) -> Result<()> {
197        let tx = self.conn.unchecked_transaction()?;
198        set_track_art_in(&tx, track_id, items)?;
199        tx.commit()?;
200        Ok(())
201    }
202
203    /// Delete `art` rows no longer referenced by any `track_art`. Returns the
204    /// number of rows removed.
205    ///
206    /// Uses `NOT EXISTS` rather than `NOT IN (subquery)`: SQLite's `NOT IN`
207    /// evaluates to UNKNOWN for the whole row if any subquery value is NULL,
208    /// which would silently delete nothing should a NULL `art_id` ever reach
209    /// `track_art` (#507). `NOT EXISTS` is NULL-safe.
210    pub fn gc_orphan_art(&self) -> Result<usize> {
211        let removed = self.conn.execute(
212            "DELETE FROM art WHERE NOT EXISTS \
213             (SELECT 1 FROM track_art WHERE track_art.art_id = art.id)",
214            [],
215        )?;
216        Ok(removed)
217    }
218}
219
220#[cfg(test)]
221mod guard_tests {
222    use crate::error::DbError;
223    use crate::models::{NewArt, TrackArt};
224    use crate::{Db, Format, NewTrack};
225
226    fn db_track_art() -> (Db, i64, i64) {
227        let db = Db::open_in_memory().unwrap();
228        let track = db
229            .upsert_track(&NewTrack {
230                backing_path: "/a.flac".into(),
231                format: Format::Flac,
232                audio_offset: 0,
233                audio_length: 1,
234                backing_size: 1,
235                backing_mtime_ns: 0,
236                backing_ctime_ns: 0,
237            })
238            .unwrap();
239        let art = db
240            .upsert_art(&NewArt {
241                mime: "image/png".into(),
242                width: None,
243                height: None,
244                data: vec![0u8],
245            })
246            .unwrap();
247        (db, track, art)
248    }
249
250    #[test]
251    fn get_art_meta_rejects_oversize_mime() {
252        let (db, _t, _art) = db_track_art();
253        db.conn
254            .execute_batch("PRAGMA ignore_check_constraints=ON")
255            .unwrap();
256        // art rows are immutable under the V5 `art_reject_content_update`
257        // trigger (which `ignore_check_constraints` does not disable), so plant
258        // the oversize-mime row with a fresh INSERT — the trigger guards only
259        // UPDATE — rather than mutating an existing row in place.
260        let mime = "x".repeat(256);
261        db.conn
262            .execute(
263                "INSERT INTO art (sha256, mime, width, height, byte_len, data) \
264                 VALUES (?1, ?2, NULL, NULL, 1, X'00')",
265                rusqlite::params!["b".repeat(64), mime],
266            )
267            .unwrap();
268        let bad = db.conn.last_insert_rowid();
269        let err = db.get_art_meta(bad).unwrap_err();
270        assert!(
271            matches!(
272                err,
273                DbError::FieldTooLarge {
274                    table: "art",
275                    field: "mime",
276                    ..
277                }
278            ),
279            "{err:?}"
280        );
281    }
282
283    #[test]
284    fn get_track_art_rejects_oversize_description() {
285        let (db, track, art) = db_track_art();
286        db.conn
287            .execute_batch("PRAGMA ignore_check_constraints=ON")
288            .unwrap();
289        let desc = "d".repeat(1025);
290        db.set_track_art(
291            track,
292            &[TrackArt {
293                art_id: art,
294                picture_type: 3,
295                description: desc,
296                ordinal: 0,
297            }],
298        )
299        .unwrap();
300        let err = db.get_track_art(track).unwrap_err();
301        assert!(
302            matches!(
303                err,
304                DbError::FieldTooLarge {
305                    table: "track_art",
306                    field: "description",
307                    ..
308                }
309            ),
310            "{err:?}"
311        );
312    }
313
314    #[test]
315    fn get_track_art_accepts_description_at_cap() {
316        let (db, track, art) = db_track_art();
317        let desc = "d".repeat(1024);
318        db.set_track_art(
319            track,
320            &[TrackArt {
321                art_id: art,
322                picture_type: 3,
323                description: desc,
324                ordinal: 0,
325            }],
326        )
327        .unwrap();
328        assert_eq!(db.get_track_art(track).unwrap()[0].description.len(), 1024);
329    }
330
331    #[test]
332    fn get_track_art_rejects_excess_rows() {
333        let (db, track, art) = db_track_art();
334        // 4097 track_art rows sharing one art_id -> TooManyArtRows. Raw INSERT
335        // (not set_track_art) keeps the fixture to a single planted blob; the
336        // PRIMARY KEY (track_id, ordinal) is satisfied by the distinct ordinals.
337        let tx = db.conn.unchecked_transaction().unwrap();
338        let mut stmt = tx
339            .prepare(
340                "INSERT INTO track_art (track_id, art_id, picture_type, description, ordinal) \
341                 VALUES (?1, ?2, 3, '', ?3)",
342            )
343            .unwrap();
344        for i in 0..4097 {
345            stmt.execute(rusqlite::params![track, art, i]).unwrap();
346        }
347        drop(stmt);
348        tx.commit().unwrap();
349        let err = db.get_track_art(track).unwrap_err();
350        assert!(matches!(err, DbError::TooManyArtRows { .. }), "{err:?}");
351    }
352
353    #[test]
354    fn get_track_art_accepts_rows_at_cap() {
355        let (db, track, art) = db_track_art();
356        let tx = db.conn.unchecked_transaction().unwrap();
357        let mut stmt = tx
358            .prepare(
359                "INSERT INTO track_art (track_id, art_id, picture_type, description, ordinal) \
360                 VALUES (?1, ?2, 3, '', ?3)",
361            )
362            .unwrap();
363        for i in 0..4096 {
364            stmt.execute(rusqlite::params![track, art, i]).unwrap();
365        }
366        drop(stmt);
367        tx.commit().unwrap();
368        assert_eq!(db.get_track_art(track).unwrap().len(), 4096);
369    }
370
371    #[test]
372    fn sha256_hex_matches_known_digest() {
373        // NIST sample vector: sha256("abc").
374        assert_eq!(
375            super::sha256_hex(b"abc"),
376            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
377        );
378    }
379
380    #[test]
381    fn gc_orphan_art_not_exists_is_null_safe() {
382        // Regression for #507: the orphan-art GC must use NOT EXISTS, not
383        // NOT IN (subquery). With a NULL in the subquery, SQLite's NOT IN
384        // evaluates to UNKNOWN for every row and deletes nothing; NOT EXISTS
385        // is unaffected. The live schema's NOT NULL on `track_art.art_id`
386        // makes this unreachable today, so the NULL is reproduced on a relaxed
387        // scratch schema to pin the pattern choice against regression.
388        let conn = rusqlite::Connection::open_in_memory().unwrap();
389        conn.execute_batch(
390            "CREATE TABLE art (id INTEGER PRIMARY KEY);
391             CREATE TABLE track_art (art_id INTEGER);
392             INSERT INTO art (id) VALUES (1), (2);
393             INSERT INTO track_art (art_id) VALUES (1), (NULL);",
394        )
395        .unwrap();
396
397        // The buggy NOT IN form deletes nothing because of the NULL.
398        let not_in = conn
399            .execute(
400                "DELETE FROM art WHERE id NOT IN (SELECT art_id FROM track_art)",
401                [],
402            )
403            .unwrap();
404        assert_eq!(not_in, 0, "NOT IN deletes nothing when a NULL is present");
405
406        // The shipped NOT EXISTS form removes the genuine orphan (id 2).
407        let not_exists = conn
408            .execute(
409                "DELETE FROM art WHERE NOT EXISTS \
410                 (SELECT 1 FROM track_art WHERE track_art.art_id = art.id)",
411                [],
412            )
413            .unwrap();
414        assert_eq!(
415            not_exists, 1,
416            "NOT EXISTS removes the orphan despite the NULL"
417        );
418    }
419}