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    /// Track id this entry resolves. Lets the stateless read path recheck the
26    /// live `content_version` under its WAL snapshot (#502), mirroring the
27    /// handle fast path's use of the handle's `track_id`.
28    pub track_id: i64,
29    pub content_version: i64,
30    pub backing_path: PathBuf,
31    pub stamp: BackingStamp,
32    pub mtime_secs: i64,
33    /// One-entry memo of the last patched Ogg page, so consecutive reads skip
34    /// re-patching the page straddling a chunk boundary. Empty for non-Ogg files
35    /// and reset whenever this resolved entry is rebuilt. (Concrete type spelled
36    /// out rather than `ogg_index::LastPageMemo` because that module is private.)
37    pub last_page: Mutex<Option<(u64, u64, Vec<u8>)>>,
38    /// Approximate resident bytes this entry costs the cache (sum of `Inline`
39    /// segment bytes; backing/art/ogg-audio bytes are not resident).
40    pub cache_bytes: u64,
41    /// Precomputed from the layout: true if any segment is streamed from the DB
42    /// by a rowid (`BinaryTag`/`ArtImage`/`OggArtSlice`). Gates the transactional
43    /// `content_version` guard in the read fast path so plain Inline/BackingAudio
44    /// layouts pay no per-read cost (#502).
45    pub streams_db_rowid: bool,
46}
47
48/// Weighs an entry by its resident inline bytes. The `.max(1)` is load-bearing:
49/// quick_cache ignores zero-weight entries when evicting, and every
50/// StructureOnly layout has `cache_bytes == 0`, so an unweighted entry would
51/// escape the byte budget entirely.
52#[derive(Clone)]
53struct CacheBytesWeighter;
54
55impl Weighter<i64, Arc<ResolvedFile>> for CacheBytesWeighter {
56    fn weight(&self, _key: &i64, val: &Arc<ResolvedFile>) -> u64 {
57        val.cache_bytes.max(1)
58    }
59}
60
61/// A per-mount cache of resolved files keyed by track id; an entry
62/// self-invalidates when the track's `content_version` changes. Backed by
63/// quick_cache: S3-FIFO eviction, byte-weighted, internally sharded.
64pub struct HeaderCache {
65    cache: Cache<i64, Arc<ResolvedFile>, CacheBytesWeighter>,
66    mode: Mode,
67}
68
69/// Default resident-bytes budget for the header cache (64 MiB).
70pub const DEFAULT_CACHE_BUDGET: u64 = 64 * 1024 * 1024;
71
72/// Item-count sizing hint for quick_cache's internal structures (not a bound):
73/// the default budget over 4 KiB, a typical inline tag region. The hint has no
74/// observable public-API behavior, so its arithmetic carries an equivalent-mutant
75/// exclusion in .cargo/mutants.toml (cargo-mutants does mutate const initializers).
76const CACHE_ESTIMATED_ITEMS: usize = (DEFAULT_CACHE_BUDGET / 4096) as usize;
77
78fn read_front(path: &Path, n: u64) -> crate::Result<Vec<u8>> {
79    use std::io::Read;
80    // Fail closed before any allocation/open: a hostile DB row can request an
81    // arbitrary `audio_offset`, but no legitimately-scanned file has a front
82    // larger than the scanner's probe ceiling. Bounding `n` here also retires a
83    // 32-bit `usize_from` truncation footgun.
84    if n > crate::scan::MAX_PROBE_BYTES {
85        return Err(CoreError::HeaderTooLarge {
86            requested: n,
87            cap: crate::scan::MAX_PROBE_BYTES,
88        });
89    }
90    crate::metrics::on_open();
91    let mut f = std::fs::File::open(path).map_err(|e| CoreError::backing_io(path, e))?;
92    let mut buf = vec![0u8; usize_from(n)];
93    f.read_exact(&mut buf)
94        .map_err(|e| CoreError::backing_io(path, e))?;
95    Ok(buf)
96}
97
98impl HeaderCache {
99    pub fn new(mode: Mode) -> HeaderCache {
100        HeaderCache::with_budget(mode, DEFAULT_CACHE_BUDGET)
101    }
102    pub fn with_budget(mode: Mode, budget: u64) -> HeaderCache {
103        HeaderCache {
104            cache: Cache::with_weighter(CACHE_ESTIMATED_ITEMS, budget, CacheBytesWeighter),
105            mode,
106        }
107    }
108    /// Drop cached resolutions for tracks no longer present (`live` = current ids).
109    pub fn retain(&self, live: &HashSet<i64>) {
110        self.cache.retain(|id, _| live.contains(id));
111    }
112    /// Drop one track's cached resolution (changelog-refresh removal path).
113    pub fn remove(&self, id: i64) {
114        self.cache.remove(&id);
115    }
116    /// Resolve a track to its layout, caching on a content-version miss. Validation
117    /// (`stat`) and synthesis run outside the cache; quick_cache's internal locks
118    /// are only touched by the brief get and insert.
119    pub fn resolve<M>(&self, db: &Db<M>, track_id: i64) -> Result<Arc<ResolvedFile>> {
120        let track = db
121            .get_track(track_id)?
122            .ok_or(CoreError::TrackNotFound(track_id))?;
123
124        // Always validate the backing file first — a stale file is an error even
125        // on a cache hit, because the audio region may have shifted.
126        crate::metrics::on_stat();
127        let meta = std::fs::metadata(&track.backing_path)
128            .map_err(|e| CoreError::backing_io(&track.backing_path, e))?;
129        if BackingStamp::from_metadata(&meta) != BackingStamp::from_track(&track) {
130            return Err(CoreError::BackingChanged(track.backing_path.clone()));
131        }
132
133        if let Some(hit) = self.cache.get(&track_id)
134            && hit.content_version == track.content_version
135        {
136            return Ok(hit);
137        }
138        let resolved = self.build(db, &track, &meta)?;
139        self.cache.insert(track_id, resolved.clone());
140        Ok(resolved)
141    }
142    /// Build a `ResolvedFile` for `track` (synthesis or passthrough). No lock held.
143    fn build<M>(
144        &self,
145        db: &Db<M>,
146        track: &musefs_db::Track,
147        meta: &std::fs::Metadata,
148    ) -> Result<Arc<ResolvedFile>> {
149        let (layout, total_len, mtime_secs_val) = match self.mode {
150            Mode::StructureOnly => {
151                // Pure passthrough: the synthesized "file" is the backing file itself.
152                // The stored audio bounds are irrelevant here — the whole file is served
153                // verbatim — so they are not validated in this mode.
154                let layout = RegionLayout::validated(vec![Segment::BackingAudio {
155                    offset: 0,
156                    len: meta.len(),
157                }])
158                .map_err(musefs_format::FormatError::InvalidLayout)?;
159                (
160                    layout,
161                    meta.len(),
162                    BackingStamp::from_track(track).display_secs(),
163                )
164            }
165            Mode::Synthesis => {
166                // Guard the stored audio bounds before any cast/allocation: a negative
167                // bound, or an audio region that runs past the end of the backing file,
168                // means the row no longer matches the file. Only synthesis splices at
169                // these bounds, so the check is scoped to this mode.
170                if track
171                    .bounds
172                    .audio_offset()
173                    .saturating_add(track.bounds.audio_length())
174                    > meta.len()
175                {
176                    return Err(CoreError::BackingChanged(track.backing_path.clone()));
177                }
178
179                let inputs = tags_to_inputs(db.get_tags(track.id)?);
180                let art_inputs = track_art_to_inputs(db, track.id)?;
181                let binary_tag_inputs = crate::mapping::binary_tags_to_inputs(db, track.id)?;
182
183                // FLAC re-reads the front for its preserved structural blocks; MP3 needs no
184                // front read — its ID3v2 tag is regenerated entirely from the DB and the
185                // Xing/LAME info frame travels with the backing audio.
186                let layout = match track.format {
187                    Format::Flac => {
188                        let rows = db.get_structural_blocks(track.id)?;
189                        // Fast path: the structural store holds STREAMINFO/SEEKTABLE and
190                        // APPLICATION/CUESHEET stream from value_blob rows. Legacy
191                        // fallback (no structural rows yet): carry every preserved block
192                        // — including APPLICATION/CUESHEET — inline from the front
193                        // re-read, and suppress the streamed binary tags so those blocks
194                        // are not emitted twice.
195                        let (structural, binary_tags): (Vec<MetadataBlock>, &[BinaryTagInput]) =
196                            if rows.is_empty() {
197                                let front = read_front(
198                                    Path::new(&track.backing_path),
199                                    track.bounds.audio_offset(),
200                                )?;
201                                (flac::read_metadata(&front)?.preserved, &[])
202                            } else {
203                                let structural = rows
204                                    .into_iter()
205                                    .filter_map(|b| {
206                                        flac::structural_block_type(&b.kind).map(|block_type| {
207                                            MetadataBlock {
208                                                block_type,
209                                                body: b.body,
210                                            }
211                                        })
212                                    })
213                                    .collect();
214                                (structural, &binary_tag_inputs)
215                            };
216                        for key in invalid_vorbis_keys(&inputs) {
217                            log::warn!(
218                                "track {}: dropping tag key {key:?} from Vorbis \
219                                 synthesis (not a valid field name)",
220                                track.id
221                            );
222                        }
223                        flac::synthesize_layout(
224                            &structural,
225                            track.bounds.audio_offset(),
226                            track.bounds.audio_length(),
227                            &inputs,
228                            binary_tags,
229                            &art_inputs,
230                        )?
231                    }
232                    Format::Mp3 => mp3::synthesize_layout(
233                        track.bounds.audio_offset(),
234                        track.bounds.audio_length(),
235                        &inputs,
236                        &binary_tag_inputs,
237                        &art_inputs,
238                    )?,
239                    Format::M4a => {
240                        // Read only the structural boxes (ftyp/moov/mdat header) by
241                        // seeking — never the (potentially hundreds-of-MB) mdat payload,
242                        // which is served from the backing file at read time. The `moov`
243                        // box may sit at EOF; the streaming reader skips the mdat payload
244                        // to reach it. The resulting layout's leading inline `head` ends
245                        // in a deliberately truncated `mdat` header whose payload is the
246                        // backing-audio tail.
247                        let mut f = std::fs::File::open(&track.backing_path)
248                            .map_err(|e| CoreError::backing_io(&track.backing_path, e))?;
249                        // `meta` was validated against the tracked size/mtime above,
250                        // so reuse it rather than issuing a second fstat.
251                        let len = meta.len();
252                        let scan = mp4::read_structure_from(&mut f, len).map_err(|e| match e {
253                            mp4::Mp4ScanError::Io(io) => {
254                                CoreError::backing_io(&track.backing_path, io)
255                            }
256                            mp4::Mp4ScanError::Format(fe) => CoreError::Format(fe),
257                            // Unreachable in practice (an ingested file already
258                            // passed the cap at scan, and backing-file drift is
259                            // caught by the size/mtime BackingChanged guard first),
260                            // but preserve the box/size/cap diagnostics rather than
261                            // erasing them into a generic Malformed.
262                            mp4::Mp4ScanError::MetadataTooLarge {
263                                box_kind,
264                                size,
265                                cap,
266                            } => CoreError::Mp4MetadataTooLarge {
267                                box_kind,
268                                size,
269                                cap,
270                            },
271                        })?;
272                        mp4::synthesize_layout(&scan, &inputs, &binary_tag_inputs, &art_inputs)?
273                    }
274                    Format::Wav => {
275                        // Read only the front (RIFF header + fmt/fact); the data
276                        // payload is served from the backing file at read time.
277                        let front = read_front(
278                            Path::new(&track.backing_path),
279                            track.bounds.audio_offset(),
280                        )?;
281                        let scan = wav::read_structure(&front)?;
282                        wav::synthesize_layout(
283                            &scan,
284                            track.bounds.audio_offset(),
285                            track.bounds.audio_length(),
286                            &inputs,
287                            &binary_tag_inputs,
288                            &art_inputs,
289                        )?
290                    }
291                    Format::Opus | Format::Vorbis | Format::OggFlac => {
292                        let front = read_front(
293                            Path::new(&track.backing_path),
294                            track.bounds.audio_offset(),
295                        )?;
296                        let header = musefs_format::ogg::read_metadata(&front)?;
297                        let arts: Vec<musefs_format::ogg::OggArt> = art_inputs
298                            .iter()
299                            .map(|meta| musefs_format::ogg::OggArt { meta })
300                            .collect();
301                        let src = crate::mapping::DbArtSource(db);
302                        for key in invalid_vorbis_keys(&inputs) {
303                            log::warn!(
304                                "track {}: dropping tag key {key:?} from Vorbis \
305                                 synthesis (not a valid field name)",
306                                track.id
307                            );
308                        }
309                        musefs_format::ogg::synthesize_layout(
310                            &header,
311                            track.bounds.audio_offset(),
312                            track.bounds.audio_length(),
313                            &inputs,
314                            &arts,
315                            &src,
316                        )?
317                    }
318                };
319                let total = layout.total_len();
320                (
321                    layout,
322                    total,
323                    BackingStamp::from_track(track)
324                        .display_secs()
325                        .max(track.updated_at),
326                )
327            }
328        };
329
330        // Defensive belt-and-suspenders: production layouts are already built via
331        // RegionLayout::validated, but re-validate at the cache boundary so a future
332        // construction path that skips validation cannot poison the cache.
333        layout
334            .validate()
335            .map_err(musefs_format::FormatError::InvalidLayout)?;
336
337        let cache_bytes = layout
338            .segments()
339            .iter()
340            .map(|s| match s {
341                Segment::Inline(b) => b.len() as u64,
342                _ => 0,
343            })
344            .sum::<u64>();
345        let streams_db_rowid = layout.streams_db_rowid();
346        Ok(Arc::new(ResolvedFile {
347            layout,
348            total_len,
349            track_id: track.id,
350            content_version: track.content_version,
351            // Trust boundary: `backing_path` is taken verbatim from the DB row
352            // and later opened with default flags (O_RDONLY, symlinks followed)
353            // at the serve sites below. The external-writer store contract
354            // treats the DB as semi-trusted, so a buggy/hostile writer could
355            // point this at an arbitrary path the mount uid can read. There is
356            // deliberately no realpath/containment check here: musefs has no
357            // serve-time library-root to contain against, and the audio-bytes
358            // invariant (served bytes are byte-identical to the named file)
359            // still holds — the served bytes are simply *some* file's, not a
360            // guaranteed-intended one. Documented, not enforced (#551).
361            backing_path: PathBuf::from(&track.backing_path),
362            stamp: BackingStamp::from_track(track),
363            mtime_secs: mtime_secs_val,
364            last_page: Mutex::new(None),
365            cache_bytes,
366            streams_db_rowid,
367        }))
368    }
369    /// Current number of cached resolved-file entries.
370    pub fn entry_count(&self) -> u64 {
371        self.cache.len() as u64
372    }
373    /// Current resident inline-byte weight.
374    pub fn weight_bytes(&self) -> u64 {
375        self.cache.weight()
376    }
377    /// Configured resident-byte budget.
378    pub fn budget_bytes(&self) -> u64 {
379        self.cache.capacity()
380    }
381    /// Raw key-hit count (NOT content-version-validated hits — see telemetry docs).
382    pub fn raw_hits(&self) -> u64 {
383        self.cache.hits()
384    }
385    /// Raw key-miss count.
386    pub fn raw_misses(&self) -> u64 {
387        self.cache.misses()
388    }
389}
390
391pub fn read_at_into<M>(
392    resolved: &ResolvedFile,
393    db: &Db<M>,
394    offset: u64,
395    size: u64,
396    out: &mut Vec<u8>,
397) -> Result<()> {
398    if offset >= resolved.total_len || size == 0 {
399        return Ok(());
400    }
401    let needs_file = resolved
402        .layout
403        .segments()
404        .iter()
405        .any(|s| matches!(s, Segment::BackingAudio { .. } | Segment::OggAudio { .. }));
406    // Open and re-validate the backing fd against the stamp the layout was
407    // resolved from (#503): between the resolve-time stat and this open the file
408    // can be rename-replaced or rewritten in place, which would otherwise splice
409    // bytes from a different/modified file behind the stamped header (or
410    // short-read against a stale size). The handle fast path validates per read
411    // via `validate_opened_backing`; this stateless fallback must too.
412    let file = if needs_file {
413        crate::metrics::on_open();
414        // Opens the semi-trusted DB path verbatim — see the trust-boundary note
415        // on `ResolvedFile::backing_path` in `HeaderCache::build` (#551).
416        let f = std::fs::File::open(&resolved.backing_path)
417            .map_err(|e| CoreError::backing_io(&resolved.backing_path, e))?;
418        let f_meta = f
419            .metadata()
420            .map_err(|e| CoreError::backing_io(&resolved.backing_path, e))?;
421        if BackingStamp::from_metadata(&f_meta) != resolved.stamp {
422            return Err(CoreError::BackingChanged(
423                resolved.backing_path.to_string_lossy().into_owned(),
424            ));
425        }
426        Some(f)
427    } else {
428        None
429    };
430
431    // DB-rowid segments (binary tags AND art) must be read under one WAL
432    // snapshot with a `content_version` recheck so a concurrent rowid-reuse
433    // (delete + reinsert reusing a freed rowid) can't splice a wrong blob
434    // mid-read (#502). Only the rare rowid-streaming layout pays this cost.
435    if resolved.streams_db_rowid {
436        db.begin_read()?;
437        let res = (|| {
438            if db.track_content_version(resolved.track_id)? != resolved.content_version {
439                // Stale resolve: the layout no longer matches the live row.
440                // Surface a retryable error rather than risk wrong bytes.
441                return Err(CoreError::BackingChanged(
442                    resolved.backing_path.to_string_lossy().into_owned(),
443                ));
444            }
445            read_with_optional_backing(resolved, db, file.as_ref(), offset, size, out)
446        })();
447        let _ = db.end_read(); // always release the snapshot
448        res
449    } else {
450        read_with_optional_backing(resolved, db, file.as_ref(), offset, size, out)
451    }
452}
453
454/// Build the optional `BackingReader` from an already-validated `file` and run
455/// the segment-splicing loop. Shared by `read_at_into`'s snapshot and
456/// non-snapshot branches so the backing-reader wiring lives in one place.
457fn read_with_optional_backing<M>(
458    resolved: &ResolvedFile,
459    db: &Db<M>,
460    file: Option<&std::fs::File>,
461    offset: u64,
462    size: u64,
463    out: &mut Vec<u8>,
464) -> Result<()> {
465    match file {
466        Some(file) => {
467            let pool = crate::readahead::ReadAheadPool::new(0);
468            let buf =
469                std::sync::Arc::new(std::sync::Mutex::new(crate::readahead::ReadAhead::new(0)));
470            let backing_len = resolved.stamp.size;
471            let epoch = std::sync::atomic::AtomicU64::new(0);
472            let br =
473                crate::readahead::BackingReader::new(file, &buf, &pool, 0, backing_len, &epoch);
474            read_segments_into(resolved, Some(db), Some(&br), offset, size, out)
475        }
476        None => read_segments_into(resolved, Some(db), None, offset, size, out),
477    }
478}
479
480/// The distinct user-defined keys in `inputs` that the Vorbis synthesis path
481/// drops because they are not valid field names. Pure and unit-tested; the
482/// caller logs them so a silently-dropped key is observable. Deduped so a
483/// multi-valued bad key warns once, not once per value.
484fn invalid_vorbis_keys(inputs: &[musefs_format::TagInput]) -> Vec<&str> {
485    let mut seen = HashSet::new();
486    inputs
487        .iter()
488        .map(|t| t.key.as_str())
489        .filter(|k| !musefs_format::is_valid_vorbis_key(k))
490        .filter(|k| seen.insert(*k))
491        .collect()
492}
493
494/// Allocating form of `read_at_into` (tests and non-hot-path callers).
495pub fn read_at<M>(resolved: &ResolvedFile, db: &Db<M>, offset: u64, size: u64) -> Result<Vec<u8>> {
496    let mut out = Vec::new();
497    read_at_into(resolved, db, offset, size, &mut out)?;
498    Ok(out)
499}
500
501/// The single segment-splicing loop. `backing` is `Some` whenever the layout has a
502/// `BackingAudio`/`OggAudio` segment (guaranteed by `read_at`/`read_at_with_file`);
503/// `db` is `Some` whenever the layout has a DB-backed segment
504/// (`ArtImage`/`BinaryTag`/`OggArtSlice`, i.e. `streams_db_rowid`). Both arms treat
505/// `None` as a contract violation, so a pure-backing layout can be served without a
506/// pooled DB connection at all (#520).
507fn read_segments_into<M>(
508    resolved: &ResolvedFile,
509    db: Option<&Db<M>>,
510    backing: Option<&crate::readahead::BackingReader>,
511    offset: u64,
512    size: u64,
513    out: &mut Vec<u8>,
514) -> Result<()> {
515    if offset >= resolved.total_len || size == 0 {
516        return Ok(());
517    }
518    let end = offset.saturating_add(size).min(resolved.total_len);
519    out.reserve(usize_from(end - offset));
520
521    let mut seg_start = 0u64;
522    for seg in resolved.layout.segments() {
523        let seg_len = seg.len();
524        let seg_end = seg_start + seg_len;
525        let ov_start = offset.max(seg_start);
526        let ov_end = end.min(seg_end);
527        if ov_start < ov_end {
528            let within = ov_start - seg_start;
529            let n = usize_from(ov_end - ov_start);
530            match seg {
531                Segment::Inline(bytes) => {
532                    let w = usize_from(within);
533                    out.extend_from_slice(&bytes[w..w + n]);
534                }
535                Segment::BackingAudio { offset: bo, .. } => {
536                    let br = backing.expect("backing segment requires an open backing reader");
537                    let start = out.len();
538                    out.resize(start + n, 0);
539                    br.read_exact_at(&mut out[start..], bo + within)?;
540                }
541                Segment::ArtImage { art_id, .. } => {
542                    let db = db.expect("art segment requires a DB connection");
543                    let start = out.len();
544                    out.resize(start + n, 0);
545                    db.read_art_chunk_into(*art_id, within, &mut out[start..])?;
546                    crate::metrics::on_art_chunk();
547                }
548                Segment::BinaryTag { payload_id, .. } => {
549                    let db = db.expect("binary-tag segment requires a DB connection");
550                    let start = out.len();
551                    out.resize(start + n, 0);
552                    db.read_binary_tag_chunk_into(*payload_id, within, &mut out[start..])?;
553                    crate::metrics::on_binary_tag_chunk();
554                }
555                Segment::OggAudio {
556                    offset: ao,
557                    seq_delta,
558                    len,
559                } => {
560                    let br = backing.expect("ogg-audio segment requires an open backing reader");
561                    serve_ogg_window(
562                        br,
563                        *ao,
564                        *len,
565                        *seq_delta,
566                        within,
567                        within + n as u64,
568                        &mut *out,
569                        Some(&resolved.last_page),
570                    )?;
571                }
572                Segment::OggArtSlice {
573                    art_id,
574                    offset,
575                    base64,
576                    art_total,
577                    ..
578                } => {
579                    let db = db.expect("ogg-art segment requires a DB connection");
580                    if *base64 {
581                        let w =
582                            musefs_format::ogg::b64_window(*offset + within, n as u64, *art_total);
583                        let raw = db.read_art_chunk(*art_id, w.in_start, usize_from(w.in_len))?;
584                        crate::metrics::on_art_chunk();
585                        let slice = musefs_format::ogg::encode_b64_slice(&raw, w.skip, n)
586                            .ok_or_else(|| {
587                                CoreError::BackingChanged(format!(
588                                    "art {} shorter than its indexed base64 window",
589                                    *art_id
590                                ))
591                            })?;
592                        out.extend_from_slice(&slice);
593                    } else {
594                        let start = out.len();
595                        out.resize(start + n, 0);
596                        db.read_art_chunk_into(*art_id, *offset + within, &mut out[start..])?;
597                        crate::metrics::on_art_chunk();
598                    }
599                }
600            }
601        }
602        seg_start = seg_end;
603        if seg_start >= end {
604            break;
605        }
606    }
607    Ok(())
608}
609
610/// Serve into `out` from an already-open backing reader (per-handle path). `db`
611/// is `None` for a pure-backing layout (no `streams_db_rowid` segment), letting
612/// the caller serve without a pooled DB connection (#520).
613pub fn read_at_with_file_into<M>(
614    resolved: &ResolvedFile,
615    db: Option<&Db<M>>,
616    backing: &crate::readahead::BackingReader,
617    offset: u64,
618    size: u64,
619    out: &mut Vec<u8>,
620) -> Result<()> {
621    read_segments_into(resolved, db, Some(backing), offset, size, out)
622}
623
624/// Allocating form of `read_at_with_file_into`.
625pub fn read_at_with_file<M>(
626    resolved: &ResolvedFile,
627    db: &Db<M>,
628    backing: &crate::readahead::BackingReader,
629    offset: u64,
630    size: u64,
631) -> Result<Vec<u8>> {
632    let mut out = Vec::new();
633    read_at_with_file_into(resolved, Some(db), backing, offset, size, &mut out)?;
634    Ok(out)
635}
636
637#[cfg(test)]
638mod ogg_serve_tests {
639    use super::*;
640    use musefs_format::Segment;
641    use musefs_format::ogg::page_test_support::lace_packet_pub;
642    use std::io::Write;
643
644    #[test]
645    fn read_at_renumbers_audio_and_preserves_payload() {
646        // Build a file: 8 header bytes + two audio pages (seq 3,4).
647        let (mut audio, _) = lace_packet_pub(0x99, 3, false, 10, &[0xA1u8; 200]);
648        let (a2, _) = lace_packet_pub(0x99, 4, false, 20, &vec![0xB2u8; 250]);
649        audio.extend_from_slice(&a2);
650        let audio_offset = 8u64;
651        let mut file_bytes = vec![0xFFu8; usize_from(audio_offset)];
652        file_bytes.extend_from_slice(&audio);
653
654        let dir = tempfile::tempdir().unwrap();
655        let path = dir.path().join("a.opus");
656        std::fs::File::create(&path)
657            .unwrap()
658            .write_all(&file_bytes)
659            .unwrap();
660
661        let layout = RegionLayout::validated(vec![
662            Segment::Inline(b"HDRBYTES".to_vec()), // 8 inline header bytes
663            Segment::OggAudio {
664                offset: audio_offset,
665                len: audio.len() as u64,
666                seq_delta: 1, // 3->4, 4->5
667            },
668        ])
669        .unwrap();
670        let total = layout.total_len();
671        let resolved = ResolvedFile {
672            layout,
673            total_len: total,
674            track_id: 1,
675            content_version: 0,
676            backing_path: path.clone(),
677            // Stamp the real file so the fallback's backing-fd re-validation
678            // (#503) passes; a dummy stamp would now read as a changed backing.
679            stamp: BackingStamp::from_metadata(&std::fs::metadata(&path).unwrap()),
680            mtime_secs: 0,
681            last_page: Mutex::new(None),
682            cache_bytes: 8,
683            streams_db_rowid: false,
684        };
685
686        // Read the whole virtual file; needs a Db only for ArtImage (unused here).
687        let db = musefs_db::Db::open_in_memory().unwrap();
688        let got = read_at(&resolved, &db, 0, total).unwrap();
689        assert_eq!(got.len(), usize_from(total));
690        assert_eq!(&got[0..8], b"HDRBYTES");
691
692        // The served audio region must have renumbered seqs (4 and 5) and identical
693        // payloads to the source.
694        let served_audio = &got[8..];
695        let h0 = musefs_format::ogg::parse_page(served_audio, 0).unwrap();
696        assert_eq!(h0.seq, 4);
697        let p1_off = h0.total_len();
698        let h1 = musefs_format::ogg::parse_page(served_audio, p1_off).unwrap();
699        assert_eq!(h1.seq, 5);
700        // Payload bytes unchanged.
701        assert!(
702            served_audio[h0.header_len..h0.total_len()]
703                .iter()
704                .all(|&b| b == 0xA1)
705        );
706        assert!(
707            served_audio[p1_off + h1.header_len..p1_off + h1.total_len()]
708                .iter()
709                .all(|&b| b == 0xB2)
710        );
711    }
712}
713
714#[cfg(test)]
715mod resolve_ogg_tests {
716    use super::*;
717    use musefs_db::{Db, Format, NewTrack, Tag};
718    use musefs_format::ogg::page_test_support::lace_packet_pub;
719    use std::io::Write;
720    use std::os::unix::fs::MetadataExt;
721
722    fn build_opus_file(path: &std::path::Path) -> (u64, u64) {
723        let head = b"OpusHead\x01\x02\x38\x01\x80\xbb\x00\x00\x00\x00\x00".to_vec();
724        let mut tags = b"OpusTags".to_vec();
725        tags.extend_from_slice(&musefs_format::ogg::page_test_support::vorbis_body_empty());
726        let (mut bytes, pages) =
727            musefs_format::ogg::page_test_support::build_header_pub(0x1234, &[&head, &tags]);
728        let audio_offset = bytes.len() as u64;
729        let _ = pages;
730        let (audio, _) = lace_packet_pub(0x1234, 2, false, 960, &vec![0x7Eu8; 400]);
731        bytes.extend_from_slice(&audio);
732        std::fs::File::create(path)
733            .unwrap()
734            .write_all(&bytes)
735            .unwrap();
736        (audio_offset, bytes.len() as u64 - audio_offset)
737    }
738
739    #[test]
740    fn resolves_and_reads_opus_with_identical_audio() {
741        let dir = tempfile::tempdir().unwrap();
742        let path = dir.path().join("track.opus");
743        let (audio_offset, audio_length) = build_opus_file(&path);
744        let original = std::fs::read(&path).unwrap();
745
746        let db = Db::open_in_memory().unwrap();
747        let meta = std::fs::metadata(&path).unwrap();
748        let track_id = db
749            .upsert_track(&NewTrack {
750                backing_path: path.to_string_lossy().into_owned(),
751                format: Format::Opus,
752                audio_offset,
753                audio_length,
754                backing_size: meta.len(),
755                backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
756                backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
757            })
758            .unwrap();
759        db.replace_tags(track_id, &[Tag::new("title", "Telephasic Workshop", 0)])
760            .unwrap();
761
762        let cache = HeaderCache::new(Mode::Synthesis);
763        let resolved = cache.resolve(&db, track_id).unwrap();
764        let out = read_at(&resolved, &db, 0, resolved.total_len).unwrap();
765
766        // The synthesized audio region (after the regenerated header) must be the
767        // original audio pages, byte-identical (seq_delta==0 here since the original
768        // OpusTags is also an empty-comment musefs-style header of equal page count).
769        let header = musefs_format::ogg::read_header(&out).unwrap();
770        let synth_audio = &out[usize_from(header.audio_offset)..];
771        assert_eq!(synth_audio, &original[usize_from(audio_offset)..]);
772
773        // Tags were rewritten. `ogg::read_tags` now returns canonical lowercase
774        // keys for known Vorbis fields (Tasks 1–6 changed the format layer).
775        let tags = musefs_format::ogg::read_tags(&out).unwrap();
776        assert!(
777            tags.iter()
778                .any(|(k, v)| k == "title" && v == "Telephasic Workshop")
779        );
780    }
781
782    #[test]
783    fn invalid_vorbis_keys_reports_distinct_out_of_grammar_keys() {
784        use musefs_format::TagInput;
785        let inputs = vec![
786            TagInput::new("artist", "A"),
787            TagInput::new("a=b", "c"),
788            TagInput::new("a=b", "d"), // same bad key twice -> reported once
789            TagInput::new("title", "S"),
790        ];
791        // Only the out-of-grammar key, deduped; valid keys are not flagged.
792        assert_eq!(invalid_vorbis_keys(&inputs), vec!["a=b"]);
793    }
794
795    #[test]
796    fn synthesis_drops_invalid_vorbis_key_end_to_end() {
797        let dir = tempfile::tempdir().unwrap();
798        let path = dir.path().join("track.opus");
799        let (audio_offset, audio_length) = build_opus_file(&path);
800
801        let db = Db::open_in_memory().unwrap();
802        let meta = std::fs::metadata(&path).unwrap();
803        let track_id = db
804            .upsert_track(&NewTrack {
805                backing_path: path.to_string_lossy().into_owned(),
806                format: Format::Opus,
807                audio_offset,
808                audio_length,
809                backing_size: meta.len(),
810                backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
811                backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
812            })
813            .unwrap();
814        // `a=b` passes the DB floor but is not a valid Vorbis field name. Without the
815        // fix it would synthesize `A=B=c` and re-parse as key "A", value "B=c".
816        db.replace_tags(
817            track_id,
818            &[
819                Tag::new("artist", "Alice", 0),
820                Tag::new("a=b", "c", 0),
821                Tag::new("title", "Song", 0),
822            ],
823        )
824        .unwrap();
825
826        let cache = HeaderCache::new(Mode::Synthesis);
827        let resolved = cache.resolve(&db, track_id).unwrap();
828        let out = read_at(&resolved, &db, 0, resolved.total_len).unwrap();
829
830        let tags = musefs_format::ogg::read_tags(&out).unwrap();
831        assert!(tags.iter().any(|(k, v)| k == "artist" && v == "Alice"));
832        assert!(tags.iter().any(|(k, v)| k == "title" && v == "Song"));
833        assert!(
834            !tags.iter().any(|(k, _)| k == "A" || k.contains('=')),
835            "the a=b key must be dropped, not synthesized as A=B=c: {tags:?}"
836        );
837    }
838
839    #[test]
840    fn read_at_with_file_matches_read_at() {
841        let dir = tempfile::tempdir().unwrap();
842        let path = dir.path().join("track.opus");
843        let (audio_offset, audio_length) = build_opus_file(&path);
844        let db = Db::open_in_memory().unwrap();
845        let meta = std::fs::metadata(&path).unwrap();
846        let track_id = db
847            .upsert_track(&NewTrack {
848                backing_path: path.to_string_lossy().into_owned(),
849                format: Format::Opus,
850                audio_offset,
851                audio_length,
852                backing_size: meta.len(),
853                backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
854                backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
855            })
856            .unwrap();
857        let cache = HeaderCache::new(Mode::Synthesis);
858        let resolved = cache.resolve(&db, track_id).unwrap();
859
860        let via_open = read_at(&resolved, &db, 0, resolved.total_len).unwrap();
861        let file = std::fs::File::open(&resolved.backing_path).unwrap();
862        let pool = crate::readahead::ReadAheadPool::new(0);
863        let buf = Arc::new(Mutex::new(crate::readahead::ReadAhead::new(0)));
864        let epoch = std::sync::atomic::AtomicU64::new(0);
865        let br = crate::readahead::BackingReader::new(&file, &buf, &pool, 0, meta.len(), &epoch);
866        let via_file = read_at_with_file(&resolved, &db, &br, 0, resolved.total_len).unwrap();
867        assert_eq!(via_open, via_file);
868    }
869
870    fn build_wav_file(path: &std::path::Path) -> (u64, u64, Vec<u8>) {
871        use std::io::Write;
872        let mut fmt = Vec::new();
873        fmt.extend_from_slice(&1u16.to_le_bytes());
874        fmt.extend_from_slice(&1u16.to_le_bytes());
875        fmt.extend_from_slice(&44_100u32.to_le_bytes());
876        fmt.extend_from_slice(&88_200u32.to_le_bytes());
877        fmt.extend_from_slice(&2u16.to_le_bytes());
878        fmt.extend_from_slice(&16u16.to_le_bytes());
879
880        let data: Vec<u8> = (0..32u8).collect();
881        let mut body = Vec::new();
882        for (id, payload) in [(&b"fmt "[..], &fmt[..]), (&b"data"[..], &data[..])] {
883            body.extend_from_slice(id);
884            body.extend_from_slice(&u32::try_from(payload.len()).unwrap().to_le_bytes());
885            body.extend_from_slice(payload);
886        }
887        let mut bytes = b"RIFF".to_vec();
888        bytes.extend_from_slice(&u32::try_from(body.len() + 4).unwrap().to_le_bytes());
889        bytes.extend_from_slice(b"WAVE");
890        bytes.extend_from_slice(&body);
891
892        let audio_offset = (bytes.len() - data.len()) as u64;
893        std::fs::File::create(path)
894            .unwrap()
895            .write_all(&bytes)
896            .unwrap();
897        (audio_offset, data.len() as u64, data)
898    }
899
900    #[test]
901    fn resolves_and_reads_wav_with_identical_audio() {
902        let dir = tempfile::tempdir().unwrap();
903        let path = dir.path().join("track.wav");
904        let (audio_offset, audio_length, original_data) = build_wav_file(&path);
905
906        let db = Db::open_in_memory().unwrap();
907        let meta = std::fs::metadata(&path).unwrap();
908        let track_id = db
909            .upsert_track(&NewTrack {
910                backing_path: path.to_string_lossy().into_owned(),
911                format: Format::Wav,
912                audio_offset,
913                audio_length,
914                backing_size: meta.len(),
915                backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
916                backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
917            })
918            .unwrap();
919        db.replace_tags(track_id, &[Tag::new("title", "Wave One", 0)])
920            .unwrap();
921
922        let cache = HeaderCache::new(Mode::Synthesis);
923        let resolved = cache.resolve(&db, track_id).unwrap();
924        let out = read_at(&resolved, &db, 0, resolved.total_len).unwrap();
925
926        // The synthesized output is a valid WAV; its data payload is byte-identical
927        // to the original audio.
928        let bounds = musefs_format::wav::locate_audio(&out).unwrap();
929        assert_eq!(
930            &out[usize_from(bounds.audio_offset)
931                ..usize_from(bounds.audio_offset + bounds.audio_length)],
932            original_data.as_slice()
933        );
934
935        // The title was synthesized into the embedded id3 chunk.
936        let tags = musefs_format::wav::read_tags(&out);
937        assert!(tags.contains(&("title".to_string(), "Wave One".to_string())));
938    }
939
940    #[test]
941    fn build_cache_bytes_counts_inline_segments_for_ogg() {
942        use musefs_db::{Format, NewTrack};
943        let dir = tempfile::tempdir().unwrap();
944        let path = dir.path().join("a.opus");
945        let (audio_offset, audio_length) = build_opus_file(&path);
946        let db = musefs_db::Db::open_in_memory().unwrap();
947        let meta = std::fs::metadata(&path).unwrap();
948        let id = db
949            .upsert_track(&NewTrack {
950                backing_path: path.to_string_lossy().into_owned(),
951                format: Format::Opus,
952                audio_offset,
953                audio_length,
954                backing_size: meta.len(),
955                backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
956                backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
957            })
958            .unwrap();
959        let cache = HeaderCache::new(Mode::Synthesis);
960        let resolved = cache.resolve(&db, id).unwrap();
961        let inline_sum: u64 = resolved
962            .layout
963            .segments()
964            .iter()
965            .map(|s| match s {
966                Segment::Inline(b) => b.len() as u64,
967                _ => 0,
968            })
969            .sum();
970        // SP4: no per-file index estimate; cache_bytes == inline segment bytes only.
971        assert_eq!(resolved.cache_bytes, inline_sum);
972        assert!(
973            inline_sum > 0,
974            "Opus header should have non-empty inline segments"
975        );
976    }
977}
978
979#[cfg(test)]
980mod ogg_art_serve_tests {
981    use super::*;
982
983    #[test]
984    fn read_at_serves_base64_art_slice_matching_full_encode() {
985        let image: Vec<u8> = (0..1000u32).map(|i| (i % 251) as u8).collect();
986        // Compute full base64 via the format crate (base64 is not a direct dep of musefs-core).
987        let full_b64 = musefs_format::ogg::encode_b64_slice(
988            &image,
989            0,
990            usize_from(musefs_format::ogg::b64_len(image.len() as u64)),
991        )
992        .expect("full-length window lies within the encoded output");
993
994        let db = musefs_db::Db::open_in_memory().unwrap();
995        let art_id = db
996            .upsert_art(&musefs_db::NewArt {
997                mime: "image/png".to_string(),
998                width: Some(1),
999                height: Some(1),
1000                data: image.clone(),
1001            })
1002            .unwrap();
1003
1004        let layout = RegionLayout::validated(vec![
1005            Segment::Inline(b"HEAD".to_vec()),
1006            Segment::OggArtSlice {
1007                art_id,
1008                offset: 0,
1009                len: musefs_format::BlobLen::new(full_b64.len() as u64).unwrap(),
1010                base64: true,
1011                art_total: image.len() as u64,
1012            },
1013            Segment::Inline(b"XY".to_vec()),
1014        ])
1015        .unwrap();
1016        let total = layout.total_len();
1017        let resolved = ResolvedFile {
1018            layout,
1019            total_len: total,
1020            track_id: 1,
1021            content_version: 0,
1022            backing_path: std::path::PathBuf::from("/dev/null"),
1023            stamp: BackingStamp {
1024                size: 0,
1025                mtime_ns: 0,
1026                ctime_ns: 0,
1027            },
1028            mtime_secs: 0,
1029            last_page: Mutex::new(None),
1030            cache_bytes: 0,
1031            streams_db_rowid: false,
1032        };
1033
1034        // Full read.
1035        let got = read_at(&resolved, &db, 0, total).unwrap();
1036        let mut want = b"HEAD".to_vec();
1037        want.extend_from_slice(&full_b64);
1038        want.extend_from_slice(b"XY");
1039        assert_eq!(got, want);
1040
1041        // Partial read straddling into the middle of the art slice (non-4-aligned).
1042        let part = read_at(&resolved, &db, 7, 23).unwrap();
1043        assert_eq!(part, want[7..30]);
1044    }
1045
1046    #[test]
1047    fn read_at_serves_raw_art_slice() {
1048        let image: Vec<u8> = (0..300u32)
1049            .map(|i| u8::try_from(i % 256).unwrap())
1050            .collect();
1051        let db = musefs_db::Db::open_in_memory().unwrap();
1052        let art_id = db
1053            .upsert_art(&musefs_db::NewArt {
1054                mime: "image/png".to_string(),
1055                width: None,
1056                height: None,
1057                data: image.clone(),
1058            })
1059            .unwrap();
1060        let layout = RegionLayout::validated(vec![Segment::OggArtSlice {
1061            art_id,
1062            offset: 0,
1063            len: musefs_format::BlobLen::new(image.len() as u64).unwrap(),
1064            base64: false,
1065            art_total: image.len() as u64,
1066        }])
1067        .unwrap();
1068        let total = layout.total_len();
1069        let resolved = ResolvedFile {
1070            layout,
1071            total_len: total,
1072            track_id: 1,
1073            content_version: 0,
1074            backing_path: std::path::PathBuf::from("/dev/null"),
1075            stamp: BackingStamp {
1076                size: 0,
1077                mtime_ns: 0,
1078                ctime_ns: 0,
1079            },
1080            mtime_secs: 0,
1081            last_page: Mutex::new(None),
1082            cache_bytes: 0,
1083            streams_db_rowid: false,
1084        };
1085        let got = read_at(&resolved, &db, 10, 50).unwrap();
1086        assert_eq!(got, image[10..60]);
1087    }
1088}
1089
1090#[cfg(test)]
1091mod cache_bound_tests {
1092    use super::*;
1093    use musefs_db::{Db, Format, NewTrack};
1094    use std::os::unix::fs::MetadataExt;
1095
1096    #[test]
1097    fn header_cache_exposes_budget_and_starts_empty() {
1098        let c = HeaderCache::with_budget(Mode::Synthesis, 1234);
1099        assert_eq!(c.entry_count(), 0);
1100        assert_eq!(c.weight_bytes(), 0);
1101        assert!(
1102            c.budget_bytes() >= 1234,
1103            "budget must be at least the requested amount"
1104        );
1105    }
1106
1107    #[test]
1108    fn header_cache_counts_entries_weight_hits_and_misses() {
1109        let dir = tempfile::tempdir().unwrap();
1110        let db = Db::open_in_memory().unwrap();
1111        let mut ids = Vec::new();
1112        for name in ["a.flac", "b.flac"] {
1113            let path = dir.path().join(name);
1114            let (audio_offset, audio_length) = write_flac_local(&path);
1115            let meta = std::fs::metadata(&path).unwrap();
1116            ids.push(
1117                db.upsert_track(&NewTrack {
1118                    backing_path: path.to_string_lossy().into_owned(),
1119                    format: Format::Flac,
1120                    audio_offset,
1121                    audio_length,
1122                    backing_size: meta.len(),
1123                    backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
1124                    backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
1125                })
1126                .unwrap(),
1127            );
1128        }
1129        let cache = HeaderCache::new(Mode::Synthesis);
1130        for id in &ids {
1131            cache.resolve(&db, *id).unwrap(); // miss → build + insert
1132            cache.resolve(&db, *id).unwrap(); // hit (content_version unchanged)
1133        }
1134        assert_eq!(cache.entry_count(), 2);
1135        assert_eq!(cache.raw_hits(), 2);
1136        assert_eq!(cache.raw_misses(), 2);
1137        assert!(
1138            cache.weight_bytes() > 0,
1139            "synthesis entries carry inline header bytes"
1140        );
1141    }
1142
1143    fn entry(content_version: i64, inline_len: usize) -> Arc<ResolvedFile> {
1144        Arc::new(ResolvedFile {
1145            layout: RegionLayout::new_unchecked(vec![Segment::Inline(vec![0u8; inline_len])]),
1146            total_len: inline_len as u64,
1147            track_id: 1,
1148            content_version,
1149            backing_path: std::path::PathBuf::from("/nonexistent"),
1150            stamp: BackingStamp {
1151                size: 0,
1152                mtime_ns: 0,
1153                ctime_ns: 0,
1154            },
1155            mtime_secs: 0,
1156            last_page: Mutex::new(None),
1157            cache_bytes: inline_len as u64,
1158            streams_db_rowid: false,
1159        })
1160    }
1161
1162    #[test]
1163    fn header_cache_resolve_caches_by_content_version() {
1164        let dir = tempfile::tempdir().unwrap();
1165        let path = dir.path().join("a.flac");
1166        let (audio_offset, audio_length) = write_flac_local(&path);
1167        let db = Db::open_in_memory().unwrap();
1168        let meta = std::fs::metadata(&path).unwrap();
1169        let id = db
1170            .upsert_track(&NewTrack {
1171                backing_path: path.to_string_lossy().into_owned(),
1172                format: Format::Flac,
1173                audio_offset,
1174                audio_length,
1175                backing_size: meta.len(),
1176                backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
1177                backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
1178            })
1179            .unwrap();
1180        let cache = HeaderCache::new(Mode::Synthesis); // NOTE: not `mut` — resolve is &self now
1181        let a = cache.resolve(&db, id).unwrap();
1182        let b = cache.resolve(&db, id).unwrap();
1183        assert!(Arc::ptr_eq(&a, &b));
1184    }
1185
1186    #[test]
1187    fn resolve_is_safe_under_concurrent_access() {
1188        // Many threads resolving the same track exercise the off-lock build race
1189        // (concurrent miss → build → insert on one shard) and concurrent gets.
1190        // Each thread needs its own connection (Db is !Sync), so use a file-backed
1191        // DB and open_readonly per thread.
1192        let dir = tempfile::tempdir().unwrap();
1193        let flac_path = dir.path().join("a.flac");
1194        let (audio_offset, audio_length) = write_flac_local(&flac_path);
1195        let db_path = dir.path().join("m.db");
1196        let track_id = {
1197            let db = Db::open(&db_path).unwrap();
1198            let meta = std::fs::metadata(&flac_path).unwrap();
1199            db.upsert_track(&NewTrack {
1200                backing_path: flac_path.to_string_lossy().into_owned(),
1201                format: Format::Flac,
1202                audio_offset,
1203                audio_length,
1204                backing_size: meta.len(),
1205                backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
1206                backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
1207            })
1208            .unwrap()
1209        };
1210
1211        let cache = std::sync::Arc::new(HeaderCache::new(Mode::Synthesis));
1212        std::thread::scope(|s| {
1213            for _ in 0..8 {
1214                let cache = std::sync::Arc::clone(&cache);
1215                let db_path = db_path.clone();
1216                s.spawn(move || {
1217                    let db = Db::open_readonly(&db_path).unwrap();
1218                    for _ in 0..50 {
1219                        let r = cache.resolve(&db, track_id).unwrap();
1220                        assert!(r.total_len > 0);
1221                        assert_eq!(r.content_version, 0);
1222                    }
1223                });
1224            }
1225        });
1226    }
1227
1228    #[test]
1229    fn header_cache_retain_drops_absent_tracks() {
1230        let dir = tempfile::tempdir().unwrap();
1231        let db = Db::open_in_memory().unwrap();
1232        let mk = |name: &str| {
1233            let path = dir.path().join(name);
1234            let (audio_offset, audio_length) = write_flac_local(&path);
1235            let meta = std::fs::metadata(&path).unwrap();
1236            db.upsert_track(&NewTrack {
1237                backing_path: path.to_string_lossy().into_owned(),
1238                format: Format::Flac,
1239                audio_offset,
1240                audio_length,
1241                backing_size: meta.len(),
1242                backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
1243                backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
1244            })
1245            .unwrap()
1246        };
1247        let keep = mk("keep.flac");
1248        let gone = mk("gone.flac");
1249        let cache = HeaderCache::new(Mode::Synthesis);
1250        let keep_a = cache.resolve(&db, keep).unwrap();
1251        let gone_a = cache.resolve(&db, gone).unwrap();
1252
1253        let live: HashSet<i64> = [keep].into_iter().collect();
1254        cache.retain(&live);
1255
1256        // The kept track stays the same cached Arc; the dropped one re-resolves fresh.
1257        assert!(Arc::ptr_eq(&keep_a, &cache.resolve(&db, keep).unwrap()));
1258        assert!(!Arc::ptr_eq(&gone_a, &cache.resolve(&db, gone).unwrap()));
1259    }
1260
1261    #[test]
1262    fn header_cache_remove_drops_one_track_only() {
1263        let dir = tempfile::tempdir().unwrap();
1264        let db = Db::open_in_memory().unwrap();
1265        let mk = |name: &str| {
1266            let path = dir.path().join(name);
1267            let (audio_offset, audio_length) = write_flac_local(&path);
1268            let meta = std::fs::metadata(&path).unwrap();
1269            db.upsert_track(&NewTrack {
1270                backing_path: path.to_string_lossy().into_owned(),
1271                format: Format::Flac,
1272                audio_offset,
1273                audio_length,
1274                backing_size: meta.len(),
1275                backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
1276                backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
1277            })
1278            .unwrap()
1279        };
1280        let keep = mk("keep.flac");
1281        let gone = mk("gone.flac");
1282        let cache = HeaderCache::new(Mode::Synthesis);
1283        let keep_a = cache.resolve(&db, keep).unwrap();
1284        let gone_a = cache.resolve(&db, gone).unwrap();
1285
1286        cache.remove(gone);
1287
1288        // The kept track stays the same cached Arc; the removed one re-resolves fresh.
1289        assert!(Arc::ptr_eq(&keep_a, &cache.resolve(&db, keep).unwrap()));
1290        assert!(!Arc::ptr_eq(&gone_a, &cache.resolve(&db, gone).unwrap()));
1291    }
1292
1293    #[test]
1294    fn default_cache_budget_is_64_mib() {
1295        assert_eq!(DEFAULT_CACHE_BUDGET, 67_108_864);
1296    }
1297
1298    #[test]
1299    fn read_segments_returns_empty_past_end_of_range() {
1300        let db = musefs_db::Db::open_in_memory().unwrap();
1301        let resolved = entry(0, 10);
1302        let out = read_at(&resolved, &db, 11, 1).unwrap();
1303        assert!(out.is_empty());
1304        let out0 = read_at(&resolved, &db, 0, 0).unwrap();
1305        assert!(out0.is_empty());
1306    }
1307
1308    fn track_with_bounds(
1309        path: &std::path::Path,
1310        audio_offset: u64,
1311        audio_length: u64,
1312    ) -> (musefs_db::Db, i64) {
1313        use musefs_db::{Format, NewTrack};
1314        let db = musefs_db::Db::open_in_memory().unwrap();
1315        let meta = std::fs::metadata(path).unwrap();
1316        let id = db
1317            .upsert_track(&NewTrack {
1318                backing_path: path.to_string_lossy().into_owned(),
1319                format: Format::Flac,
1320                audio_offset,
1321                audio_length,
1322                backing_size: meta.len(),
1323                backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
1324                backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
1325            })
1326            .unwrap();
1327        (db, id)
1328    }
1329
1330    #[test]
1331    fn build_rejects_audio_region_past_end_of_file() {
1332        // An audio region past the end of the backing file (offset + length >
1333        // backing_size) is rejected at write time by the V4 bounds CHECK — it can
1334        // no longer be committed and reach synthesis.
1335        let dir = tempfile::tempdir().unwrap();
1336        let path = dir.path().join("a.flac");
1337        let _ = write_flac_local(&path);
1338        let meta = std::fs::metadata(&path).unwrap();
1339        let db = musefs_db::Db::open_in_memory().unwrap();
1340        let rejected = db.upsert_track(&musefs_db::NewTrack {
1341            backing_path: path.to_string_lossy().into_owned(),
1342            format: musefs_db::Format::Flac,
1343            audio_offset: meta.len(),
1344            audio_length: 5,
1345            backing_size: meta.len(),
1346            backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
1347            backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
1348        });
1349        assert!(
1350            rejected.is_err(),
1351            "bounds CHECK must reject an over-EOF audio region"
1352        );
1353    }
1354
1355    #[test]
1356    fn build_accepts_audio_region_ending_exactly_at_eof() {
1357        let dir = tempfile::tempdir().unwrap();
1358        let path = dir.path().join("a.flac");
1359        let (audio_offset, audio_length) = write_flac_local(&path);
1360        let (db, id) = track_with_bounds(&path, audio_offset, audio_length);
1361        let cache = HeaderCache::new(Mode::Synthesis);
1362        let resolved = cache
1363            .resolve(&db, id)
1364            .expect("exact-fit bounds must resolve");
1365        assert!(resolved.total_len > 0);
1366    }
1367
1368    #[test]
1369    fn build_accepts_audio_region_ending_before_eof() {
1370        // A valid track whose audio region ends strictly before EOF
1371        // (audio_offset + audio_length < backing_size, allowed by TrackBounds)
1372        // must still resolve: the bounds guard rejects only an over-EOF region.
1373        // Pins the guard's `>` against `<`, which would spuriously reject every
1374        // sub-EOF track.
1375        let dir = tempfile::tempdir().unwrap();
1376        let path = dir.path().join("a.flac");
1377        let (audio_offset, audio_length) = write_flac_local(&path);
1378        // Append trailing bytes so the audio region no longer reaches EOF; the
1379        // padded length becomes backing_size, leaving offset + length < it.
1380        use std::io::Write;
1381        std::fs::OpenOptions::new()
1382            .append(true)
1383            .open(&path)
1384            .unwrap()
1385            .write_all(&[0u8; 64])
1386            .unwrap();
1387        let (db, id) = track_with_bounds(&path, audio_offset, audio_length);
1388        let cache = HeaderCache::new(Mode::Synthesis);
1389        let resolved = cache.resolve(&db, id).expect("sub-EOF bounds must resolve");
1390        assert!(resolved.total_len > 0);
1391    }
1392
1393    #[test]
1394    fn build_cache_bytes_counts_inline_segments() {
1395        let dir = tempfile::tempdir().unwrap();
1396        let path = dir.path().join("a.flac");
1397        let (audio_offset, audio_length) = write_flac_local(&path);
1398        let (db, id) = track_with_bounds(&path, audio_offset, audio_length);
1399        let cache = HeaderCache::new(Mode::Synthesis);
1400        let resolved = cache.resolve(&db, id).unwrap();
1401        let inline_sum: u64 = resolved
1402            .layout
1403            .segments()
1404            .iter()
1405            .map(|s| match s {
1406                Segment::Inline(b) => b.len() as u64,
1407                _ => 0,
1408            })
1409            .sum();
1410        assert!(inline_sum > 0);
1411        assert_eq!(resolved.cache_bytes, inline_sum);
1412    }
1413
1414    #[test]
1415    fn build_rejects_layout_failing_validation() {
1416        // A layout with an empty Inline segment fails validate(); the defensive
1417        // check at the cache boundary must surface it rather than cache it.
1418        let bad = RegionLayout::new_unchecked(vec![Segment::Inline(vec![])]);
1419        let err = bad.validate();
1420        assert!(err.is_err());
1421    }
1422
1423    fn write_flac_local(path: &std::path::Path) -> (u64, u64) {
1424        fn block(bt: u8, body: &[u8], last: bool) -> Vec<u8> {
1425            let mut v = vec![(if last { 0x80 } else { 0 }) | (bt & 0x7F)];
1426            let n: u32 = u32::try_from(body.len()).unwrap();
1427            v.extend_from_slice(&[
1428                u8::try_from(n >> 16).unwrap(),
1429                u8::try_from(n >> 8).unwrap(),
1430                u8::try_from(n).unwrap(),
1431            ]);
1432            v.extend_from_slice(body);
1433            v
1434        }
1435        let mut si = vec![
1436            0x10, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0A, 0xC4, 0x42, 0xF0,
1437            0x00, 0x00, 0x00, 0x00,
1438        ];
1439        si.extend_from_slice(&[0u8; 16]);
1440        let mut vc = Vec::new();
1441        let vendor = b"x";
1442        vc.extend_from_slice(&u32::try_from(vendor.len()).unwrap().to_le_bytes());
1443        vc.extend_from_slice(vendor);
1444        vc.extend_from_slice(&0u32.to_le_bytes());
1445        let mut out = b"fLaC".to_vec();
1446        out.extend(block(0, &si, false));
1447        out.extend(block(4, &vc, true));
1448        let audio = [0xABu8; 256];
1449        let audio_offset = out.len() as u64;
1450        out.extend_from_slice(&audio);
1451        std::fs::write(path, &out).unwrap();
1452        (audio_offset, audio.len() as u64)
1453    }
1454
1455    #[test]
1456    fn cache_weight_stays_within_budget_after_flood() {
1457        let cache = HeaderCache::with_budget(Mode::Synthesis, 4096);
1458        for id in 0..64i64 {
1459            cache.cache.insert(id, entry(0, 256)); // 64 × 256 B = 16 KiB ≫ 4 KiB
1460        }
1461        // End-state assertion only: quick_cache does not document per-insert
1462        // synchronous eviction, so the per-insert bound is not guaranteed.
1463        assert!(
1464            cache.cache.weight() <= 4096,
1465            "total weight {} exceeds the 4096-byte budget",
1466            cache.cache.weight()
1467        );
1468        // len() is assumed to count resident entries. If this assertion ever
1469        // trips, the diagnosis is the same as the weight() note above: re-read
1470        // the spec's eviction-timing section and escalate — don't loosen.
1471        assert!(
1472            cache.cache.len() < 64,
1473            "no eviction happened: all 64 over-budget entries are resident"
1474        );
1475    }
1476
1477    #[test]
1478    fn zero_cache_bytes_entry_still_weighs_one() {
1479        // StructureOnly layouts have cache_bytes == 0; the weigher's .max(1) keeps
1480        // them inside the weighted bound instead of escaping it (quick_cache
1481        // ignores zero-weight entries when evicting).
1482        let cache = HeaderCache::with_budget(Mode::StructureOnly, 1024);
1483        cache.cache.insert(1, entry(0, 0));
1484        assert_eq!(cache.cache.weight(), 1);
1485        assert!(cache.cache.get(&1).is_some());
1486    }
1487}
1488
1489#[cfg(test)]
1490mod binary_tag_serve_tests {
1491    use super::*;
1492    use musefs_db::{BinaryTag, NewTrack};
1493    use std::os::unix::fs::MetadataExt;
1494
1495    #[test]
1496    fn resolve_mp3_emits_binary_tag_in_synthesized_region() {
1497        use id3::frame::{Content, Unknown};
1498        use id3::{Encoder, Frame, Tag, TagLike, Version};
1499        let dir = tempfile::tempdir().unwrap();
1500        let mut tag = Tag::new();
1501        let needle = [0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x77, 0x88];
1502        tag.add_frame(Frame::with_content(
1503            "PRIV",
1504            Content::Unknown(Unknown {
1505                data: needle.to_vec(),
1506                version: Version::Id3v24,
1507            }),
1508        ));
1509        let mut bytes = Vec::new();
1510        Encoder::new()
1511            .version(Version::Id3v24)
1512            .encode(&tag, &mut bytes)
1513            .unwrap();
1514        bytes.extend_from_slice(&[0xFF, 0xFB, 0x90, 0x00]);
1515        let path = dir.path().join("a.mp3");
1516        std::fs::write(&path, &bytes).unwrap();
1517
1518        let db = musefs_db::Db::open_in_memory().unwrap();
1519        let bounds = musefs_format::mp3::locate_audio(&bytes).unwrap();
1520        let meta = std::fs::metadata(&path).unwrap();
1521        let tid = db
1522            .upsert_track(&musefs_db::NewTrack {
1523                backing_path: path.to_string_lossy().into_owned(),
1524                format: musefs_db::Format::Mp3,
1525                audio_offset: bounds.audio_offset,
1526                audio_length: bounds.audio_length,
1527                backing_size: meta.len(),
1528                backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
1529                backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
1530            })
1531            .unwrap();
1532        db.set_binary_tags(
1533            tid,
1534            &[musefs_db::BinaryTag {
1535                key: "PRIV".into(),
1536                payload: needle.to_vec(),
1537                ordinal: 0,
1538            }],
1539        )
1540        .unwrap();
1541
1542        let cache = crate::reader::HeaderCache::new(crate::Mode::Synthesis);
1543        let resolved = cache.resolve(&db, tid).unwrap();
1544        let whole = crate::reader::read_at(&resolved, &db, 0, resolved.total_len).unwrap();
1545        assert!(
1546            whole.windows(needle.len()).any(|w| w == needle),
1547            "PRIV body not in synthesized file"
1548        );
1549    }
1550
1551    #[test]
1552    fn read_at_serves_binary_tag_segment() {
1553        let db = Db::open_in_memory().unwrap();
1554        let id = db
1555            .upsert_track(&NewTrack {
1556                backing_path: "/x.mp3".into(),
1557                format: Format::Mp3,
1558                audio_offset: 0,
1559                audio_length: 0,
1560                backing_size: 0,
1561                backing_mtime_ns: 0,
1562                backing_ctime_ns: 0,
1563            })
1564            .unwrap();
1565        db.set_binary_tags(
1566            id,
1567            &[BinaryTag {
1568                key: "PRIV".into(),
1569                payload: vec![10, 20, 30, 40],
1570                ordinal: 0,
1571            }],
1572        )
1573        .unwrap();
1574        let rowid = db.get_binary_tags(id).unwrap()[0].rowid;
1575
1576        let resolved = ResolvedFile {
1577            layout: RegionLayout::validated(vec![Segment::BinaryTag {
1578                payload_id: rowid,
1579                len: musefs_format::BlobLen::new(4).unwrap(),
1580            }])
1581            .unwrap(),
1582            total_len: 4,
1583            track_id: id,
1584            // Match the live row: set_binary_tags bumps content_version via
1585            // trigger, and the rowid-streaming read path rechecks it (#502).
1586            content_version: db.track_content_version(id).unwrap(),
1587            backing_path: PathBuf::from("/x.mp3"),
1588            stamp: BackingStamp {
1589                size: 0,
1590                mtime_ns: 0,
1591                ctime_ns: 0,
1592            },
1593            mtime_secs: 0,
1594            last_page: Mutex::new(None),
1595            cache_bytes: 0,
1596            streams_db_rowid: true,
1597        };
1598        // No BackingAudio segment, so read_at opens no file.
1599        let got = read_at(&resolved, &db, 1, 2).unwrap();
1600        assert_eq!(got, vec![20, 30]);
1601    }
1602
1603    #[test]
1604    fn fallback_read_rejects_changed_backing() {
1605        // #503: the stateless read path must re-validate the freshly opened
1606        // backing fd against the resolved stamp, like the handle fast path does.
1607        let dir = tempfile::tempdir().unwrap();
1608        let path = dir.path().join("a.mp3");
1609        std::fs::write(&path, vec![0u8; 100]).unwrap();
1610        let db = Db::open_in_memory().unwrap();
1611        let layout = RegionLayout::validated(vec![
1612            Segment::Inline(vec![1, 2, 3]),
1613            Segment::BackingAudio {
1614                offset: 0,
1615                len: 100,
1616            },
1617        ])
1618        .unwrap();
1619        let total = layout.total_len();
1620        let resolved = ResolvedFile {
1621            layout,
1622            total_len: total,
1623            track_id: 1,
1624            content_version: 0,
1625            backing_path: path.clone(),
1626            stamp: BackingStamp::from_metadata(&std::fs::metadata(&path).unwrap()),
1627            mtime_secs: 0,
1628            last_page: Mutex::new(None),
1629            cache_bytes: 3,
1630            streams_db_rowid: false,
1631        };
1632        // Matching stamp: the read succeeds.
1633        assert!(read_at(&resolved, &db, 0, total).is_ok());
1634        // Replace the backing file with a different size -> stamp mismatch.
1635        std::fs::write(&path, vec![0u8; 200]).unwrap();
1636        let err = read_at(&resolved, &db, 0, total).unwrap_err();
1637        assert!(matches!(err, CoreError::BackingChanged(_)), "{err:?}");
1638    }
1639
1640    #[test]
1641    fn fallback_read_of_art_rechecks_content_version() {
1642        // #502: an art-only layout (no BinaryTag) must take the snapshot +
1643        // content_version recheck path on the stateless fallback, so a stale
1644        // resolve cannot stream a reused art rowid's bytes.
1645        let db = Db::open_in_memory().unwrap();
1646        let id = db
1647            .upsert_track(&NewTrack {
1648                backing_path: "/y.mp3".into(),
1649                format: Format::Mp3,
1650                audio_offset: 0,
1651                audio_length: 0,
1652                backing_size: 0,
1653                backing_mtime_ns: 0,
1654                backing_ctime_ns: 0,
1655            })
1656            .unwrap();
1657        let art_id = db
1658            .upsert_art(&musefs_db::NewArt {
1659                mime: "image/png".into(),
1660                width: None,
1661                height: None,
1662                data: vec![1, 2, 3, 4],
1663            })
1664            .unwrap();
1665        let layout = RegionLayout::validated(vec![Segment::ArtImage {
1666            art_id,
1667            len: musefs_format::BlobLen::new(4).unwrap(),
1668        }])
1669        .unwrap();
1670        let live_cv = db.track_content_version(id).unwrap();
1671        let mk = |content_version| ResolvedFile {
1672            layout: layout.clone(),
1673            total_len: 4,
1674            track_id: id,
1675            content_version,
1676            backing_path: PathBuf::from("/y.mp3"),
1677            stamp: BackingStamp {
1678                size: 0,
1679                mtime_ns: 0,
1680                ctime_ns: 0,
1681            },
1682            mtime_secs: 0,
1683            last_page: Mutex::new(None),
1684            cache_bytes: 0,
1685            streams_db_rowid: true,
1686        };
1687        // Live content_version: art bytes are served.
1688        assert_eq!(read_at(&mk(live_cv), &db, 0, 4).unwrap(), vec![1, 2, 3, 4]);
1689        // Stale content_version: the recheck (now covering art) rejects.
1690        let err = read_at(&mk(live_cv + 1), &db, 0, 4).unwrap_err();
1691        assert!(matches!(err, CoreError::BackingChanged(_)), "{err:?}");
1692    }
1693}
1694
1695#[cfg(test)]
1696mod serve_cap_tests {
1697    use super::*;
1698    use musefs_db::{Db, Format, NewTrack};
1699
1700    const CAP: u64 = crate::scan::MAX_PROBE_BYTES;
1701
1702    /// A sparse backing file of `len` bytes (no real bytes written — `set_len`
1703    /// only extends the file's logical size, which tmpfs keeps sparse).
1704    fn sparse_file(dir: &std::path::Path, name: &str, len: u64) -> std::path::PathBuf {
1705        let path = dir.join(name);
1706        let f = std::fs::File::create(&path).unwrap();
1707        f.set_len(len).unwrap();
1708        path
1709    }
1710
1711    /// Insert a `tracks` row whose `audio_offset` exceeds the cap while still
1712    /// satisfying both serve guards (`backing_size == meta.len()` and
1713    /// `audio_offset + audio_length <= meta.len()`). Returns the track id.
1714    /// Takes `&Db` (= `Db<ReadWrite>`) because `upsert_track` is defined on
1715    /// `impl Db<ReadWrite>`, not the generic `impl<M> Db<M>`.
1716    fn hostile_track(db: &Db, path: &std::path::Path, format: Format) -> i64 {
1717        let meta = std::fs::metadata(path).unwrap();
1718        let stamp = BackingStamp::from_metadata(&meta);
1719        db.upsert_track(&NewTrack {
1720            backing_path: path.to_string_lossy().into_owned(),
1721            format,
1722            audio_offset: CAP + 1,
1723            audio_length: 1,
1724            backing_size: meta.len(),
1725            backing_mtime_ns: stamp.mtime_ns,
1726            backing_ctime_ns: stamp.ctime_ns,
1727        })
1728        .unwrap()
1729    }
1730
1731    /// Assert a resolve attempt fails closed with the cap error for `audio_offset`.
1732    fn assert_capped(result: crate::Result<std::sync::Arc<ResolvedFile>>) {
1733        match result {
1734            Err(CoreError::HeaderTooLarge { requested, cap }) => {
1735                assert_eq!(requested, CAP + 1);
1736                assert_eq!(cap, CAP);
1737            }
1738            Err(other) => panic!("expected HeaderTooLarge, got {other:?}"),
1739            Ok(_) => panic!("expected HeaderTooLarge, resolve unexpectedly succeeded"),
1740        }
1741    }
1742
1743    #[test]
1744    fn wav_serve_caps_hostile_offset() {
1745        let dir = tempfile::tempdir().unwrap();
1746        let path = sparse_file(dir.path(), "hostile.wav", CAP + 2);
1747        let db = Db::open_in_memory().unwrap();
1748        let track_id = hostile_track(&db, &path, Format::Wav);
1749
1750        let cache = HeaderCache::new(Mode::Synthesis);
1751        assert_capped(cache.resolve(&db, track_id));
1752    }
1753
1754    #[test]
1755    fn ogg_serve_caps_hostile_offset() {
1756        let dir = tempfile::tempdir().unwrap();
1757        let path = sparse_file(dir.path(), "hostile.opus", CAP + 2);
1758        let db = Db::open_in_memory().unwrap();
1759        let track_id = hostile_track(&db, &path, Format::Opus);
1760
1761        let cache = HeaderCache::new(Mode::Synthesis);
1762        assert_capped(cache.resolve(&db, track_id));
1763    }
1764
1765    #[test]
1766    fn flac_legacy_serve_caps_hostile_offset() {
1767        let dir = tempfile::tempdir().unwrap();
1768        let path = sparse_file(dir.path(), "hostile.flac", CAP + 2);
1769        let db = Db::open_in_memory().unwrap();
1770        // No structural-block rows inserted -> build() takes the legacy fallback
1771        // branch (rows.is_empty()) that calls read_front.
1772        let track_id = hostile_track(&db, &path, Format::Flac);
1773        assert!(db.get_structural_blocks(track_id).unwrap().is_empty());
1774
1775        let cache = HeaderCache::new(Mode::Synthesis);
1776        assert_capped(cache.resolve(&db, track_id));
1777    }
1778
1779    #[test]
1780    fn read_front_rejects_oversize_before_open() {
1781        // Nonexistent path: if the cap check did NOT fire first, File::open would
1782        // error and we'd get an Io error instead of HeaderTooLarge. So this also
1783        // pins the fail-closed ordering (check precedes any open/allocation).
1784        let err =
1785            read_front(std::path::Path::new("/nonexistent/musefs/front"), CAP + 1).unwrap_err();
1786        match err {
1787            CoreError::HeaderTooLarge { requested, cap } => {
1788                assert_eq!(requested, CAP + 1);
1789                assert_eq!(cap, CAP);
1790            }
1791            other => panic!("expected HeaderTooLarge, got {other:?}"),
1792        }
1793    }
1794
1795    #[test]
1796    fn read_front_allows_exactly_cap() {
1797        // Boundary: `n == CAP` must NOT be rejected — the check is `>`, not `>=`.
1798        // With a nonexistent path the call still fails, but with an Io error from
1799        // File::open, never HeaderTooLarge. This pins the boundary and kills the
1800        // `> -> >=` mutant.
1801        let err = read_front(std::path::Path::new("/nonexistent/musefs/front"), CAP).unwrap_err();
1802        assert!(
1803            matches!(err, CoreError::BackingIo { .. }),
1804            "expected a backing-file Io error at the cap boundary, got {err:?}"
1805        );
1806    }
1807
1808    #[test]
1809    fn read_front_io_error_carries_the_backing_path() {
1810        // The most common passthrough failure (a moved/inaccessible backing file)
1811        // must name the path rather than collapse to a pathless `Io`/EIO (#521).
1812        let p = std::path::Path::new("/nonexistent/musefs/backing.flac");
1813        match read_front(p, 16).unwrap_err() {
1814            CoreError::BackingIo { path, .. } => assert_eq!(path, p),
1815            other => panic!("expected BackingIo carrying the path, got {other:?}"),
1816        }
1817    }
1818}
1819
1820#[cfg(test)]
1821mod readahead_differential_tests {
1822    use super::*;
1823    use crate::readahead::{BackingReader, ReadAhead, ReadAheadPool};
1824    use std::sync::{Arc, Mutex};
1825
1826    fn pcm_fixture() -> (musefs_db::Db, Arc<ResolvedFile>, std::fs::File) {
1827        let dir = tempfile::tempdir().unwrap();
1828        let path = dir.path().join("test.wav");
1829        let mut body = Vec::new();
1830        body.extend_from_slice(b"fmt ");
1831        body.extend_from_slice(&16u32.to_le_bytes());
1832        body.extend_from_slice(&1u16.to_le_bytes());
1833        body.extend_from_slice(&1u16.to_le_bytes());
1834        body.extend_from_slice(&44100u32.to_le_bytes());
1835        body.extend_from_slice(&88200u32.to_le_bytes());
1836        body.extend_from_slice(&2u16.to_le_bytes());
1837        body.extend_from_slice(&16u16.to_le_bytes());
1838        let audio_data: Vec<u8> = (0..1024u32).map(|i| (i % 251) as u8).collect();
1839        body.extend_from_slice(b"data");
1840        body.extend_from_slice(&u32::try_from(audio_data.len()).unwrap().to_le_bytes());
1841        body.extend_from_slice(&audio_data);
1842        let mut riff = b"RIFF".to_vec();
1843        riff.extend_from_slice(&u32::try_from(body.len()).unwrap().to_le_bytes());
1844        riff.extend_from_slice(b"WAVE");
1845        riff.extend_from_slice(&body);
1846        let audio_offset = (riff.len() - audio_data.len()) as u64;
1847        std::fs::write(&path, &riff).unwrap();
1848
1849        let db = musefs_db::Db::open_in_memory().unwrap();
1850        let meta = std::fs::metadata(&path).unwrap();
1851        use std::os::unix::fs::MetadataExt;
1852        let track_id = db
1853            .upsert_track(&musefs_db::NewTrack {
1854                backing_path: path.to_string_lossy().into_owned(),
1855                format: musefs_db::Format::Wav,
1856                audio_offset,
1857                audio_length: audio_data.len() as u64,
1858                backing_size: meta.len(),
1859                backing_mtime_ns: meta.mtime() * 1_000_000_000 + meta.mtime_nsec(),
1860                backing_ctime_ns: meta.ctime() * 1_000_000_000 + meta.ctime_nsec(),
1861            })
1862            .unwrap();
1863        let cache = HeaderCache::new(Mode::Synthesis);
1864        let resolved = cache.resolve(&db, track_id).unwrap();
1865        let file = std::fs::File::open(&resolved.backing_path).unwrap();
1866        (db, resolved, file)
1867    }
1868
1869    fn oracle_read(
1870        resolved: &ResolvedFile,
1871        file: &std::fs::File,
1872        offset: u64,
1873        size: u64,
1874        out: &mut Vec<u8>,
1875    ) -> Result<()> {
1876        if offset >= resolved.total_len || size == 0 {
1877            return Ok(());
1878        }
1879        let end = offset.saturating_add(size).min(resolved.total_len);
1880        out.reserve(usize_from(end - offset));
1881        let mut seg_start = 0u64;
1882        for seg in resolved.layout.segments() {
1883            let seg_len = seg.len();
1884            let seg_end = seg_start + seg_len;
1885            let ov_start = offset.max(seg_start);
1886            let ov_end = end.min(seg_end);
1887            if ov_start < ov_end {
1888                let within = ov_start - seg_start;
1889                let n = usize_from(ov_end - ov_start);
1890                match seg {
1891                    Segment::Inline(bytes) => {
1892                        let w = usize_from(within);
1893                        out.extend_from_slice(&bytes[w..w + n]);
1894                    }
1895                    Segment::BackingAudio { offset: bo, .. } => {
1896                        let start = out.len();
1897                        out.resize(start + n, 0);
1898                        use std::os::unix::fs::FileExt;
1899                        file.read_exact_at(&mut out[start..], bo + within)?;
1900                    }
1901                    _ => panic!("unexpected segment in PCM fixture"),
1902                }
1903            }
1904            seg_start = seg_end;
1905            if seg_start >= end {
1906                break;
1907            }
1908        }
1909        Ok(())
1910    }
1911
1912    #[test]
1913    fn pcm_bytes_identical_through_backing_reader() {
1914        let (db, resolved, file) = pcm_fixture();
1915        let pool = ReadAheadPool::new(0);
1916        let buf = Arc::new(Mutex::new(ReadAhead::new(0)));
1917        let epoch = std::sync::atomic::AtomicU64::new(0);
1918        let br = BackingReader::new(&file, &buf, &pool, 0, resolved.stamp.size, &epoch);
1919        let total = resolved.total_len;
1920        for &size in &[1u64, 7, 4096, 65536, 262_144] {
1921            let mut off = 0;
1922            while off < total {
1923                let n = size.min(total - off);
1924                let mut via = Vec::new();
1925                read_segments_into(&resolved, Some(&db), Some(&br), off, n, &mut via).unwrap();
1926                let mut direct = Vec::new();
1927                oracle_read(&resolved, &file, off, n, &mut direct).unwrap();
1928                assert_eq!(via, direct, "mismatch at off={off} size={size}");
1929                off += n;
1930            }
1931        }
1932    }
1933
1934    #[test]
1935    fn pcm_bytes_identical_under_forced_eviction() {
1936        let (db, resolved, file) = pcm_fixture();
1937        let pool = ReadAheadPool::new(1024 * 1024);
1938        let buf = Arc::new(Mutex::new(ReadAhead::new(pool.per_stream_cap())));
1939        pool.register(1, Arc::clone(&buf));
1940        let epoch = std::sync::atomic::AtomicU64::new(0);
1941        let br = BackingReader::new(&file, &buf, &pool, 1, resolved.stamp.size, &epoch);
1942        let total = resolved.total_len;
1943        let mut off = 0;
1944        while off < total {
1945            let n = 65536u64.min(total - off);
1946            let mut via = Vec::new();
1947            read_segments_into(&resolved, Some(&db), Some(&br), off, n, &mut via).unwrap();
1948            let mut direct = Vec::new();
1949            oracle_read(&resolved, &file, off, n, &mut direct).unwrap();
1950            assert_eq!(via, direct, "eviction mismatch at {off}");
1951            off += n;
1952        }
1953    }
1954
1955    #[test]
1956    fn partial_overlap_seek_serves_correct_bytes() {
1957        let (db, resolved, file) = pcm_fixture();
1958        let pool = ReadAheadPool::new(64 * 1024 * 1024);
1959        let buf = Arc::new(Mutex::new(ReadAhead::new(pool.per_stream_cap())));
1960        pool.register(1, Arc::clone(&buf));
1961        let epoch = std::sync::atomic::AtomicU64::new(0);
1962        let br = BackingReader::new(&file, &buf, &pool, 1, resolved.stamp.size, &epoch);
1963        let seq = [(0u64, 600u64), (590, 50), (10, 4096), (12, 4096)];
1964        for &(off, n) in &seq {
1965            let n = n.min(resolved.total_len.saturating_sub(off));
1966            if n == 0 {
1967                continue;
1968            }
1969            let mut via = Vec::new();
1970            read_segments_into(&resolved, Some(&db), Some(&br), off, n, &mut via).unwrap();
1971            let mut direct = Vec::new();
1972            oracle_read(&resolved, &file, off, n, &mut direct).unwrap();
1973            assert_eq!(via, direct, "partial-seek mismatch at {off}+{n}");
1974        }
1975    }
1976}