diff --git a/crates/engine/src/score.rs b/crates/engine/src/score.rs index ea264377..5f333831 100644 --- a/crates/engine/src/score.rs +++ b/crates/engine/src/score.rs @@ -19,13 +19,16 @@ const MAX_OUTPUT_BYTES: usize = 256 * 1024 * 1024; // 256 MiB const MAX_ITEMS_PER_MEASURE: usize = 128; /// Upper bound on pitches per chord, so a hostile session cannot make a huge document. const MAX_PITCHES_PER_CHORD: usize = 16; +/// Upper bound on notes collected per part, before `fill` builds onsets and spans, so a hostile +/// session cannot make `layout` accumulate an unbounded note vector. +const MAX_NOTES_PER_PART: usize = 100_000; /// Note values a single notehead can show, in sixteenths (longest first). const VALUES: [u32; 8] = [16, 12, 8, 6, 4, 3, 2, 1]; /// The output has grown past the byte budget: refuse to grow it further. -fn over_budget(buf: &str) -> Result<(), String> { - if buf.len() > MAX_OUTPUT_BYTES { - return Err(format!("score too large: over {} MiB", MAX_OUTPUT_BYTES / (1024 * 1024))); +fn over_budget(buf: &str, budget: usize) -> Result<(), String> { + if buf.len() > budget { + return Err(format!("score too large: over {} bytes", budget)); } Ok(()) } @@ -107,6 +110,9 @@ pub fn layout(s: &Session, tracks: &[TrackId]) -> Vec { let base = s.tempo.samples_to_ticks(c.start, sr); let end = s.tempo.samples_to_ticks(c.start.saturating_add(c.length), sr); for n in &sequence.notes { + if notes.len() >= MAX_NOTES_PER_PART { + break; + } let st = base.saturating_add(n.start); if st < base || st >= end || n.length <= 0 { continue; @@ -165,17 +171,21 @@ fn split_units(mut units: i64) -> Vec { } fn fill(bars: &[(i64, i64, u32, u32)], notes: &[(i64, i64, u8)]) -> Vec { - // Onsets → chords, each held until its longest note ends or the next onset. + // Onsets → chords, each held until its longest *retained* note ends or the next onset. A pitch + // the cap rejects must not extend the onset end. let mut onsets: std::collections::BTreeMap)> = std::collections::BTreeMap::new(); for (a, b, p) in notes { - let e = onsets.entry(*a).or_insert((*b, Vec::new())); - e.0 = e.0.max(*b); - if !e.1.contains(p) { - e.1.push(*p); + let (ends, pitches) = onsets.entry(*a).or_insert((*b, Vec::new())); + let new_pitch = !pitches.contains(p); + if new_pitch && pitches.len() >= MAX_PITCHES_PER_CHORD { + // Chord full and this pitch is absent: it is dropped and must not extend the onset end. + continue; } - // Cap chord size to avoid pathological memory use. - if e.1.len() > MAX_PITCHES_PER_CHORD { - e.1.truncate(MAX_PITCHES_PER_CHORD); + // A retained pitch (already in the chord, or one the cap accepts) may extend the onset + // end, even when it reappears with a longer duration. + *ends = (*ends).max(*b); + if new_pitch { + pitches.push(*p); } } let starts: Vec = onsets.keys().copied().collect(); @@ -258,10 +268,19 @@ fn note_type(units: u32) -> (&'static str, bool) { /// MusicXML 4.0 (partwise) for `parts`. pub fn musicxml(parts: &[Part], setup: &ScoreSetup) -> Result { + musicxml_budgeted(parts, setup, MAX_OUTPUT_BYTES) +} + +/// [`musicxml`] with an explicit byte budget (kept separate so tests can pass a tiny budget +/// instead of building a 256 MiB score). +fn musicxml_budgeted(parts: &[Part], setup: &ScoreSetup, budget: usize) -> Result { let mut x = String::new(); x.push_str("\n"); x.push_str("\n"); x.push_str("\n"); + // The header (and an oversized title/composer, escaped) can itself exceed the budget even with + // no measures, so check before the per-measure loop too. + over_budget(&x, budget)?; let _ = writeln!(x, " {}", xml_escape(&setup.title)); x.push_str(" \n"); if !setup.composer.is_empty() { @@ -272,6 +291,7 @@ pub fn musicxml(parts: &[Part], setup: &ScoreSetup) -> Result { let _ = writeln!(x, " {}", i + 1, xml_escape(&p.name)); } x.push_str(" \n"); + over_budget(&x, budget)?; for (i, p) in parts.iter().enumerate() { let _ = writeln!(x, " ", i + 1); let mut meter = (0, 0); @@ -338,11 +358,12 @@ pub fn musicxml(parts: &[Part], setup: &ScoreSetup) -> Result { } } x.push_str(" \n"); - over_budget(&x)?; + over_budget(&x, budget)?; } x.push_str(" \n"); } x.push_str("\n"); + over_budget(&x, budget)?; Ok(x) } @@ -356,6 +377,12 @@ fn staff_step(pitch: u8, bass: bool) -> i32 { /// An engraved score as a printable SVG page (A4 portrait width, as tall as needed). pub fn svg(parts: &[Part], setup: &ScoreSetup) -> Result { + svg_budgeted(parts, setup, MAX_OUTPUT_BYTES) +} + +/// [`svg`] with an explicit byte budget (kept separate so tests can pass a tiny budget instead of +/// building a 256 MiB score). +fn svg_budgeted(parts: &[Part], setup: &ScoreSetup, budget: usize) -> Result { const W: f64 = 794.0; const MARGIN: f64 = 48.0; const SP: f64 = 7.0; // staff space @@ -377,7 +404,9 @@ pub fn svg(parts: &[Part], setup: &ScoreSetup) -> Result { let _ = writeln!(o, "{}", W / 2.0, xml_escape(&setup.title)); if !setup.composer.is_empty() { let _ = writeln!(o, "{}", W - MARGIN, xml_escape(&setup.composer)); + over_budget(&o, budget)?; } + over_budget(&o, budget)?; let bar_w = (staff_x1 - staff_x0) / per as f64; for sys in 0..systems { let y_sys = top + sys as f64 * sys_h; @@ -489,8 +518,13 @@ pub fn svg(parts: &[Part], setup: &ScoreSetup) -> Result { let _ = writeln!(o, "", x + 3.0); } } - over_budget(&o)?; + // Check each measure, so a run of rest-only or missing measures that only draws + // bar lines cannot grow the output past the budget untouched. + over_budget(&o, budget)?; } + // The staff markup (lines, clef, track names) itself grows with the part count even + // without any items, so check once per staff too. + over_budget(&o, budget)?; } // System bracket. let y_last = y_sys + (parts.len().max(1) - 1) as f64 * STAFF_GAP + 4.0 * SP; @@ -500,8 +534,10 @@ pub fn svg(parts: &[Part], setup: &ScoreSetup) -> Result { staff_x0 - 30.0, staff_x0 - 30.0 ); + over_budget(&o, budget)?; } o.push_str("\n"); + over_budget(&o, budget)?; Ok(o) } @@ -606,22 +642,37 @@ mod tests { #[test] fn oversize_score_is_refused_not_aborted() { - // Many dense parts drive the output past the byte budget: the call must - // return an error, not grow a multi-GB string or abort. - let bars: Vec<_> = (0..MAX_MEASURES as i64).map(|i| (i * 16, 16, 4, 4)).collect(); + // Many dense parts drive the output past the byte budget: the call must return an error, + // not grow a multi-GB string or abort. Use the budgeted entry points with a tiny budget so + // the test exercises the over-budget path without allocating 256 MiB of score, and covers + // both the per-measure checks and the header/staff-only growth. + let bars: Vec<_> = (0..100).map(|i| (i * 16, 16, 4, 4)).collect(); let mut notes = Vec::new(); - for i in 0..(MAX_MEASURES * 8) as i64 { + for i in 0..2000 { let onset = i * 2; notes.push((onset, onset + 1, 60 + (i % 24) as u8)); } let measures = fill(&bars, ¬es); let part = Part { name: "dense".into(), bass: false, measures }; let setup = ScoreSetup { title: "t".into(), composer: String::new(), bars_per_system: 1, show_track_names: true }; - // Enough copies of the dense part to exceed the byte budget. - let parts: Vec = (0..256).map(|i| Part { name: format!("part{i}"), ..part.clone() }).collect(); - let err = svg(&parts, &setup).unwrap_err(); + // Enough copies of the dense part to exceed a tiny budget. + let parts: Vec = (0..64).map(|i| Part { name: format!("part{i}"), ..part.clone() }).collect(); + let tiny = 1024; + let err = svg_budgeted(&parts, &setup, tiny).unwrap_err(); assert!(err.contains("score too large"), "{err}"); - let err = musicxml(&parts, &setup).unwrap_err(); + let err = musicxml_budgeted(&parts, &setup, tiny).unwrap_err(); + assert!(err.contains("score too large"), "{err}"); + } + + #[test] + fn oversized_title_is_refused_by_the_header_check() { + // An oversized part name/title must be caught by the header check even with no measures: + // the escaped copy alone exceeds the budget. (A 54 MiB title blows past 64 KiB of escaped + // text without touching the per-measure loop.) + let setup = ScoreSetup { title: "&".repeat(64 * 1024), composer: "c".repeat(64 * 1024), bars_per_system: 4, show_track_names: false }; + let err = musicxml_budgeted(&[], &setup, 1024).unwrap_err(); + assert!(err.contains("score too large"), "{err}"); + let err = svg_budgeted(&[], &setup, 1024).unwrap_err(); assert!(err.contains("score too large"), "{err}"); } }