Skip to main content

musefs_core/
reader.rs

1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4use std::sync::Mutex;
5
6use musefs_db::convert::usize_from;
7use musefs_db::{Db, Format};
8use musefs_format::flac::{self, MetadataBlock};
9use musefs_format::{BinaryTagInput, RegionLayout, Segment, mp3, mp4, wav};
10use quick_cache::Weighter;
11use quick_cache::sync::Cache;
12
13use crate::error::{CoreError, Result};
14use crate::facade::Mode;
15use crate::freshness::BackingStamp;
16use crate::mapping::{tags_to_inputs, track_art_to_inputs};
17use crate::ogg_index::serve_ogg_window;
18
19/// A fully resolved synthesized file: its segment layout, total size, the
20/// content version it was built from, and where the backing audio lives.
21#[derive(Debug)]
22pub struct ResolvedFile {
23    pub layout: RegionLayout,
24    pub total_len: u64,
25    pub content_version: i64,
26    pub backing_path: PathBuf,
27    pub stamp: BackingStamp,
28    pub mtime_secs: i64,
29    /// One-entry memo of the last patched Ogg page, so consecutive reads skip
30    /// re-patching the page straddling a chunk boundary. Empty for non-Ogg files
31    /// and reset whenever this resolved entry is rebuilt. (Concrete type spelled
32    /// out rather than `ogg_index::LastPageMemo` because that module is private.)
33    pub last_page: Mutex<Option<(u64, u64, Vec<u8>)>>,
34    /// Approximate resident bytes this entry costs the cache (sum of `Inline`
35    /// segment bytes; backing/art/ogg-audio bytes are not resident).
36    pub cache_bytes: u64,
37    /// Precomputed from the layout: true if any segment streams an opaque binary
38    /// tag payload from the DB. Gates the transactional `content_version` guard in
39    /// the read fast path so plain Inline/BackingAudio layouts pay no per-read cost.
40    pub has_binary_tag: bool,
41}
42
43/// Weighs an entry by its resident inline bytes. The `.max(1)` is load-bearing:
44/// quick_cache ignores zero-weight entries when evicting, and every
45/// StructureOnly layout has `cache_bytes == 0`, so an unweighted entry would
46/// escape the byte budget entirely.
47#[derive(Clone)]
48struct CacheBytesWeighter;
49
50impl Weighter<i64, Arc<ResolvedFile>> for CacheBytesWeighter {
51    fn weight(&self, _key: &i64, val: &Arc<ResolvedFile>) -> u64 {
52        val.cache_bytes.max(1)
53    }
54}
55
56/// A per-mount cache of resolved files keyed by track id; an entry
57/// self-invalidates when the track's `content_version` changes. Backed by
58/// quick_cache: S3-FIFO eviction, byte-weighted, internally sharded.
59pub struct HeaderCache {
60    cache: Cache<i64, Arc<ResolvedFile>, CacheBytesWeighter>,
61    mode: Mode,
62}
63
64/// Default resident-bytes budget for the header cache (64 MiB).
65pub const DEFAULT_CACHE_BUDGET: u64 = 64 * 1024 * 1024;
66
67/// Item-count sizing hint for quick_cache's internal structures (not a bound):
68/// the default budget over 4 KiB, a typical inline tag region. The hint has no
69/// observable public-API behavior, so its arithmetic carries an equivalent-mutant
70/// exclusion in .cargo/mutants.toml (cargo-mutants does mutate const initializers).
71const CACHE_ESTIMATED_ITEMS: usize = (DEFAULT_CACHE_BUDGET / 4096) as usize;
72
73fn read_front(path: &Path, n: u64) -> crate::Result<Vec<u8>> {
74    use std::io::Read;
75    // Fail closed before any allocation/open: a hostile DB row can request an
76    // arbitrary `audio_offset`, but no legitimately-scanned file has a front
77    // larger than the scanner's probe ceiling. Bounding `n` here also retires a
78    // 32-bit `usize_from` truncation footgun.
79    if n > crate::scan::MAX_PROBE_BYTES {
80        return Err(CoreError::HeaderTooLarge {
81            requested: n,
82            cap: crate::scan::MAX_PROBE_BYTES,
83        });
84    }
85    crate::metrics::on_open();
86    let mut f = std::fs::File::open(path)?;
87    let mut buf = vec![0u8; usize_from(n)];
88    f.read_exact(&mut buf)?;
89    Ok(buf)
90}
91
92impl HeaderCache {
93    pub fn new(mode: Mode) -> HeaderCache {
94        HeaderCache::with_budget(mode, DEFAULT_CACHE_BUDGET)
95    }
96    pub fn with_budget(mode: Mode, budget: u64) -> HeaderCache {
97        HeaderCache {
98            cache: Cache::with_weighter(CACHE_ESTIMATED_ITEMS, budget, CacheBytesWeighter),
99            mode,
100        }
101    }
102    /// Drop cached resolutions for tracks no longer present (`live` = current ids).
103    pub fn retain(&self, live: &HashSet<i64>) {
104        self.cache.retain(|id, _| live.contains(id));
105    }
106    /// Drop one track's cached resolution (changelog-refresh removal path).
107    pub fn remove(&self, id: i64) {
108        self.cache.remove(&id);
109    }
110    /// Resolve a track to its layout, caching on a content-version miss. Validation
111    /// (`stat`) and synthesis run outside the cache; quick_cache's internal locks
112    /// are only touched by the brief get and insert.
113    pub fn resolve<M>(&self, db: &Db<M>, track_id: i64) -> Result<Arc<ResolvedFile>> {
114        let track = db
115            .get_track(track_id)?
116            .ok_or(CoreError::TrackNotFound(track_id))?;
117
118        // Always validate the backing file first — a stale file is an error even
119        // on a cache hit, because the audio region may have shifted.
120        crate::metrics::on_stat();
121        let meta = std::fs::metadata(&track.backing_path)?;
122        if BackingStamp::from_metadata(&meta) != BackingStamp::from_track(&track) {
123            return Err(CoreError::BackingChanged(track.backing_path.clone()));
124        }
125
126        if let Some(hit) = self.cache.get(&track_id)
127            && hit.content_version == track.content_version
128        {
129            return Ok(hit);
130        }
131        let resolved = self.build(db, &track, &meta)?;
132        self.cache.insert(track_id, resolved.clone());
133        Ok(resolved)
134    }
135    /// Build a `ResolvedFile` for `track` (synthesis or passthrough). No lock held.
136    fn build<M>(
137        &self,
138        db: &Db<M>,
139        track: &musefs_db::Track,
140        meta: &std::fs::Metadata,
141    ) -> Result<Arc<ResolvedFile>> {
142        let (layout, total_len, mtime_secs_val) = match self.mode {
143            Mode::StructureOnly => {
144                // Pure passthrough: the synthesized "file" is the backing file itself.
145                // The stored audio bounds are irrelevant here — the whole file is served
146                // verbatim — so they are not validated in this mode.
147                let layout = RegionLayout::validated(vec![Segment::BackingAudio {
148                    offset: 0,
149                    len: meta.len(),
150                }])
151                .map_err(musefs_format::FormatError::InvalidLayout)?;
152                (
153                    layout,
154                    meta.len(),
155                    BackingStamp::from_track(track).display_secs(),
156                )
157            }
158            Mode::Synthesis => {
159                // Guard the stored audio bounds before any cast/allocation: a negative
160                // bound, or an audio region that runs past the end of the backing file,
161                // means the row no longer matches the file. Only synthesis splices at
162                // these bounds, so the check is scoped to this mode.
163                if track
164                    .bounds
165                    .audio_offset()
166                    .saturating_add(track.bounds.audio_length())
167                    > meta.len()
168                {
169                    return Err(CoreError::BackingChanged(track.backing_path.clone()));
170                }
171
172                let inputs = tags_to_inputs(db.get_tags(track.id)?);
173                let art_inputs = track_art_to_inputs(db, track.id)?;
174                let binary_tag_inputs = crate::mapping::binary_tags_to_inputs(db, track.id)?;
175
176                // FLAC re-reads the front for its preserved structural blocks; MP3 needs no
177                // front read — its ID3v2 tag is regenerated entirely from the DB and the
178                // Xing/LAME info frame travels with the backing audio.
179                let layout = match track.format {
180                    Format::Flac => {
181                        let rows = db.get_structural_blocks(track.id)?;
182                        // Fast path: the structural store holds STREAMINFO/SEEKTABLE and
183                        // APPLICATION/CUESHEET stream from value_blob rows. Legacy
184                        // fallback (no structural rows yet): carry every preserved block
185                        // — including APPLICATION/CUESHEET — inline from the front
186                        // re-read, and suppress the streamed binary tags so those blocks
187                        // are not emitted twice.
188                        let (structural, binary_tags): (Vec<MetadataBlock>, &[BinaryTagInput]) =
189                            if rows.is_empty() {
190                                let front = read_front(
191                                    Path::new(&track.backing_path),
192                                    track.bounds.audio_offset(),
193                                )?;
194                                (flac::read_metadata(&front)?.preserved, &[])
195                            } else {
196                                let structural = rows
197                                    .into_iter()
198                                    .filter_map(|b| {
199                                        flac::structural_block_type(&b.kind).map(|block_type| {
200                                            MetadataBlock {
201                                                block_type,
202                                                body: b.body,
203                                            }
204                                        })
205                                    })
206                                    .collect();
207                                (structural, &binary_tag_inputs)
208                            };
209                        for key in invalid_vorbis_keys(&inputs) {
210                            log::warn!(
211                                "track {}: dropping tag key {key:?} from Vorbis \
212                                 synthesis (not a valid field name)",
213                                track.id
214                            );
215                        }
216                        flac::synthesize_layout(
217                            &structural,
218                            track.bounds.audio_offset(),
219                            track.bounds.audio_length(),
220                            &inputs,
221                            binary_tags,
222                            &art_inputs,
223                        )?
224                    }
225                    Format::Mp3 => mp3::synthesize_layout(
226                        track.bounds.audio_offset(),
227                        track.bounds.audio_length(),
228                        &inputs,
229                        &binary_tag_inputs,
230                        &art_inputs,
231                    )?,
232                    Format::M4a => {
233                        // Read only the structural boxes (ftyp/moov/mdat header) by
234                        // seeking — never the (potentially hundreds-of-MB) mdat payload,
235                        // which is served from the backing file at read time. The `moov`
236                        // box may sit at EOF; the streaming reader skips the mdat payload
237                        // to reach it. The resulting layout's leading inline `head` ends
238                        // in a deliberately truncated `mdat` header whose payload is the
239                        // backing-audio tail.
240                        let mut f = std::fs::File::open(&track.backing_path)?;
241                        // `meta` was validated against the tracked size/mtime above,
242                        // so reuse it rather than issuing a second fstat.
243                        let len = meta.len();
244                        let scan = mp4::read_structure_from(&mut f, len).map_err(|e| match e {
245                            mp4::Mp4ScanError::Io(io) => CoreError::Io(io),
246                            mp4::Mp4ScanError::Format(fe) => CoreError::Format(fe),
247                            // Unreachable in practice (an ingested file already
248                            // passed the cap at scan, and backing-file drift is
249                            // caught by the size/mtime BackingChanged guard first),
250                            // but preserve the box/size/cap diagnostics rather than
251                            // erasing them into a generic Malformed.
252                            mp4::Mp4ScanError::MetadataTooLarge {
253                                box_kind,
254                                size,
255                                cap,
256                            } => CoreError::Mp4MetadataTooLarge {
257                                box_kind,
258                                size,
259                                cap,
260                            },
261                        })?;
262                        mp4::synthesize_layout(&scan, &inputs, &binary_tag_inputs, &art_inputs)?
263                    }
264                    Format::Wav => {
265                        // Read only the front (RIFF header + fmt/fact); the data
266                        // payload is served from the backing file at read time.
267                        let front = read_front(
268                            Path::new(&track.backing_path),
269                            track.bounds.audio_offset(),
270                        )?;
271                        let scan = wav::read_structure(&front)?;
272                        wav::synthesize_layout(
273                            &scan,
274                            track.bounds.audio_offset(),
275                            track.bounds.audio_length(),
276                            &inputs,
277                            &binary_tag_inputs,
278                            &art_inputs,
279                        )?
280                    }
281                    Format::Opus | Format::Vorbis | Format::OggFlac => {
282                        let front = read_front(
283                            Path::new(&track.backing_path),
284                            track.bounds.audio_offset(),
285                        )?;
286                        let header = musefs_format::ogg::read_metadata(&front)?;
287                        let arts: Vec<musefs_format::ogg::OggArt> = art_inputs
288                            .iter()
289                            .map(|meta| musefs_format::ogg::OggArt { meta })
290                            .collect();
291                        let src = crate::mapping::DbArtSource(db);
292                        for key in invalid_vorbis_keys(&inputs) {
293                            log::warn!(
294                                "track {}: dropping tag key {key:?} from Vorbis \
295                                 synthesis (not a valid field name)",
296                                track.id
297                            );
298                        }
299                        musefs_format::ogg::synthesize_layout(
300                            &header,
301                            track.bounds.audio_offset(),
302                            track.bounds.audio_length(),
303                            &inputs,
304                            &arts,
305                            &src,
306                        )?
307                    }
308                };
309                let total = layout.total_len();
310                (
311                    layout,
312                    total,
313                    BackingStamp::from_track(track)
314                        .display_secs()
315                        .max(track.updated_at),
316                )
317            }
318        };
319
320        // Defensive belt-and-suspenders: production layouts are already built via
321        // RegionLayout::validated, but re-validate at the cache boundary so a future
322        // construction path that skips validation cannot poison the cache.
323        layout
324            .validate()
325            .map_err(musefs_format::FormatError::InvalidLayout)?;
326
327        let cache_bytes = layout
328            .segments()
329            .iter()
330            .map(|s| match s {
331                Segment::Inline(b) => b.len() as u64,
332                _ => 0,
333            })
334            .sum::<u64>();
335        let has_binary_tag = layout.has_binary_tag();
336        Ok(Arc::new(ResolvedFile {
337            layout,
338            total_len,
339            content_version: track.content_version,
340            backing_path: PathBuf::from(&track.backing_path),
341            stamp: BackingStamp::from_track(track),
342            mtime_secs: mtime_secs_val,
343            last_page: Mutex::new(None),
344            cache_bytes,
345            has_binary_tag,
346        }))
347    }
348}
349
350/// Read `size` bytes at virtual `offset` into `out` (appended), opening the
351/// backing file once for this call if the layout needs it.
352pub fn read_at_into<M>(
353    resolved: &ResolvedFile,
354    db: &Db<M>,
355    offset: u64,
356    size: u64,
357    out: &mut Vec<u8>,
358) -> Result<()> {
359    if offset >= resolved.total_len || size == 0 {
360        return Ok(());
361    }
362    let needs_file = resolved
363        .layout
364        .segments()
365        .iter()
366        .any(|s| matches!(s, Segment::BackingAudio { .. } | Segment::OggAudio { .. }));
367    if needs_file {
368        crate::metrics::on_open();
369        let file = std::fs::File::open(&resolved.backing_path)?;
370        read_segments_into(resolved, db, Some(&file), offset, size, out)
371    } else {
372        read_segments_into(resolved, db, None, offset, size, out)
373    }
374}
375
376/// The distinct user-defined keys in `inputs` that the Vorbis synthesis path
377/// drops because they are not valid field names. Pure and unit-tested; the
378/// caller logs them so a silently-dropped key is observable. Deduped so a
379/// multi-valued bad key warns once, not once per value.
380fn invalid_vorbis_keys(inputs: &[musefs_format::TagInput]) -> Vec<&str> {
381    let mut seen = HashSet::new();
382    inputs
383        .iter()
384        .map(|t| t.key.as_str())
385        .filter(|k| !musefs_format::is_valid_vorbis_key(k))
386        .filter(|k| seen.insert(*k))
387        .collect()
388}
389
390/// Allocating form of `read_at_into` (tests and non-hot-path callers).
391pub fn read_at<M>(resolved: &ResolvedFile, db: &Db<M>, offset: u64, size: u64) -> Result<Vec<u8>> {
392    let mut out = Vec::new();
393    read_at_into(resolved, db, offset, size, &mut out)?;
394    Ok(out)
395}
396
397/// The single segment-splicing loop. `file` is `Some` whenever the layout has a
398/// `BackingAudio`/`OggAudio` segment (guaranteed by `read_at`/`read_at_with_file`);
399/// the backing arms treat `None` as a contract violation.
400fn read_segments_into<M>(
401    resolved: &ResolvedFile,
402    db: &Db<M>,
403    file: Option<&std::fs::File>,
404    offset: u64,
405    size: u64,
406    out: &mut Vec<u8>,
407) -> Result<()> {
408    if offset >= resolved.total_len || size == 0 {
409        return Ok(());
410    }
411    let end = offset.saturating_add(size).min(resolved.total_len);
412    out.reserve(usize_from(end - offset));
413
414    let mut seg_start = 0u64;
415    for seg in resolved.layout.segments() {
416        let seg_len = seg.len();
417        let seg_end = seg_start + seg_len;
418        let ov_start = offset.max(seg_start);
419        let ov_end = end.min(seg_end);
420        if ov_start < ov_end {
421            let within = ov_start - seg_start;
422            let n = usize_from(ov_end - ov_start);
423            match seg {
424                Segment::Inline(bytes) => {
425                    let w = usize_from(within);
426                    out.extend_from_slice(&bytes[w..w + n]);
427                }
428                Segment::BackingAudio { offset: bo, .. } => {
429                    let f = file.expect("backing segment requires an open backing file");
430                    // Finding #15 (ESTALE, untested by design): on an NFS-backed mount a stale file
431                    // handle surfaces here as a raw io::Error from the positioned read (or as
432                    // BackingChanged from the size/mtime re-validation) and is propagated verbatim
433                    // through the FUSE layer. There is no test-framework support to inject NFS ESTALE,
434                    // so this path is documented rather than covered.
435                    let start = out.len();
436                    out.resize(start + n, 0);
437                    crate::metrics::backing_read_exact_at(f, &mut out[start..], bo + within)?;
438                    crate::metrics::on_pread(n as u64);
439                }
440                Segment::ArtImage { art_id, .. } => {
441                    let start = out.len();
442                    out.resize(start + n, 0);
443                    db.read_art_chunk_into(*art_id, within, &mut out[start..])?;
444                    crate::metrics::on_art_chunk();
445                }
446                Segment::BinaryTag { payload_id, .. } => {
447                    let start = out.len();
448                    out.resize(start + n, 0);
449                    db.read_binary_tag_chunk_into(*payload_id, within, &mut out[start..])?;
450                    crate::metrics::on_binary_tag_chunk();
451                }
452                Segment::OggAudio {
453                    offset: ao,
454                    seq_delta,
455                    len,
456                } => {
457                    let f = file.expect("ogg-audio segment requires an open backing file");
458                    serve_ogg_window(
459                        f,
460                        *ao,
461                        *len,
462                        *seq_delta,
463                        within,
464                        within + n as u64,
465                        &mut *out,
466                        Some(&resolved.last_page),
467                    )?;
468                }
469                Segment::OggArtSlice {
470                    art_id,
471                    offset,
472                    base64,
473                    art_total,
474                    ..
475                } => {
476                    if *base64 {
477                        // Output base64 chars [offset+within, +n) of base64(image).
478                        let w =
479                            musefs_format::ogg::b64_window(*offset + within, n as u64, *art_total);
480                        let raw = db.read_art_chunk(*art_id, w.in_start, usize_from(w.in_len))?;
481                        crate::metrics::on_art_chunk();
482                        out.extend_from_slice(&musefs_format::ogg::encode_b64_slice(
483                            &raw, w.skip, n,
484                        ));
485                    } else {
486                        // Raw image bytes (OggFLAC PICTURE block).
487                        let start = out.len();
488                        out.resize(start + n, 0);
489                        db.read_art_chunk_into(*art_id, *offset + within, &mut out[start..])?;
490                        crate::metrics::on_art_chunk();
491                    }
492                }
493            }
494        }
495        seg_start = seg_end;
496        if seg_start >= end {
497            break;
498        }
499    }
500    Ok(())
501}
502
503/// Serve into `out` from an already-open backing `file` (per-handle path).
504pub fn read_at_with_file_into<M>(
505    resolved: &ResolvedFile,
506    db: &Db<M>,
507    file: &std::fs::File,
508    offset: u64,
509    size: u64,
510    out: &mut Vec<u8>,
511) -> Result<()> {
512    read_segments_into(resolved, db, Some(file), offset, size, out)
513}
514
515/// Allocating form of `read_at_with_file_into`.
516pub fn read_at_with_file<M>(
517    resolved: &ResolvedFile,
518    db: &Db<M>,
519    file: &std::fs::File,
520    offset: u64,
521    size: u64,
522) -> Result<Vec<u8>> {
523    let mut out = Vec::new();
524    read_at_with_file_into(resolved, db, file, offset, size, &mut out)?;
525    Ok(out)
526}
527
528#[cfg(test)]
529mod ogg_serve_tests {
530    use super::*;
531    use musefs_format::Segment;
532    use musefs_format::ogg::page_test_support::lace_packet_pub;
533    use std::io::Write;
534
535    #[test]
536    fn read_at_renumbers_audio_and_preserves_payload() {
537        // Build a file: 8 header bytes + two audio pages (seq 3,4).
538        let (mut audio, _) = lace_packet_pub(0x99, 3, false, 10, &[0xA1u8; 200]);
539        let (a2, _) = lace_packet_pub(0x99, 4, false, 20, &vec![0xB2u8; 250]);
540        audio.extend_from_slice(&a2);
541        let audio_offset = 8u64;
542        let mut file_bytes = vec![0xFFu8; usize_from(audio_offset)];
543        file_bytes.extend_from_slice(&audio);
544
545        let dir = tempfile::tempdir().unwrap();
546        let path = dir.path().join("a.opus");
547        std::fs::File::create(&path)
548            .unwrap()
549            .write_all(&file_bytes)
550            .unwrap();
551
552        let layout = RegionLayout::validated(vec![
553            Segment::Inline(b"HDRBYTES".to_vec()), // 8 inline header bytes
554            Segment::OggAudio {
555                offset: audio_offset,
556                len: audio.len() as u64,
557                seq_delta: 1, // 3->4, 4->5
558            },
559        ])
560        .unwrap();
561        let total = layout.total_len();
562        let resolved = ResolvedFile {
563            layout,
564            total_len: total,
565            content_version: 0,
566            backing_path: path.clone(),
567            stamp: BackingStamp {
568                size: 0,
569                mtime_ns: 0,
570                ctime_ns: 0,
571            },
572            mtime_secs: 0,
573            last_page: Mutex::new(None),
574            cache_bytes: 8,
575            has_binary_tag: false,
576        };
577
578        // Read the whole virtual file; needs a Db only for ArtImage (unused here).
579        let db = musefs_db::Db::open_in_memory().unwrap();
580        let got = read_at(&resolved, &db, 0, total).unwrap();
581        assert_eq!(got.len(), usize_from(total));
582        assert_eq!(&got[0..8], b"HDRBYTES");
583
584        // The served audio region must have renumbered seqs (4 and 5) and identical
585        // payloads to the source.
586        let served_audio = &got[8..];
587        let h0 = musefs_format::ogg::parse_page(served_audio, 0).unwrap();
588        assert_eq!(h0.seq, 4);
589        let p1_off = h0.total_len();
590        let h1 = musefs_format::ogg::parse_page(served_audio, p1_off).unwrap();
591        assert_eq!(h1.seq, 5);
592        // Payload bytes unchanged.
593        assert!(
594            served_audio[h0.header_len..h0.total_len()]
595                .iter()
596                .all(|&b| b == 0xA1)
597        );
598        assert!(
599            served_audio[p1_off + h1.header_len..p1_off + h1.total_len()]
600                .iter()
601                .all(|&b| b == 0xB2)
602        );
603    }
604}
605
606#[cfg(test)]
607mod resolve_ogg_tests {
608    use super::*;
609    use musefs_db::{Db, Format, NewTrack, Tag};
610    use musefs_format::ogg::page_test_support::lace_packet_pub;
611    use std::io::Write;
612    use std::os::unix::fs::MetadataExt;
613
614    fn build_opus_file(path: &std::path::Path) -> (u64, u64) {
615        let head = b"OpusHead\x01\x02\x38\x01\x80\xbb\x00\x00\x00\x00\x00".to_vec();
616        let mut tags = b"OpusTags".to_vec();
617        tags.extend_from_slice(&musefs_format::ogg::page_test_support::vorbis_body_empty());
618        let (mut bytes, pages) =
619            musefs_format::ogg::page_test_support::build_header_pub(0x1234, &[&head, &tags]);
620        let audio_offset = bytes.len() as u64;
621        let _ = pages;
622        let (audio, _) = lace_packet_pub(0x1234, 2, false, 960, &vec![0x7Eu8; 400]);
623        bytes.extend_from_slice(&audio);
624        std::fs::File::create(path)
625            .unwrap()
626            .write_all(&bytes)
627            .unwrap();
628        (audio_offset, bytes.len() as u64 - audio_offset)
629    }
630
631    #[test]
632    fn resolves_and_reads_opus_with_identical_audio() {
633        let dir = tempfile::tempdir().unwrap();
634        let path = dir.path().join("track.opus");
635        let (audio_offset, audio_length) = build_opus_file(&path);
636        let original = std::fs::read(&path).unwrap();
637
638        let db = Db::open_in_memory().unwrap();
639        let meta = std::fs::metadata(&path).unwrap();
640        let track_id = db
641            .upsert_track(&NewTrack {
642                backing_path: path.to_string_lossy().into_owned(),
643                format: Format::Opus,
644                audio_offset,
645                audio_length,
646                backing_size: meta.len(),
647                backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
648                backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
649            })
650            .unwrap();
651        db.replace_tags(track_id, &[Tag::new("title", "Telephasic Workshop", 0)])
652            .unwrap();
653
654        let cache = HeaderCache::new(Mode::Synthesis);
655        let resolved = cache.resolve(&db, track_id).unwrap();
656        let out = read_at(&resolved, &db, 0, resolved.total_len).unwrap();
657
658        // The synthesized audio region (after the regenerated header) must be the
659        // original audio pages, byte-identical (seq_delta==0 here since the original
660        // OpusTags is also an empty-comment musefs-style header of equal page count).
661        let header = musefs_format::ogg::read_header(&out).unwrap();
662        let synth_audio = &out[usize_from(header.audio_offset)..];
663        assert_eq!(synth_audio, &original[usize_from(audio_offset)..]);
664
665        // Tags were rewritten. `ogg::read_tags` now returns canonical lowercase
666        // keys for known Vorbis fields (Tasks 1–6 changed the format layer).
667        let tags = musefs_format::ogg::read_tags(&out).unwrap();
668        assert!(
669            tags.iter()
670                .any(|(k, v)| k == "title" && v == "Telephasic Workshop")
671        );
672    }
673
674    #[test]
675    fn invalid_vorbis_keys_reports_distinct_out_of_grammar_keys() {
676        use musefs_format::TagInput;
677        let inputs = vec![
678            TagInput::new("artist", "A"),
679            TagInput::new("a=b", "c"),
680            TagInput::new("a=b", "d"), // same bad key twice -> reported once
681            TagInput::new("title", "S"),
682        ];
683        // Only the out-of-grammar key, deduped; valid keys are not flagged.
684        assert_eq!(invalid_vorbis_keys(&inputs), vec!["a=b"]);
685    }
686
687    #[test]
688    fn synthesis_drops_invalid_vorbis_key_end_to_end() {
689        let dir = tempfile::tempdir().unwrap();
690        let path = dir.path().join("track.opus");
691        let (audio_offset, audio_length) = build_opus_file(&path);
692
693        let db = Db::open_in_memory().unwrap();
694        let meta = std::fs::metadata(&path).unwrap();
695        let track_id = db
696            .upsert_track(&NewTrack {
697                backing_path: path.to_string_lossy().into_owned(),
698                format: Format::Opus,
699                audio_offset,
700                audio_length,
701                backing_size: meta.len(),
702                backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
703                backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
704            })
705            .unwrap();
706        // `a=b` passes the DB floor but is not a valid Vorbis field name. Without the
707        // fix it would synthesize `A=B=c` and re-parse as key "A", value "B=c".
708        db.replace_tags(
709            track_id,
710            &[
711                Tag::new("artist", "Alice", 0),
712                Tag::new("a=b", "c", 0),
713                Tag::new("title", "Song", 0),
714            ],
715        )
716        .unwrap();
717
718        let cache = HeaderCache::new(Mode::Synthesis);
719        let resolved = cache.resolve(&db, track_id).unwrap();
720        let out = read_at(&resolved, &db, 0, resolved.total_len).unwrap();
721
722        let tags = musefs_format::ogg::read_tags(&out).unwrap();
723        assert!(tags.iter().any(|(k, v)| k == "artist" && v == "Alice"));
724        assert!(tags.iter().any(|(k, v)| k == "title" && v == "Song"));
725        assert!(
726            !tags.iter().any(|(k, _)| k == "A" || k.contains('=')),
727            "the a=b key must be dropped, not synthesized as A=B=c: {tags:?}"
728        );
729    }
730
731    #[test]
732    fn read_at_with_file_matches_read_at() {
733        let dir = tempfile::tempdir().unwrap();
734        let path = dir.path().join("track.opus");
735        let (audio_offset, audio_length) = build_opus_file(&path);
736        let db = Db::open_in_memory().unwrap();
737        let meta = std::fs::metadata(&path).unwrap();
738        let track_id = db
739            .upsert_track(&NewTrack {
740                backing_path: path.to_string_lossy().into_owned(),
741                format: Format::Opus,
742                audio_offset,
743                audio_length,
744                backing_size: meta.len(),
745                backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
746                backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
747            })
748            .unwrap();
749        let cache = HeaderCache::new(Mode::Synthesis);
750        let resolved = cache.resolve(&db, track_id).unwrap();
751
752        let via_open = read_at(&resolved, &db, 0, resolved.total_len).unwrap();
753        let file = std::fs::File::open(&resolved.backing_path).unwrap();
754        let via_file = read_at_with_file(&resolved, &db, &file, 0, resolved.total_len).unwrap();
755        assert_eq!(via_open, via_file);
756    }
757
758    fn build_wav_file(path: &std::path::Path) -> (u64, u64, Vec<u8>) {
759        use std::io::Write;
760        let mut fmt = Vec::new();
761        fmt.extend_from_slice(&1u16.to_le_bytes());
762        fmt.extend_from_slice(&1u16.to_le_bytes());
763        fmt.extend_from_slice(&44_100u32.to_le_bytes());
764        fmt.extend_from_slice(&88_200u32.to_le_bytes());
765        fmt.extend_from_slice(&2u16.to_le_bytes());
766        fmt.extend_from_slice(&16u16.to_le_bytes());
767
768        let data: Vec<u8> = (0..32u8).collect();
769        let mut body = Vec::new();
770        for (id, payload) in [(&b"fmt "[..], &fmt[..]), (&b"data"[..], &data[..])] {
771            body.extend_from_slice(id);
772            body.extend_from_slice(&u32::try_from(payload.len()).unwrap().to_le_bytes());
773            body.extend_from_slice(payload);
774        }
775        let mut bytes = b"RIFF".to_vec();
776        bytes.extend_from_slice(&u32::try_from(body.len() + 4).unwrap().to_le_bytes());
777        bytes.extend_from_slice(b"WAVE");
778        bytes.extend_from_slice(&body);
779
780        let audio_offset = (bytes.len() - data.len()) as u64;
781        std::fs::File::create(path)
782            .unwrap()
783            .write_all(&bytes)
784            .unwrap();
785        (audio_offset, data.len() as u64, data)
786    }
787
788    #[test]
789    fn resolves_and_reads_wav_with_identical_audio() {
790        let dir = tempfile::tempdir().unwrap();
791        let path = dir.path().join("track.wav");
792        let (audio_offset, audio_length, original_data) = build_wav_file(&path);
793
794        let db = Db::open_in_memory().unwrap();
795        let meta = std::fs::metadata(&path).unwrap();
796        let track_id = db
797            .upsert_track(&NewTrack {
798                backing_path: path.to_string_lossy().into_owned(),
799                format: Format::Wav,
800                audio_offset,
801                audio_length,
802                backing_size: meta.len(),
803                backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
804                backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
805            })
806            .unwrap();
807        db.replace_tags(track_id, &[Tag::new("title", "Wave One", 0)])
808            .unwrap();
809
810        let cache = HeaderCache::new(Mode::Synthesis);
811        let resolved = cache.resolve(&db, track_id).unwrap();
812        let out = read_at(&resolved, &db, 0, resolved.total_len).unwrap();
813
814        // The synthesized output is a valid WAV; its data payload is byte-identical
815        // to the original audio.
816        let bounds = musefs_format::wav::locate_audio(&out).unwrap();
817        assert_eq!(
818            &out[usize_from(bounds.audio_offset)
819                ..usize_from(bounds.audio_offset + bounds.audio_length)],
820            original_data.as_slice()
821        );
822
823        // The title was synthesized into the embedded id3 chunk.
824        let tags = musefs_format::wav::read_tags(&out);
825        assert!(tags.contains(&("title".to_string(), "Wave One".to_string())));
826    }
827
828    #[test]
829    fn build_cache_bytes_counts_inline_segments_for_ogg() {
830        use musefs_db::{Format, NewTrack};
831        let dir = tempfile::tempdir().unwrap();
832        let path = dir.path().join("a.opus");
833        let (audio_offset, audio_length) = build_opus_file(&path);
834        let db = musefs_db::Db::open_in_memory().unwrap();
835        let meta = std::fs::metadata(&path).unwrap();
836        let id = db
837            .upsert_track(&NewTrack {
838                backing_path: path.to_string_lossy().into_owned(),
839                format: Format::Opus,
840                audio_offset,
841                audio_length,
842                backing_size: meta.len(),
843                backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
844                backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
845            })
846            .unwrap();
847        let cache = HeaderCache::new(Mode::Synthesis);
848        let resolved = cache.resolve(&db, id).unwrap();
849        let inline_sum: u64 = resolved
850            .layout
851            .segments()
852            .iter()
853            .map(|s| match s {
854                Segment::Inline(b) => b.len() as u64,
855                _ => 0,
856            })
857            .sum();
858        // SP4: no per-file index estimate; cache_bytes == inline segment bytes only.
859        assert_eq!(resolved.cache_bytes, inline_sum);
860        assert!(
861            inline_sum > 0,
862            "Opus header should have non-empty inline segments"
863        );
864    }
865}
866
867#[cfg(test)]
868mod ogg_art_serve_tests {
869    use super::*;
870
871    #[test]
872    fn read_at_serves_base64_art_slice_matching_full_encode() {
873        let image: Vec<u8> = (0..1000u32).map(|i| (i % 251) as u8).collect();
874        // Compute full base64 via the format crate (base64 is not a direct dep of musefs-core).
875        let full_b64 = musefs_format::ogg::encode_b64_slice(
876            &image,
877            0,
878            usize_from(musefs_format::ogg::b64_len(image.len() as u64)),
879        );
880
881        let db = musefs_db::Db::open_in_memory().unwrap();
882        let art_id = db
883            .upsert_art(&musefs_db::NewArt {
884                mime: "image/png".to_string(),
885                width: Some(1),
886                height: Some(1),
887                data: image.clone(),
888            })
889            .unwrap();
890
891        let layout = RegionLayout::validated(vec![
892            Segment::Inline(b"HEAD".to_vec()),
893            Segment::OggArtSlice {
894                art_id,
895                offset: 0,
896                len: musefs_format::BlobLen::new(full_b64.len() as u64).unwrap(),
897                base64: true,
898                art_total: image.len() as u64,
899            },
900            Segment::Inline(b"XY".to_vec()),
901        ])
902        .unwrap();
903        let total = layout.total_len();
904        let resolved = ResolvedFile {
905            layout,
906            total_len: total,
907            content_version: 0,
908            backing_path: std::path::PathBuf::from("/dev/null"),
909            stamp: BackingStamp {
910                size: 0,
911                mtime_ns: 0,
912                ctime_ns: 0,
913            },
914            mtime_secs: 0,
915            last_page: Mutex::new(None),
916            cache_bytes: 0,
917            has_binary_tag: false,
918        };
919
920        // Full read.
921        let got = read_at(&resolved, &db, 0, total).unwrap();
922        let mut want = b"HEAD".to_vec();
923        want.extend_from_slice(&full_b64);
924        want.extend_from_slice(b"XY");
925        assert_eq!(got, want);
926
927        // Partial read straddling into the middle of the art slice (non-4-aligned).
928        let part = read_at(&resolved, &db, 7, 23).unwrap();
929        assert_eq!(part, want[7..30]);
930    }
931
932    #[test]
933    fn read_at_serves_raw_art_slice() {
934        let image: Vec<u8> = (0..300u32)
935            .map(|i| u8::try_from(i % 256).unwrap())
936            .collect();
937        let db = musefs_db::Db::open_in_memory().unwrap();
938        let art_id = db
939            .upsert_art(&musefs_db::NewArt {
940                mime: "image/png".to_string(),
941                width: None,
942                height: None,
943                data: image.clone(),
944            })
945            .unwrap();
946        let layout = RegionLayout::validated(vec![Segment::OggArtSlice {
947            art_id,
948            offset: 0,
949            len: musefs_format::BlobLen::new(image.len() as u64).unwrap(),
950            base64: false,
951            art_total: image.len() as u64,
952        }])
953        .unwrap();
954        let total = layout.total_len();
955        let resolved = ResolvedFile {
956            layout,
957            total_len: total,
958            content_version: 0,
959            backing_path: std::path::PathBuf::from("/dev/null"),
960            stamp: BackingStamp {
961                size: 0,
962                mtime_ns: 0,
963                ctime_ns: 0,
964            },
965            mtime_secs: 0,
966            last_page: Mutex::new(None),
967            cache_bytes: 0,
968            has_binary_tag: false,
969        };
970        let got = read_at(&resolved, &db, 10, 50).unwrap();
971        assert_eq!(got, image[10..60]);
972    }
973}
974
975#[cfg(test)]
976mod cache_bound_tests {
977    use super::*;
978    use musefs_db::{Db, Format, NewTrack};
979    use std::os::unix::fs::MetadataExt;
980
981    fn entry(content_version: i64, inline_len: usize) -> Arc<ResolvedFile> {
982        Arc::new(ResolvedFile {
983            layout: RegionLayout::new_unchecked(vec![Segment::Inline(vec![0u8; inline_len])]),
984            total_len: inline_len as u64,
985            content_version,
986            backing_path: std::path::PathBuf::from("/nonexistent"),
987            stamp: BackingStamp {
988                size: 0,
989                mtime_ns: 0,
990                ctime_ns: 0,
991            },
992            mtime_secs: 0,
993            last_page: Mutex::new(None),
994            cache_bytes: inline_len as u64,
995            has_binary_tag: false,
996        })
997    }
998
999    #[test]
1000    fn header_cache_resolve_caches_by_content_version() {
1001        let dir = tempfile::tempdir().unwrap();
1002        let path = dir.path().join("a.flac");
1003        let (audio_offset, audio_length) = write_flac_local(&path);
1004        let db = Db::open_in_memory().unwrap();
1005        let meta = std::fs::metadata(&path).unwrap();
1006        let id = db
1007            .upsert_track(&NewTrack {
1008                backing_path: path.to_string_lossy().into_owned(),
1009                format: Format::Flac,
1010                audio_offset,
1011                audio_length,
1012                backing_size: meta.len(),
1013                backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
1014                backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
1015            })
1016            .unwrap();
1017        let cache = HeaderCache::new(Mode::Synthesis); // NOTE: not `mut` — resolve is &self now
1018        let a = cache.resolve(&db, id).unwrap();
1019        let b = cache.resolve(&db, id).unwrap();
1020        assert!(Arc::ptr_eq(&a, &b));
1021    }
1022
1023    #[test]
1024    fn resolve_is_safe_under_concurrent_access() {
1025        // Many threads resolving the same track exercise the off-lock build race
1026        // (concurrent miss → build → insert on one shard) and concurrent gets.
1027        // Each thread needs its own connection (Db is !Sync), so use a file-backed
1028        // DB and open_readonly per thread.
1029        let dir = tempfile::tempdir().unwrap();
1030        let flac_path = dir.path().join("a.flac");
1031        let (audio_offset, audio_length) = write_flac_local(&flac_path);
1032        let db_path = dir.path().join("m.db");
1033        let track_id = {
1034            let db = Db::open(&db_path).unwrap();
1035            let meta = std::fs::metadata(&flac_path).unwrap();
1036            db.upsert_track(&NewTrack {
1037                backing_path: flac_path.to_string_lossy().into_owned(),
1038                format: Format::Flac,
1039                audio_offset,
1040                audio_length,
1041                backing_size: meta.len(),
1042                backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
1043                backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
1044            })
1045            .unwrap()
1046        };
1047
1048        let cache = std::sync::Arc::new(HeaderCache::new(Mode::Synthesis));
1049        std::thread::scope(|s| {
1050            for _ in 0..8 {
1051                let cache = std::sync::Arc::clone(&cache);
1052                let db_path = db_path.clone();
1053                s.spawn(move || {
1054                    let db = Db::open_readonly(&db_path).unwrap();
1055                    for _ in 0..50 {
1056                        let r = cache.resolve(&db, track_id).unwrap();
1057                        assert!(r.total_len > 0);
1058                        assert_eq!(r.content_version, 0);
1059                    }
1060                });
1061            }
1062        });
1063    }
1064
1065    #[test]
1066    fn header_cache_retain_drops_absent_tracks() {
1067        let dir = tempfile::tempdir().unwrap();
1068        let db = Db::open_in_memory().unwrap();
1069        let mk = |name: &str| {
1070            let path = dir.path().join(name);
1071            let (audio_offset, audio_length) = write_flac_local(&path);
1072            let meta = std::fs::metadata(&path).unwrap();
1073            db.upsert_track(&NewTrack {
1074                backing_path: path.to_string_lossy().into_owned(),
1075                format: Format::Flac,
1076                audio_offset,
1077                audio_length,
1078                backing_size: meta.len(),
1079                backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
1080                backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
1081            })
1082            .unwrap()
1083        };
1084        let keep = mk("keep.flac");
1085        let gone = mk("gone.flac");
1086        let cache = HeaderCache::new(Mode::Synthesis);
1087        let keep_a = cache.resolve(&db, keep).unwrap();
1088        let gone_a = cache.resolve(&db, gone).unwrap();
1089
1090        let live: HashSet<i64> = [keep].into_iter().collect();
1091        cache.retain(&live);
1092
1093        // The kept track stays the same cached Arc; the dropped one re-resolves fresh.
1094        assert!(Arc::ptr_eq(&keep_a, &cache.resolve(&db, keep).unwrap()));
1095        assert!(!Arc::ptr_eq(&gone_a, &cache.resolve(&db, gone).unwrap()));
1096    }
1097
1098    #[test]
1099    fn header_cache_remove_drops_one_track_only() {
1100        let dir = tempfile::tempdir().unwrap();
1101        let db = Db::open_in_memory().unwrap();
1102        let mk = |name: &str| {
1103            let path = dir.path().join(name);
1104            let (audio_offset, audio_length) = write_flac_local(&path);
1105            let meta = std::fs::metadata(&path).unwrap();
1106            db.upsert_track(&NewTrack {
1107                backing_path: path.to_string_lossy().into_owned(),
1108                format: Format::Flac,
1109                audio_offset,
1110                audio_length,
1111                backing_size: meta.len(),
1112                backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
1113                backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
1114            })
1115            .unwrap()
1116        };
1117        let keep = mk("keep.flac");
1118        let gone = mk("gone.flac");
1119        let cache = HeaderCache::new(Mode::Synthesis);
1120        let keep_a = cache.resolve(&db, keep).unwrap();
1121        let gone_a = cache.resolve(&db, gone).unwrap();
1122
1123        cache.remove(gone);
1124
1125        // The kept track stays the same cached Arc; the removed one re-resolves fresh.
1126        assert!(Arc::ptr_eq(&keep_a, &cache.resolve(&db, keep).unwrap()));
1127        assert!(!Arc::ptr_eq(&gone_a, &cache.resolve(&db, gone).unwrap()));
1128    }
1129
1130    #[test]
1131    fn default_cache_budget_is_64_mib() {
1132        assert_eq!(DEFAULT_CACHE_BUDGET, 67_108_864);
1133    }
1134
1135    #[test]
1136    fn read_segments_returns_empty_past_end_of_range() {
1137        let db = musefs_db::Db::open_in_memory().unwrap();
1138        let resolved = entry(0, 10);
1139        let out = read_at(&resolved, &db, 11, 1).unwrap();
1140        assert!(out.is_empty());
1141        let out0 = read_at(&resolved, &db, 0, 0).unwrap();
1142        assert!(out0.is_empty());
1143    }
1144
1145    fn track_with_bounds(
1146        path: &std::path::Path,
1147        audio_offset: u64,
1148        audio_length: u64,
1149    ) -> (musefs_db::Db, i64) {
1150        use musefs_db::{Format, NewTrack};
1151        let db = musefs_db::Db::open_in_memory().unwrap();
1152        let meta = std::fs::metadata(path).unwrap();
1153        let id = db
1154            .upsert_track(&NewTrack {
1155                backing_path: path.to_string_lossy().into_owned(),
1156                format: Format::Flac,
1157                audio_offset,
1158                audio_length,
1159                backing_size: meta.len(),
1160                backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
1161                backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
1162            })
1163            .unwrap();
1164        (db, id)
1165    }
1166
1167    #[test]
1168    fn build_rejects_audio_region_past_end_of_file() {
1169        // An audio region past the end of the backing file (offset + length >
1170        // backing_size) is rejected at write time by the V4 bounds CHECK — it can
1171        // no longer be committed and reach synthesis.
1172        let dir = tempfile::tempdir().unwrap();
1173        let path = dir.path().join("a.flac");
1174        let _ = write_flac_local(&path);
1175        let meta = std::fs::metadata(&path).unwrap();
1176        let db = musefs_db::Db::open_in_memory().unwrap();
1177        let rejected = db.upsert_track(&musefs_db::NewTrack {
1178            backing_path: path.to_string_lossy().into_owned(),
1179            format: musefs_db::Format::Flac,
1180            audio_offset: meta.len(),
1181            audio_length: 5,
1182            backing_size: meta.len(),
1183            backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
1184            backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
1185        });
1186        assert!(
1187            rejected.is_err(),
1188            "bounds CHECK must reject an over-EOF audio region"
1189        );
1190    }
1191
1192    #[test]
1193    fn build_accepts_audio_region_ending_exactly_at_eof() {
1194        let dir = tempfile::tempdir().unwrap();
1195        let path = dir.path().join("a.flac");
1196        let (audio_offset, audio_length) = write_flac_local(&path);
1197        let (db, id) = track_with_bounds(&path, audio_offset, audio_length);
1198        let cache = HeaderCache::new(Mode::Synthesis);
1199        let resolved = cache
1200            .resolve(&db, id)
1201            .expect("exact-fit bounds must resolve");
1202        assert!(resolved.total_len > 0);
1203    }
1204
1205    #[test]
1206    fn build_accepts_audio_region_ending_before_eof() {
1207        // A valid track whose audio region ends strictly before EOF
1208        // (audio_offset + audio_length < backing_size, allowed by TrackBounds)
1209        // must still resolve: the bounds guard rejects only an over-EOF region.
1210        // Pins the guard's `>` against `<`, which would spuriously reject every
1211        // sub-EOF track.
1212        let dir = tempfile::tempdir().unwrap();
1213        let path = dir.path().join("a.flac");
1214        let (audio_offset, audio_length) = write_flac_local(&path);
1215        // Append trailing bytes so the audio region no longer reaches EOF; the
1216        // padded length becomes backing_size, leaving offset + length < it.
1217        use std::io::Write;
1218        std::fs::OpenOptions::new()
1219            .append(true)
1220            .open(&path)
1221            .unwrap()
1222            .write_all(&[0u8; 64])
1223            .unwrap();
1224        let (db, id) = track_with_bounds(&path, audio_offset, audio_length);
1225        let cache = HeaderCache::new(Mode::Synthesis);
1226        let resolved = cache.resolve(&db, id).expect("sub-EOF bounds must resolve");
1227        assert!(resolved.total_len > 0);
1228    }
1229
1230    #[test]
1231    fn build_cache_bytes_counts_inline_segments() {
1232        let dir = tempfile::tempdir().unwrap();
1233        let path = dir.path().join("a.flac");
1234        let (audio_offset, audio_length) = write_flac_local(&path);
1235        let (db, id) = track_with_bounds(&path, audio_offset, audio_length);
1236        let cache = HeaderCache::new(Mode::Synthesis);
1237        let resolved = cache.resolve(&db, id).unwrap();
1238        let inline_sum: u64 = resolved
1239            .layout
1240            .segments()
1241            .iter()
1242            .map(|s| match s {
1243                Segment::Inline(b) => b.len() as u64,
1244                _ => 0,
1245            })
1246            .sum();
1247        assert!(inline_sum > 0);
1248        assert_eq!(resolved.cache_bytes, inline_sum);
1249    }
1250
1251    #[test]
1252    fn build_rejects_layout_failing_validation() {
1253        // A layout with an empty Inline segment fails validate(); the defensive
1254        // check at the cache boundary must surface it rather than cache it.
1255        let bad = RegionLayout::new_unchecked(vec![Segment::Inline(vec![])]);
1256        let err = bad.validate();
1257        assert!(err.is_err());
1258    }
1259
1260    fn write_flac_local(path: &std::path::Path) -> (u64, u64) {
1261        fn block(bt: u8, body: &[u8], last: bool) -> Vec<u8> {
1262            let mut v = vec![(if last { 0x80 } else { 0 }) | (bt & 0x7F)];
1263            let n: u32 = u32::try_from(body.len()).unwrap();
1264            v.extend_from_slice(&[
1265                u8::try_from(n >> 16).unwrap(),
1266                u8::try_from(n >> 8).unwrap(),
1267                u8::try_from(n).unwrap(),
1268            ]);
1269            v.extend_from_slice(body);
1270            v
1271        }
1272        let mut si = vec![
1273            0x10, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0A, 0xC4, 0x42, 0xF0,
1274            0x00, 0x00, 0x00, 0x00,
1275        ];
1276        si.extend_from_slice(&[0u8; 16]);
1277        let mut vc = Vec::new();
1278        let vendor = b"x";
1279        vc.extend_from_slice(&u32::try_from(vendor.len()).unwrap().to_le_bytes());
1280        vc.extend_from_slice(vendor);
1281        vc.extend_from_slice(&0u32.to_le_bytes());
1282        let mut out = b"fLaC".to_vec();
1283        out.extend(block(0, &si, false));
1284        out.extend(block(4, &vc, true));
1285        let audio = [0xABu8; 256];
1286        let audio_offset = out.len() as u64;
1287        out.extend_from_slice(&audio);
1288        std::fs::write(path, &out).unwrap();
1289        (audio_offset, audio.len() as u64)
1290    }
1291
1292    #[test]
1293    fn cache_weight_stays_within_budget_after_flood() {
1294        let cache = HeaderCache::with_budget(Mode::Synthesis, 4096);
1295        for id in 0..64i64 {
1296            cache.cache.insert(id, entry(0, 256)); // 64 × 256 B = 16 KiB ≫ 4 KiB
1297        }
1298        // End-state assertion only: quick_cache does not document per-insert
1299        // synchronous eviction, so the per-insert bound is not guaranteed.
1300        assert!(
1301            cache.cache.weight() <= 4096,
1302            "total weight {} exceeds the 4096-byte budget",
1303            cache.cache.weight()
1304        );
1305        // len() is assumed to count resident entries. If this assertion ever
1306        // trips, the diagnosis is the same as the weight() note above: re-read
1307        // the spec's eviction-timing section and escalate — don't loosen.
1308        assert!(
1309            cache.cache.len() < 64,
1310            "no eviction happened: all 64 over-budget entries are resident"
1311        );
1312    }
1313
1314    #[test]
1315    fn zero_cache_bytes_entry_still_weighs_one() {
1316        // StructureOnly layouts have cache_bytes == 0; the weigher's .max(1) keeps
1317        // them inside the weighted bound instead of escaping it (quick_cache
1318        // ignores zero-weight entries when evicting).
1319        let cache = HeaderCache::with_budget(Mode::StructureOnly, 1024);
1320        cache.cache.insert(1, entry(0, 0));
1321        assert_eq!(cache.cache.weight(), 1);
1322        assert!(cache.cache.get(&1).is_some());
1323    }
1324}
1325
1326#[cfg(test)]
1327mod binary_tag_serve_tests {
1328    use super::*;
1329    use musefs_db::{BinaryTag, NewTrack};
1330    use std::os::unix::fs::MetadataExt;
1331
1332    #[test]
1333    fn resolve_mp3_emits_binary_tag_in_synthesized_region() {
1334        use id3::frame::{Content, Unknown};
1335        use id3::{Encoder, Frame, Tag, TagLike, Version};
1336        let dir = tempfile::tempdir().unwrap();
1337        let mut tag = Tag::new();
1338        let needle = [0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x77, 0x88];
1339        tag.add_frame(Frame::with_content(
1340            "PRIV",
1341            Content::Unknown(Unknown {
1342                data: needle.to_vec(),
1343                version: Version::Id3v24,
1344            }),
1345        ));
1346        let mut bytes = Vec::new();
1347        Encoder::new()
1348            .version(Version::Id3v24)
1349            .encode(&tag, &mut bytes)
1350            .unwrap();
1351        bytes.extend_from_slice(&[0xFF, 0xFB, 0x90, 0x00]);
1352        let path = dir.path().join("a.mp3");
1353        std::fs::write(&path, &bytes).unwrap();
1354
1355        let db = musefs_db::Db::open_in_memory().unwrap();
1356        let bounds = musefs_format::mp3::locate_audio(&bytes).unwrap();
1357        let meta = std::fs::metadata(&path).unwrap();
1358        let tid = db
1359            .upsert_track(&musefs_db::NewTrack {
1360                backing_path: path.to_string_lossy().into_owned(),
1361                format: musefs_db::Format::Mp3,
1362                audio_offset: bounds.audio_offset,
1363                audio_length: bounds.audio_length,
1364                backing_size: meta.len(),
1365                backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
1366                backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
1367            })
1368            .unwrap();
1369        db.set_binary_tags(
1370            tid,
1371            &[musefs_db::BinaryTag {
1372                key: "PRIV".into(),
1373                payload: needle.to_vec(),
1374                ordinal: 0,
1375            }],
1376        )
1377        .unwrap();
1378
1379        let cache = crate::reader::HeaderCache::new(crate::Mode::Synthesis);
1380        let resolved = cache.resolve(&db, tid).unwrap();
1381        let whole = crate::reader::read_at(&resolved, &db, 0, resolved.total_len).unwrap();
1382        assert!(
1383            whole.windows(needle.len()).any(|w| w == needle),
1384            "PRIV body not in synthesized file"
1385        );
1386    }
1387
1388    #[test]
1389    fn read_at_serves_binary_tag_segment() {
1390        let db = Db::open_in_memory().unwrap();
1391        let id = db
1392            .upsert_track(&NewTrack {
1393                backing_path: "/x.mp3".into(),
1394                format: Format::Mp3,
1395                audio_offset: 0,
1396                audio_length: 0,
1397                backing_size: 0,
1398                backing_mtime_ns: 0,
1399                backing_ctime_ns: 0,
1400            })
1401            .unwrap();
1402        db.set_binary_tags(
1403            id,
1404            &[BinaryTag {
1405                key: "PRIV".into(),
1406                payload: vec![10, 20, 30, 40],
1407                ordinal: 0,
1408            }],
1409        )
1410        .unwrap();
1411        let rowid = db.get_binary_tags(id).unwrap()[0].rowid;
1412
1413        let resolved = ResolvedFile {
1414            layout: RegionLayout::validated(vec![Segment::BinaryTag {
1415                payload_id: rowid,
1416                len: musefs_format::BlobLen::new(4).unwrap(),
1417            }])
1418            .unwrap(),
1419            total_len: 4,
1420            content_version: 0,
1421            backing_path: PathBuf::from("/x.mp3"),
1422            stamp: BackingStamp {
1423                size: 0,
1424                mtime_ns: 0,
1425                ctime_ns: 0,
1426            },
1427            mtime_secs: 0,
1428            last_page: Mutex::new(None),
1429            cache_bytes: 0,
1430            has_binary_tag: true,
1431        };
1432        // No BackingAudio segment, so read_at opens no file.
1433        let got = read_at(&resolved, &db, 1, 2).unwrap();
1434        assert_eq!(got, vec![20, 30]);
1435    }
1436}
1437
1438#[cfg(test)]
1439mod serve_cap_tests {
1440    use super::*;
1441    use musefs_db::{Db, Format, NewTrack};
1442
1443    const CAP: u64 = crate::scan::MAX_PROBE_BYTES;
1444
1445    /// A sparse backing file of `len` bytes (no real bytes written — `set_len`
1446    /// only extends the file's logical size, which tmpfs keeps sparse).
1447    fn sparse_file(dir: &std::path::Path, name: &str, len: u64) -> std::path::PathBuf {
1448        let path = dir.join(name);
1449        let f = std::fs::File::create(&path).unwrap();
1450        f.set_len(len).unwrap();
1451        path
1452    }
1453
1454    /// Insert a `tracks` row whose `audio_offset` exceeds the cap while still
1455    /// satisfying both serve guards (`backing_size == meta.len()` and
1456    /// `audio_offset + audio_length <= meta.len()`). Returns the track id.
1457    /// Takes `&Db` (= `Db<ReadWrite>`) because `upsert_track` is defined on
1458    /// `impl Db<ReadWrite>`, not the generic `impl<M> Db<M>`.
1459    fn hostile_track(db: &Db, path: &std::path::Path, format: Format) -> i64 {
1460        let meta = std::fs::metadata(path).unwrap();
1461        let stamp = BackingStamp::from_metadata(&meta);
1462        db.upsert_track(&NewTrack {
1463            backing_path: path.to_string_lossy().into_owned(),
1464            format,
1465            audio_offset: CAP + 1,
1466            audio_length: 1,
1467            backing_size: meta.len(),
1468            backing_mtime_ns: stamp.mtime_ns,
1469            backing_ctime_ns: stamp.ctime_ns,
1470        })
1471        .unwrap()
1472    }
1473
1474    /// Assert a resolve attempt fails closed with the cap error for `audio_offset`.
1475    fn assert_capped(result: crate::Result<std::sync::Arc<ResolvedFile>>) {
1476        match result {
1477            Err(CoreError::HeaderTooLarge { requested, cap }) => {
1478                assert_eq!(requested, CAP + 1);
1479                assert_eq!(cap, CAP);
1480            }
1481            Err(other) => panic!("expected HeaderTooLarge, got {other:?}"),
1482            Ok(_) => panic!("expected HeaderTooLarge, resolve unexpectedly succeeded"),
1483        }
1484    }
1485
1486    #[test]
1487    fn wav_serve_caps_hostile_offset() {
1488        let dir = tempfile::tempdir().unwrap();
1489        let path = sparse_file(dir.path(), "hostile.wav", CAP + 2);
1490        let db = Db::open_in_memory().unwrap();
1491        let track_id = hostile_track(&db, &path, Format::Wav);
1492
1493        let cache = HeaderCache::new(Mode::Synthesis);
1494        assert_capped(cache.resolve(&db, track_id));
1495    }
1496
1497    #[test]
1498    fn ogg_serve_caps_hostile_offset() {
1499        let dir = tempfile::tempdir().unwrap();
1500        let path = sparse_file(dir.path(), "hostile.opus", CAP + 2);
1501        let db = Db::open_in_memory().unwrap();
1502        let track_id = hostile_track(&db, &path, Format::Opus);
1503
1504        let cache = HeaderCache::new(Mode::Synthesis);
1505        assert_capped(cache.resolve(&db, track_id));
1506    }
1507
1508    #[test]
1509    fn flac_legacy_serve_caps_hostile_offset() {
1510        let dir = tempfile::tempdir().unwrap();
1511        let path = sparse_file(dir.path(), "hostile.flac", CAP + 2);
1512        let db = Db::open_in_memory().unwrap();
1513        // No structural-block rows inserted -> build() takes the legacy fallback
1514        // branch (rows.is_empty()) that calls read_front.
1515        let track_id = hostile_track(&db, &path, Format::Flac);
1516        assert!(db.get_structural_blocks(track_id).unwrap().is_empty());
1517
1518        let cache = HeaderCache::new(Mode::Synthesis);
1519        assert_capped(cache.resolve(&db, track_id));
1520    }
1521
1522    #[test]
1523    fn read_front_rejects_oversize_before_open() {
1524        // Nonexistent path: if the cap check did NOT fire first, File::open would
1525        // error and we'd get an Io error instead of HeaderTooLarge. So this also
1526        // pins the fail-closed ordering (check precedes any open/allocation).
1527        let err =
1528            read_front(std::path::Path::new("/nonexistent/musefs/front"), CAP + 1).unwrap_err();
1529        match err {
1530            CoreError::HeaderTooLarge { requested, cap } => {
1531                assert_eq!(requested, CAP + 1);
1532                assert_eq!(cap, CAP);
1533            }
1534            other => panic!("expected HeaderTooLarge, got {other:?}"),
1535        }
1536    }
1537
1538    #[test]
1539    fn read_front_allows_exactly_cap() {
1540        // Boundary: `n == CAP` must NOT be rejected — the check is `>`, not `>=`.
1541        // With a nonexistent path the call still fails, but with an Io error from
1542        // File::open, never HeaderTooLarge. This pins the boundary and kills the
1543        // `> -> >=` mutant.
1544        let err = read_front(std::path::Path::new("/nonexistent/musefs/front"), CAP).unwrap_err();
1545        assert!(
1546            matches!(err, CoreError::Io(_)),
1547            "expected Io error at the cap boundary, got {err:?}"
1548        );
1549    }
1550}