Browse / github.com/storytold/filmcraft / patch
fix(interchange): bound OMF traversal and reject cyclic segments
fix(interchange): bound OMF traversal and reject cyclic segments A tiny OMF file with a cyclic master-source sequence currently aborts with a stack overflow; a shared sequence graph can also expand exponentially. This change returns an actionable OMF parse error for cycles or excessive traversal, while preserving the order and multiplicity of legitimate repeated segments and source clips. Addresses the OMF reference-traversal portion of #2795. The separate Bento value and AAF/CFB amplification concerns remain outside this PR, so this does not close the umbrella issue. - Detect cycles on the active segment path, including effect inputs and master/file-sour
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- thecyberbutler (imported from pull request #3339, open)
- Base
de529c88f4948d0803bef746a325707f2671a71d- Size
- +476 −60 in 4 files
- Review
- pending
- Shared
- 2026-10-28
- Id
7ab6876aeda6bf0924e603b3
How it went
No other operator has reported using it yet.
Diff
raw · crates/interchange/README.md crates/interchange/src/omf/mod.rs crates/interchange/src/omf/read.rs crates/interchange/src/omf/traversal_tests.rs
@@ -60,8 +60,19 @@ Premiere Pro does not write FCPXML. | AAF | a composition mob of its own (not tagged top-level); the nest's clips are source clips that point at it, at its first track of their kind. With embedded or consolidated audio the media inside the nest is prepared too (`NestNeeds::Inside`) | a sequence the clips use, read once however many clips use it | | OMF | the nested sequence's sound, mixed by the engine with what is on the clip (`NestNeeds::Render`), as one clip named after the sequence. Without that essence: a gap, and the report names the sequence | media in the document | -A sequence that claims to be inside itself (a damaged project or file) is a gap with a report-entry in both directions; nothing recurses without a bound.+A project sequence that claims to be inside itself is exported as a gap with a report entry.+The AAF importer likewise reports recursive nested compositions as gaps.++OMF import rejects cycles in sequence/effect inputs and in master/file-source segment lookups+with an OMF parse error. Segment nesting is limited to 32 edges (64 during reference discovery).+Source-mob chains exceeding 16 edges, including cycles, likewise return an error instead of audio gaps.+Discovery, composition slots, expansion and source-segment lookups share a 65,536-visit budget+for the whole document. Materialised lists in early-exit source lookups, effect slots and gain+points also consume that budget.+Repeated references consume that budget even when they produce no clips. References on the+current recursion path detect cycles; reusing a segment elsewhere still preserves every edit+in its original order. Exceeding a depth or work limit returns an error rather than a partial+import. These limits bound graph traversal; Bento/AAF value-size amplification is a separate concern. The AAF reader also accepts Premiere Pro's layout of a composition: all video tracks in ONE slot (a nested scope with a segment per track, lowest first), and "top-level" tagged on nested@@ -108,3 +119,7 @@ OMF carries no video at all. nested compositions tagged top-level) reads back as the same edit. - `src/omf/tests.rs`: the Bento label and TOC, the required properties of every OMF class written, reference resolution, embedded WAVE data, sequence lengths in samples.+- `src/omf/traversal_tests.rs`: synthetic Bento graphs exercise cyclic sequences/effects and+ source lookups, repeated-reference/automation-point expansion, shared budgets across tracks/compositions,+ the depth boundary, preserved repeated edits, and truncation/bit-flip mutations under+ `catch_unwind`. The master-source cycle regression aborts with stack overflow before the fix.@@ -68,3 +68,6 @@ pub fn sniff(bytes: &[u8]) -> bool { #[cfg(test)] mod tests;++#[cfg(test)]+mod traversal_tests;@@ -10,7 +10,13 @@ use crate::{Error, Report, Result}; type Uid = [u8; 12]; +// One budget for the whole import, including repeated references and source lookups. const MAX_REFERENCE_VISITS: usize = 65_536;+const MAX_SEGMENT_DEPTH: usize = 32;++fn traversal_error(message: &str) -> Error {+ Error::Parse { format: "OMF", message: message.into() }+} struct R<'a, 'b> { c: &'b Container<'a>,@@ -21,6 +27,7 @@ struct R<'a, 'b> { media: HashMap<Uid, Vec<u8>>, doc: Document, sources: HashMap<(Uid, u32, String), usize>,+ remaining_visits: usize, } pub(crate) fn read(bytes: &[u8], report: &mut Report) -> Result<Document> {@@ -29,7 +36,16 @@ pub(crate) fn read(bytes: &[u8], report: &mut Report) -> Result<Document> { let class_of = |o: u32| c.get(o, "OMFI:OOBJ:ObjClass").and_then(|v| v.get(..4).map(|b| [b[0], b[1], b[2], b[3]])); let head = c.objects.keys().copied().find(|&o| class_of(o) == Some(*b"HEAD")).ok_or_else(|| perr("no OMF header object".into()))?; let be = c.get(head, "OMFI:HEAD:ByteOrder").is_none_or(|v| v.first() != Some(&0x49));- let mut r = R { c: &c, report, be, mobs: HashMap::new(), media: HashMap::new(), doc: Document::default(), sources: HashMap::new() };+ let mut r = R {+ c: &c,+ report,+ be,+ mobs: HashMap::new(),+ media: HashMap::new(),+ doc: Document::default(),+ sources: HashMap::new(),+ remaining_visits: MAX_REFERENCE_VISITS,+ }; let mob_list = r.refs(head, "OMFI:HEAD:Mobs"); let mob_list = if mob_list.is_empty() { c.objects.keys().copied().filter(|&o| matches!(class_of(o).as_ref(), Some(b"CMOB" | b"MMOB" | b"SMOB"))).collect()@@ -54,9 +70,8 @@ pub(crate) fn read(bytes: &[u8], report: &mut Report) -> Result<Document> { let cmobs: Vec<u32> = mob_list.iter().copied().filter(|&m| class_of(m) == Some(*b"CMOB")).collect(); let mut referenced = HashSet::new(); let mut visited = HashSet::new();- let mut visits = 0; for &m in &cmobs {- r.collect_refs(m, &mut referenced, &mut visited, &mut visits, 0);+ r.collect_refs(m, &mut referenced, &mut visited, 0)?; } let primary: Vec<u32> = r.refs(head, "OMFI:HEAD:PrimaryMobs").into_iter().filter(|m| cmobs.contains(m)).collect(); let top: Vec<u32> = if !primary.is_empty() {@@ -65,7 +80,7 @@ pub(crate) fn read(bytes: &[u8], report: &mut Report) -> Result<Document> { cmobs.iter().copied().filter(|m| r.uid(*m, "OMFI:MOBJ:MobID").is_none_or(|id| !referenced.contains(&id))).collect() }; for m in top {- let comp = r.composition(m);+ let comp = r.composition(m)?; r.doc.compositions.push(comp); } if r.doc.compositions.is_empty() {@@ -75,6 +90,28 @@ pub(crate) fn read(bytes: &[u8], report: &mut Report) -> Result<Document> { } impl R<'_, '_> {+ fn visit(&mut self) -> Result<()> {+ self.spend_visits(1)+ }++ fn spend_visits(&mut self, count: usize) -> Result<()> {+ self.remaining_visits =+ self.remaining_visits.checked_sub(count).ok_or_else(|| traversal_error("reference traversal budget exceeded (65,536 visits)"))?;+ Ok(())+ }++ fn enter_segment(&mut self, seg: u32, path: &mut Vec<u32>) -> Result<()> {+ self.visit()?;+ if path.contains(&seg) {+ return Err(traversal_error("cycle in segment references"));+ }+ if path.len() > MAX_SEGMENT_DEPTH {+ return Err(traversal_error("segment nesting exceeds depth limit (32 edges)"));+ }+ path.push(seg);+ Ok(())+ }+ fn class(&self, o: u32) -> [u8; 4] { self.c.get(o, "OMFI:OOBJ:ObjClass").and_then(|v| v.get(..4).map(|b| [b[0], b[1], b[2], b[3]])).unwrap_or(*b"????") }@@ -126,11 +163,14 @@ impl R<'_, '_> { self.i32(o, "OMFI:CPNT:Length").unwrap_or(0).max(0) } - fn collect_refs(&self, o: u32, out: &mut HashSet<Uid>, visited: &mut HashSet<u32>, visits: &mut usize, depth: usize) {- if depth > 64 || !visited.insert(o) || *visits >= MAX_REFERENCE_VISITS {- return;+ fn collect_refs(&mut self, o: u32, out: &mut HashSet<Uid>, visited: &mut HashSet<u32>, depth: usize) -> Result<()> {+ self.visit()?;+ if !visited.insert(o) {+ return Ok(());+ }+ if depth > 64 {+ return Err(traversal_error("reference nesting exceeds depth limit (64 edges)")); }- *visits += 1; if self.class(o) == *b"SCLP" && let Some(id) = self.uid(o, "OMFI:SCLP:SourceID") {@@ -138,17 +178,19 @@ impl R<'_, '_> { } for p in ["OMFI:MOBJ:Slots", "OMFI:SEQU:Components", "OMFI:EFFE:EffectSlots"] { for c in self.refs(o, p) {- self.collect_refs(c, out, visited, visits, depth + 1);+ self.collect_refs(c, out, visited, depth + 1)?; } } for p in ["OMFI:MSLT:Segment", "OMFI:ESLT:ArgValue"] { if let Some(c) = self.r#ref(o, p) {- self.collect_refs(c, out, visited, visits, depth + 1);+ self.collect_refs(c, out, visited, depth + 1)?; } }+ Ok(()) } - fn composition(&mut self, m: u32) -> Composition {+ fn composition(&mut self, m: u32) -> Result<Composition> {+ self.visit()?; let name = self.string(m, "OMFI:MOBJ:Name").unwrap_or_else(|| "Sequence".into()); let mut comp = Composition { name,@@ -164,6 +206,7 @@ impl R<'_, '_> { let slots = self.refs(m, "OMFI:MOBJ:Slots"); let mut na = 0; for s in slots {+ self.visit()?; let Some(seg) = self.r#ref(s, "OMFI:MSLT:Segment") else { continue }; let rate = self.rational(s, "OMFI:MSLT:EditRate").filter(|r| r.0 > 0 && r.1 > 0).unwrap_or((25, 1)); let kind = self.data_kind(seg);@@ -190,7 +233,7 @@ impl R<'_, '_> { if rate.1 == 1 && rate.0 >= 8000 { comp.sample_rate = rate.0 as u32; }- let items = self.segment(seg, rate, 0);+ let items = self.segment(seg, rate, &mut Vec::new())?; na += 1; let td = self.r#ref(s, "OMFI:MSLT:TrackDesc"); let tname = td.and_then(|t| self.string(t, "OMFI:TRKD:TrackName")).unwrap_or_else(|| format!("A{na}"));@@ -206,24 +249,22 @@ impl R<'_, '_> { .min(6); comp.tracks.push(CTrack { kind: CKind::Sound, name: tname, number, channels, items }); }- comp+ Ok(comp) } - fn segment(&mut self, seg: u32, rate: (i64, i64), depth: usize) -> Vec<CItem> {+ fn segment(&mut self, seg: u32, rate: (i64, i64), path: &mut Vec<u32>) -> Result<Vec<CItem>> {+ self.enter_segment(seg, path)?; let len = units_to_ticks(self.len(seg), rate.0, rate.1);- if depth > 32 {- return vec![CItem::Filler(len)];- }- match &self.class(seg) {+ let items = match &self.class(seg) { b"SEQU" => { let mut out = Vec::new(); for c in self.refs(seg, "OMFI:SEQU:Components") {- out.extend(self.segment(c, rate, depth + 1));+ out.extend(self.segment(c, rate, path)?); } out } b"FILL" => vec![CItem::Filler(len)],- b"SCLP" => vec![self.clip(seg, rate)],+ b"SCLP" => vec![self.clip(seg, rate)?], b"TRAN" => { let cut = units_to_ticks(self.i32(seg, "OMFI:TRAN:CutPoint").unwrap_or(0), rate.0, rate.1); let effect =@@ -233,18 +274,20 @@ impl R<'_, '_> { b"EFFE" => { let kind = self.r#ref(seg, "OMFI:EFFE:EffectKind").and_then(|k| self.string(k, "OMFI:EDEF:EffectID")).unwrap_or_default(); let slots = self.refs(seg, "OMFI:EFFE:EffectSlots");+ self.spend_visits(slots.len())?; let arg = |r: &Self, id: i64| slots.iter().copied().find(|&s| r.i32(s, "OMFI:ESLT:ArgID") == Some(id)).and_then(|s| r.r#ref(s, "OMFI:ESLT:ArgValue")); let Some(input) = arg(self, -1).or_else(|| arg(self, 1).filter(|_| kind != "omfi:effect:MonoAudioGain")) else {- return vec![CItem::Filler(len)];+ path.pop();+ return Ok(vec![CItem::Filler(len)]); }; let gain = if kind == "omfi:effect:MonoAudioGain" {- arg(self, 1).and_then(|v| self.gain(v, len))+ arg(self, 1).map(|v| self.gain(v, len)).transpose()?.flatten() } else { self.report.warn("OMF effects other than audio gain are not imported (their input is used)"); None };- let mut items = self.segment(input, rate, depth + 1);+ let mut items = self.segment(input, rate, path)?; if let (Some(g), [CItem::Clip(c)]) = (gain, items.as_mut_slice()) { c.gain = Some(g); }@@ -254,20 +297,23 @@ impl R<'_, '_> { self.report.info("an unsupported OMF component was imported as a gap"); vec![CItem::Filler(len)] }- }+ };+ path.pop();+ Ok(items) } fn amp(&self, v: &[u8]) -> Option<f64> { self.rational_of(v).filter(|r| r.1 != 0).map(|(n, d)| n as f64 / d as f64) } - fn gain(&mut self, v: u32, len: Tick) -> Option<Gain> {- match &self.class(v) {+ fn gain(&mut self, v: u32, len: Tick) -> Result<Option<Gain>> {+ Ok(match &self.class(v) { b"CVAL" => self.c.get(v, "OMFI:CVAL:Value").and_then(|d| self.amp(&d)).map(Gain::Constant), b"VVAL" => { let linear = self.u16(v, "OMFI:VVAL:Interpolation") != Some(1);- let points: Vec<(Tick, f64)> = self- .refs(v, "OMFI:VVAL:PointList")+ let refs = self.refs(v, "OMFI:VVAL:PointList");+ self.spend_visits(refs.len())?;+ let points: Vec<(Tick, f64)> = refs .into_iter() .filter_map(|p| { let (n, d) = self.rational(p, "OMFI:CTLP:Time").filter(|r| r.1 != 0)?;@@ -278,72 +324,86 @@ impl R<'_, '_> { (!points.is_empty()).then_some(Gain::Varying { linear, points }) } _ => None,- }+ }) } - fn clip(&mut self, c: u32, rate: (i64, i64)) -> CItem {+ fn clip(&mut self, c: u32, rate: (i64, i64)) -> Result<CItem> { let len = units_to_ticks(self.len(c), rate.0, rate.1); let start = units_to_ticks(self.i32(c, "OMFI:SCLP:StartTime").unwrap_or(0), rate.0, rate.1); let name = self.string(c, "FilmCraft:SCLP:ClipName").unwrap_or_default();- let (Some(id), Some(track)) = (self.uid(c, "OMFI:SCLP:SourceID"), self.u32(c, "OMFI:SCLP:SourceTrackID")) else { return CItem::Filler(len) };+ let (Some(id), Some(track)) = (self.uid(c, "OMFI:SCLP:SourceID"), self.u32(c, "OMFI:SCLP:SourceTrackID")) else { return Ok(CItem::Filler(len)) }; if id == [0; 12] {- return CItem::Filler(len);+ return Ok(CItem::Filler(len)); }- match self.resolve(id, track, start, None, 0) {+ Ok(match self.resolve(id, track, start, None, 0)? { Some((source, t)) => CItem::Clip(CClip { len, source, start: t, gain: None, name }), None => CItem::Filler(len),- }+ }) } - fn slot_of(&self, mob: u32, track: u32) -> Option<u32> {- self.refs(mob, "OMFI:MOBJ:Slots")- .into_iter()- .find(|&s| self.r#ref(s, "OMFI:MSLT:TrackDesc").and_then(|t| self.u32(t, "OMFI:TRKD:TrackID")) == Some(track))+ fn slot_of(&mut self, mob: u32, track: u32) -> Result<Option<u32>> {+ let slots = self.refs(mob, "OMFI:MOBJ:Slots");+ self.spend_visits(slots.len())?;+ Ok(slots.into_iter().find(|&s| self.r#ref(s, "OMFI:MSLT:TrackDesc").and_then(|t| self.u32(t, "OMFI:TRKD:TrackID")) == Some(track))) } - fn first_clip(&self, seg: u32) -> Option<u32> {+ fn first_clip(&mut self, seg: u32, path: &mut Vec<u32>) -> Result<Option<u32>> {+ self.enter_segment(seg, path)?;+ let mut clip = None; match &self.class(seg) {- b"SCLP" => Some(seg),- b"SEQU" => self.refs(seg, "OMFI:SEQU:Components").into_iter().find_map(|c| self.first_clip(c)),- _ => None,+ b"SCLP" => clip = Some(seg),+ b"SEQU" => {+ let components = self.refs(seg, "OMFI:SEQU:Components");+ // The list is materialised even when the first child is a clip.+ self.spend_visits(components.len())?;+ for c in components {+ clip = self.first_clip(c, path)?;+ if clip.is_some() {+ break;+ }+ }+ }+ _ => {} }+ path.pop();+ Ok(clip) } - fn resolve(&mut self, id: Uid, track: u32, start: Tick, master: Option<String>, depth: usize) -> Option<(usize, Tick)> {+ fn resolve(&mut self, id: Uid, track: u32, start: Tick, master: Option<String>, depth: usize) -> Result<Option<(usize, Tick)>> { if depth > 16 {- return None;+ return Err(traversal_error("source chain exceeds depth limit (16 edges)")); } let Some(&mob) = self.mobs.get(&id) else { self.report.warn("a clip references a mob that is not in the file (imported as a gap)");- return None;+ return Ok(None); };- let slot = self.slot_of(mob, track)?;+ let Some(slot) = self.slot_of(mob, track)? else { return Ok(None) }; let rate = self.rational(slot, "OMFI:MSLT:EditRate").filter(|r| r.0 > 0 && r.1 > 0).unwrap_or((48_000, 1));- let seg = self.r#ref(slot, "OMFI:MSLT:Segment")?;+ let Some(seg) = self.r#ref(slot, "OMFI:MSLT:Segment") else { return Ok(None) }; match &self.class(mob) { b"MMOB" => { let key = master.unwrap_or_else(|| id.iter().map(|b| format!("{b:02x}")).collect());- let clip = self.first_clip(seg)?;+ let Some(clip) = self.first_clip(seg, &mut Vec::new())? else { return Ok(None) }; let inner = units_to_ticks(self.i32(clip, "OMFI:SCLP:StartTime").unwrap_or(0), rate.0, rate.1);- let next = self.uid(clip, "OMFI:SCLP:SourceID")?;- let next_track = self.u32(clip, "OMFI:SCLP:SourceTrackID")?;+ let Some(next) = self.uid(clip, "OMFI:SCLP:SourceID") else { return Ok(None) };+ let Some(next_track) = self.u32(clip, "OMFI:SCLP:SourceTrackID") else { return Ok(None) }; let name = self.string(mob, "OMFI:MOBJ:Name");- let r = self.resolve(next, next_track, start + inner, Some(key), depth + 1)?;+ let Some(r) = self.resolve(next, next_track, start + inner, Some(key), depth + 1)? else { return Ok(None) }; if let Some(n) = name { self.doc.sources[r.0].name = n; }- Some(r)+ Ok(Some(r)) } b"SMOB" => {- let desc = self.r#ref(mob, "OMFI:SMOB:MediaDescription")?;+ let Some(desc) = self.r#ref(mob, "OMFI:SMOB:MediaDescription") else { return Ok(None) }; let sr = self.rational(desc, "OMFI:MDFL:SampleRate"); if sr.is_none() && !self.media.contains_key(&id) {- return None; // a tape / physical source+ return Ok(None); // a tape / physical source } let key = master.unwrap_or_else(|| id.iter().map(|b| format!("{b:02x}")).collect()); let skey = (id, track, key.clone()); if let Some(&i) = self.sources.get(&skey) {- return Some((i, start));+ return Ok(Some((i, start))); } let file_rate = sr.filter(|r| r.0 > 0 && r.1 > 0).unwrap_or(rate); let length = units_to_ticks(self.i32(desc, "OMFI:MDFL:Length").unwrap_or(0), file_rate.0, file_rate.1);@@ -368,7 +428,7 @@ impl R<'_, '_> { let (file_channels, sample_rate, bits) = fmt.unwrap_or((1, file_rate.0 as u32, 16)); let channel = (file_channels > 1 && sound_slots > 1).then(|| track.saturating_sub(1).min(file_channels - 1)); // timecode of the physical source- let tc = self.first_clip(seg).and_then(|clip| {+ let tc = self.first_clip(seg, &mut Vec::new())?.and_then(|clip| { let tid = self.uid(clip, "OMFI:SCLP:SourceID")?; let tape = *self.mobs.get(&tid)?; let (start_tc, fps, trate) = self.refs(tape, "OMFI:MOBJ:Slots").into_iter().find_map(|s| {@@ -416,11 +476,11 @@ impl R<'_, '_> { let i = self.doc.sources.len(); self.doc.sources.push(s); self.sources.insert(skey, i);- Some((i, start))+ Ok(Some((i, start))) } _ => { self.report.warn("nested OMF compositions are not supported (imported as gaps)");- None+ Ok(None) } } }new file mode 100644@@ -0,0 +1,338 @@+//! Small synthetic Bento graphs; no external media or binary fixtures.+use super::{ImportOptions, bento::Writer, import, read};+use crate::{Error, Report, comp::CItem};+use filmcraft_time::Tick;++struct Graph {+ w: Writer,+ head: u32,+ mob: u32,+ sound: u32,+ mobs: Vec<u32>,+}++impl Graph {+ fn new() -> Self {+ let mut g = Self { w: Writer::new(), head: 0, mob: 0, sound: 0, mobs: Vec::new() };+ g.head = g.obj(b"HEAD");+ g.mob = g.obj(b"CMOB");+ g.sound = g.obj(b"DDEF");+ g.w.set(g.sound, "OMFI:DDEF:DataKindID", "omfi:UniqueName", b"omfi:data:Sound\0");+ g.mobs.push(g.mob);+ g+ }+ fn obj(&mut self, class: &[u8; 4]) -> u32 {+ let o = self.w.new_object();+ self.w.set(o, "OMFI:OOBJ:ObjClass", "omfi:ClassID", class);+ o+ }+ fn component(&mut self, class: &[u8; 4]) -> u32 {+ let o = self.obj(class);+ self.one(o, "OMFI:CPNT:DataKind", self.sound);+ self.w.set(o, "OMFI:CPNT:Length", "omfi:Length32", &1i32.to_be_bytes());+ o+ }+ fn one(&mut self, o: u32, p: &str, target: u32) {+ self.w.set(o, p, "omfi:ObjRef", &target.to_be_bytes());+ }+ fn refs(&mut self, o: u32, p: &str, targets: &[u32]) {+ let mut bytes = u16::try_from(targets.len()).unwrap().to_be_bytes().to_vec();+ for t in targets {+ bytes.extend_from_slice(&t.to_be_bytes());+ }+ self.w.set(o, p, "omfi:ObjRefArray", &bytes);+ }+ fn sequence(&mut self, targets: &[u32]) -> u32 {+ let o = self.component(b"SEQU");+ self.refs(o, "OMFI:SEQU:Components", targets);+ o+ }+ fn slot(&mut self, seg: u32) -> u32 {+ let s = self.obj(b"MSLT");+ self.one(s, "OMFI:MSLT:Segment", seg);+ let t = self.obj(b"TRKD");+ self.w.set(t, "OMFI:TRKD:TrackID", "omfi:UInt32", &1u32.to_be_bytes());+ self.one(s, "OMFI:MSLT:TrackDesc", t);+ s+ }+ fn source_clip(&mut self, class: &[u8; 4], seg: u32) -> u32 {+ let mob = self.obj(class);+ self.mobs.push(mob);+ let mut uid = [0; 12];+ uid[..4].copy_from_slice(&mob.to_be_bytes());+ self.w.set(mob, "OMFI:MOBJ:MobID", "omfi:UID", &uid);+ let slot = self.slot(seg);+ self.refs(mob, "OMFI:MOBJ:Slots", &[slot]);+ if class == b"SMOB" {+ let desc = self.obj(b"WAVD");+ self.one(mob, "OMFI:SMOB:MediaDescription", desc);+ let mut rate = 48_000i32.to_be_bytes().to_vec();+ rate.extend_from_slice(&1i32.to_be_bytes());+ self.w.set(desc, "OMFI:MDFL:SampleRate", "omfi:Rational", &rate);+ }+ let clip = self.component(b"SCLP");+ self.w.set(clip, "OMFI:SCLP:SourceID", "omfi:UID", &uid);+ self.w.set(clip, "OMFI:SCLP:SourceTrackID", "omfi:UInt32", &1u32.to_be_bytes());+ clip+ }+ fn finish(mut self, seg: u32) -> Vec<u8> {+ let s = self.slot(seg);+ self.refs(self.mob, "OMFI:MOBJ:Slots", &[s]);+ self.refs(self.head, "OMFI:HEAD:PrimaryMobs", &[self.mob]);+ let mobs = self.mobs.clone();+ self.refs(self.head, "OMFI:HEAD:Mobs", &mobs);+ self.w.finish()+ }+}++fn rejected(bytes: &[u8], reason: &str) {+ let result = std::panic::catch_unwind(|| import(bytes, &ImportOptions::default())).expect("OMF import panicked");+ let Err(Error::Parse { format: "OMF", message }) = result else { panic!("expected OMF parse error") };+ assert!(message.contains(reason), "{message}");+}++#[test]+fn sequence_cycle_is_rejected() {+ for fanout in [1, 2] {+ let mut g = Graph::new();+ let seq = g.component(b"SEQU");+ g.refs(seq, "OMFI:SEQU:Components", &vec![seq; fanout]);+ rejected(&g.finish(seq), "cycle");+ }+}++#[test]+fn repeated_dag_expansion_is_bounded() {+ for empty in [false, true] {+ let mut g = Graph::new();+ let mut seg = if empty { g.sequence(&[]) } else { g.component(b"FILL") };+ // Only 17 segment objects, but 131,071 visits even with no output items.+ for _ in 0..16 {+ seg = g.sequence(&[seg, seg]);+ }+ rejected(&g.finish(seg), "budget");+ }+}++#[test]+fn shared_segments_preserve_every_occurrence() {+ let mut g = Graph::new();+ let a = g.component(b"FILL");+ let b = g.obj(b"FILL");+ g.w.set(b, "OMFI:CPNT:Length", "omfi:Length32", &2i32.to_be_bytes());+ let seq = g.sequence(&[a, b]);+ let top = g.sequence(&[seq, a, seq]);+ let bytes = g.finish(top);+ let doc = read::read(&bytes, &mut Report::default()).unwrap();+ let items = &doc.compositions[0].tracks[0].items;+ let lengths: Vec<Tick> = items+ .iter()+ .map(|i| match i {+ CItem::Filler(t) => *t,+ _ => panic!("expected filler"),+ })+ .collect();+ assert_eq!(lengths, [1, 2, 1, 1, 2].map(|n| Tick(n * filmcraft_time::TICKS_PER_SECOND / 25)));+}++#[test]+fn effect_input_cycle_is_rejected() {+ let mut g = Graph::new();+ let effect = g.component(b"EFFE");+ let seq = g.sequence(&[effect]);+ let slot = g.obj(b"ESLT");+ g.w.set(slot, "OMFI:ESLT:ArgID", "omfi:ArgIDType", &(-1i32).to_be_bytes());+ g.one(slot, "OMFI:ESLT:ArgValue", seq);+ g.refs(effect, "OMFI:EFFE:EffectSlots", &[slot]);+ rejected(&g.finish(effect), "cycle");+}++#[test]+fn segment_depth_limit_is_explicit() {+ for levels in [32, 33] {+ let mut g = Graph::new();+ let mut seg = g.component(b"FILL");+ for _ in 0..levels {+ seg = g.sequence(&[seg]);+ }+ let bytes = g.finish(seg);+ if levels == 32 {+ assert!(import(&bytes, &ImportOptions::default()).is_ok());+ } else {+ rejected(&bytes, "depth limit");+ }+ }+}++#[test]+fn master_sequence_cycle_is_rejected() {+ let mut g = Graph::new();+ let seq = g.component(b"SEQU");+ g.refs(seq, "OMFI:SEQU:Components", &[seq]);+ let clip = g.source_clip(b"MMOB", seq);+ rejected(&g.finish(clip), "cycle");+}++#[test]+fn source_timecode_sequence_cycle_is_rejected() {+ let mut g = Graph::new();+ let seq = g.component(b"SEQU");+ g.refs(seq, "OMFI:SEQU:Components", &[seq]);+ let clip = g.source_clip(b"SMOB", seq);+ rejected(&g.finish(clip), "cycle");+}++#[test]+fn source_lookup_expansion_is_bounded() {+ let mut g = Graph::new();+ let mut seg = g.component(b"FILL");+ for _ in 0..16 {+ seg = g.sequence(&[seg, seg]);+ }+ let clip = g.source_clip(b"MMOB", seg);+ rejected(&g.finish(clip), "budget");+}++#[test]+fn source_chain_depth_limit_preserves_audio_or_returns_an_error() {+ for depth in [16, 17] {+ let mut g = Graph::new();+ let filler = g.component(b"FILL");+ let mut clip = g.source_clip(b"SMOB", filler);+ for _ in 0..depth {+ clip = g.source_clip(b"MMOB", clip);+ }+ let bytes = g.finish(clip);+ if depth == 16 {+ let doc = read::read(&bytes, &mut Report::default()).unwrap();+ assert_eq!(doc.sources.len(), 1);+ assert!(matches!(doc.compositions[0].tracks[0].items.as_slice(), [CItem::Clip(c)] if c.source == 0));+ } else {+ rejected(&bytes, "source chain exceeds depth limit");+ }+ }+}++#[test]+fn source_chain_cycles_return_an_error_instead_of_filler() {+ for length in [1, 2] {+ let mut g = Graph::new();+ let start = g.component(b"SCLP");+ let mut clip = start;+ for _ in 0..length {+ clip = g.source_clip(b"MMOB", clip);+ }+ // Close the chain by pointing its innermost clip back to the outermost master mob.+ let mut uid = [0; 12];+ uid[..4].copy_from_slice(&g.mobs.last().unwrap().to_be_bytes());+ g.w.set(start, "OMFI:SCLP:SourceID", "omfi:UID", &uid);+ g.w.set(start, "OMFI:SCLP:SourceTrackID", "omfi:UInt32", &1u32.to_be_bytes());+ rejected(&g.finish(clip), "source chain exceeds depth limit");+ }+}++#[test]+fn budget_is_shared_across_tracks_and_compositions() {+ for compositions in [false, true] {+ let mut g = Graph::new();+ let mut seg = g.component(b"FILL");+ // Each expansion fits individually, but the whole document does not.+ for _ in 0..14 {+ seg = g.sequence(&[seg, seg]);+ }+ let slot = g.slot(seg);+ if compositions {+ g.refs(g.mob, "OMFI:MOBJ:Slots", &[slot]);+ let second = g.obj(b"CMOB");+ g.mobs.push(second);+ g.mob = second;+ } else {+ g.refs(g.mob, "OMFI:MOBJ:Slots", &[slot, slot, slot]);+ }+ // Write the fixture directly so every property has only one value.+ let mobs = g.mobs.clone();+ g.refs(g.head, "OMFI:HEAD:Mobs", &mobs);+ if compositions {+ g.refs(g.mob, "OMFI:MOBJ:Slots", &[slot, slot]);+ }+ rejected(&g.w.finish(), "budget");+ }+}++#[test]+fn shared_source_clips_are_not_dropped() {+ let mut g = Graph::new();+ let filler = g.component(b"FILL");+ let clip = g.source_clip(b"SMOB", filler);+ let seq = g.sequence(&[clip, clip]);+ let top = g.sequence(&[seq, clip, seq]);+ let doc = read::read(&g.finish(top), &mut Report::default()).unwrap();+ assert_eq!(doc.sources.len(), 1);+ let items = &doc.compositions[0].tracks[0].items;+ assert_eq!(items.len(), 5);+ assert!(items.iter().all(|i| matches!(i, CItem::Clip(c) if c.source == 0)));+}++#[test]+fn mutated_reference_graphs_never_panic() {+ let mut g = Graph::new();+ let filler = g.component(b"FILL");+ let seq = g.sequence(&[filler, filler]);+ let bytes = g.finish(seq);+ for at in (0..bytes.len()).step_by(17) {+ let result = std::panic::catch_unwind(|| import(&bytes[..at], &ImportOptions::default()));+ assert!(result.is_ok(), "truncation at {at} panicked");+ for mask in [1, 0x80, 0xff] {+ let mut changed = bytes.clone();+ changed[at] ^= mask;+ let result = std::panic::catch_unwind(|| import(&changed, &ImportOptions::default()));+ assert!(result.is_ok(), "mutation at {at} panicked");+ }+ }+}++#[test]+fn repeated_discovery_references_consume_the_budget() {+ let mut g = Graph::new();+ let filler = g.component(b"FILL");+ let seq = g.sequence(&vec![filler; u16::MAX as usize]);+ rejected(&g.finish(seq), "budget");+}++#[test]+fn early_source_lookup_still_charges_the_reference_list() {+ let mut g = Graph::new();+ let filler = g.component(b"FILL");+ let source = g.source_clip(b"SMOB", filler);+ let master_seq = g.sequence(&vec![source; 1024]);+ let clip = g.source_clip(b"MMOB", master_seq);+ let top = g.sequence(&vec![clip; 128]);+ rejected(&g.finish(top), "budget");+}++#[test]+fn repeated_gain_points_consume_the_budget() {+ let mut g = Graph::new();+ let filler = g.component(b"FILL");+ let clip = g.source_clip(b"SMOB", filler);+ let point = g.obj(b"CTLP");+ let rational = [0, 0, 0, 1, 0, 0, 0, 1];+ g.w.set(point, "OMFI:CTLP:Time", "omfi:Rational", &rational);+ g.w.set(point, "OMFI:CTLP:Value", "omfi:DataValue", &rational);+ let value = g.component(b"VVAL");+ g.refs(value, "OMFI:VVAL:PointList", &vec![point; 1024]);+ let effect = g.component(b"EFFE");+ let kind = g.obj(b"EDEF");+ g.w.set(kind, "OMFI:EDEF:EffectID", "omfi:UniqueName", b"omfi:effect:MonoAudioGain\0");+ g.one(effect, "OMFI:EFFE:EffectKind", kind);+ let mut slots = Vec::new();+ for (id, arg) in [(-1i32, clip), (1, value)] {+ let slot = g.obj(b"ESLT");+ g.w.set(slot, "OMFI:ESLT:ArgID", "omfi:ArgIDType", &id.to_be_bytes());+ g.one(slot, "OMFI:ESLT:ArgValue", arg);+ slots.push(slot);+ }+ g.refs(effect, "OMFI:EFFE:EffectSlots", &slots);+ let top = g.sequence(&vec![effect; 128]);+ rejected(&g.finish(top), "budget");+}