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#[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 pub last_page: Mutex<Option<(u64, u64, Vec<u8>)>>,
34 pub cache_bytes: u64,
37 pub has_binary_tag: bool,
41}
42
43#[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
56pub struct HeaderCache {
60 cache: Cache<i64, Arc<ResolvedFile>, CacheBytesWeighter>,
61 mode: Mode,
62}
63
64pub const DEFAULT_CACHE_BUDGET: u64 = 64 * 1024 * 1024;
66
67const 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 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 pub fn retain(&self, live: &HashSet<i64>) {
104 self.cache.retain(|id, _| live.contains(id));
105 }
106 pub fn remove(&self, id: i64) {
108 self.cache.remove(&id);
109 }
110 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 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 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 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 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 let layout = match track.format {
180 Format::Flac => {
181 let rows = db.get_structural_blocks(track.id)?;
182 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 let mut f = std::fs::File::open(&track.backing_path)?;
241 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 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 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 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
350pub 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
376fn 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
390pub 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
397fn 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 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 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 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
503pub 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
515pub 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 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()), Segment::OggAudio {
555 offset: audio_offset,
556 len: audio.len() as u64,
557 seq_delta: 1, },
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 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 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 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 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 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"), TagInput::new("title", "S"),
682 ];
683 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 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 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 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 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 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 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 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); 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 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 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 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 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 let dir = tempfile::tempdir().unwrap();
1213 let path = dir.path().join("a.flac");
1214 let (audio_offset, audio_length) = write_flac_local(&path);
1215 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 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)); }
1298 assert!(
1301 cache.cache.weight() <= 4096,
1302 "total weight {} exceeds the 4096-byte budget",
1303 cache.cache.weight()
1304 );
1305 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 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 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 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 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 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 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 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 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}