Skip to main content

forma_ir/
parser.rs

1//! Parsers for FMIR data tables: string table, slot table, and island table.
2//!
3//! Each parser consumes a byte slice containing exactly one section's data
4//! and returns a typed, indexed collection.
5
6use crate::format::{
7    le_u16, le_u32, IrError, IrHeader, IslandEntry, IslandTrigger, Opcode, PropsMode, SectionTable,
8    SlotEntry, SlotSource, SlotType, HEADER_SIZE, SECTION_TABLE_SIZE,
9};
10
11// ---------------------------------------------------------------------------
12// StringTable
13// ---------------------------------------------------------------------------
14
15/// Parsed string table — an indexed collection of UTF-8 strings.
16///
17/// Binary format: `count(u32)`, then `count` entries of `[len(u16), bytes([u8; len])]`.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct StringTable {
20    strings: Vec<String>,
21}
22
23impl StringTable {
24    /// Parse a string table from its section data.
25    pub fn parse(data: &[u8]) -> Result<Self, IrError> {
26        if data.len() < 4 {
27            return Err(IrError::BufferTooShort {
28                expected: 4,
29                actual: data.len(),
30            });
31        }
32
33        let count = le_u32(&data[0..4]) as usize;
34        // Guard against a declared count that the section cannot possibly
35        // hold. The old `count > data.len()` guard compared entries against
36        // *bytes*: a string costs 2 bytes on disk (an empty string's u16
37        // length prefix) but 24 bytes of `String` header in the reservation,
38        // so it permitted a 12x memory blow-up before decoding a single
39        // string — and `Vec::with_capacity` OOM aborts the process rather
40        // than returning an error. `data.len() >= 4` is guaranteed above.
41        //
42        // NOTE: this rejects impossible counts; it does not cap expansion. A
43        // 100 MB section with count = 49,999,998 still passes and legitimately
44        // reserves ~1.1 GB. Bounded parser memory would need a maximum
45        // section-size policy at a higher layer, not this guard.
46        let max_entries = (data.len() - 4) / 2;
47        if count > max_entries {
48            return Err(IrError::BufferTooShort {
49                // Saturating: `count` is u32-wide and `usize` is 32 bits on
50                // wasm32, a first-class target for this crate.
51                expected: 4usize.saturating_add(count.saturating_mul(2)),
52                actual: data.len(),
53            });
54        }
55        let mut offset = 4;
56        // Exact reservation is the cheaper choice once `count` is validated:
57        // measured, amortised push growth peaks *higher* (realloc holds both
58        // buffers) and runs slower.
59        let mut strings = Vec::with_capacity(count);
60
61        for _ in 0..count {
62            // Need at least 2 bytes for the length prefix.
63            if offset + 2 > data.len() {
64                return Err(IrError::BufferTooShort {
65                    expected: offset + 2,
66                    actual: data.len(),
67                });
68            }
69
70            let str_len = le_u16(&data[offset..offset + 2]) as usize;
71            offset += 2;
72
73            if offset + str_len > data.len() {
74                return Err(IrError::BufferTooShort {
75                    expected: offset + str_len,
76                    actual: data.len(),
77                });
78            }
79
80            let s = std::str::from_utf8(&data[offset..offset + str_len])
81                .map_err(|e| IrError::InvalidUtf8(e.to_string()))?;
82            strings.push(s.to_owned());
83            offset += str_len;
84        }
85
86        Ok(StringTable { strings })
87    }
88
89    /// O(1) indexed lookup into the string table.
90    pub fn get(&self, idx: u32) -> Result<&str, IrError> {
91        self.strings
92            .get(idx as usize)
93            .map(|s| s.as_str())
94            .ok_or(IrError::StringIndexOutOfBounds {
95                index: idx,
96                len: self.strings.len(),
97            })
98    }
99
100    /// Number of strings in the table.
101    pub fn len(&self) -> usize {
102        self.strings.len()
103    }
104
105    /// Returns true if the string table is empty.
106    pub fn is_empty(&self) -> bool {
107        self.strings.is_empty()
108    }
109}
110
111// ---------------------------------------------------------------------------
112// SlotTable
113// ---------------------------------------------------------------------------
114
115/// Parsed slot table — an indexed collection of slot entries.
116///
117/// Binary format (v2): `count(u16)`, then `count` variable-length entries of
118/// `[slot_id(u16), name_str_idx(u32), type_hint(u8), source(u8), default_len(u16), default_bytes([u8; default_len])]`.
119/// Minimum entry size: 10 bytes (when default_len = 0).
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct SlotTable {
122    slots: Vec<SlotEntry>,
123}
124
125/// Minimum size of a single v2 slot entry in bytes (without default data).
126/// slot_id(2) + name_str_idx(4) + type_hint(1) + source(1) + default_len(2) = 10
127const SLOT_ENTRY_MIN_SIZE: usize = 10;
128
129impl SlotTable {
130    /// Parse a slot table from its section data.
131    pub fn parse(data: &[u8]) -> Result<Self, IrError> {
132        if data.len() < 2 {
133            return Err(IrError::BufferTooShort {
134                expected: 2,
135                actual: data.len(),
136            });
137        }
138
139        let count = le_u16(&data[0..2]) as usize;
140        // Same reasoning as StringTable::parse: a SlotEntry is 10 bytes on
141        // disk but ~32 bytes in memory, so an unchecked count reserves far
142        // more than the section could ever contain. `data.len() >= 2` above.
143        let max_entries = (data.len() - 2) / SLOT_ENTRY_MIN_SIZE;
144        if count > max_entries {
145            return Err(IrError::BufferTooShort {
146                expected: 2usize.saturating_add(count.saturating_mul(SLOT_ENTRY_MIN_SIZE)),
147                actual: data.len(),
148            });
149        }
150        let mut slots = Vec::with_capacity(count);
151        let mut offset = 2;
152
153        for _ in 0..count {
154            // Check minimum entry size
155            if offset + SLOT_ENTRY_MIN_SIZE > data.len() {
156                return Err(IrError::BufferTooShort {
157                    expected: offset + SLOT_ENTRY_MIN_SIZE,
158                    actual: data.len(),
159                });
160            }
161
162            let slot_id = le_u16(&data[offset..offset + 2]);
163            let name_str_idx = le_u32(&data[offset + 2..offset + 6]);
164            let type_hint = SlotType::from_byte(data[offset + 6])?;
165            let source = SlotSource::from_byte(data[offset + 7])?;
166            let default_len = le_u16(&data[offset + 8..offset + 10]) as usize;
167            offset += SLOT_ENTRY_MIN_SIZE;
168
169            // Read default bytes
170            if offset + default_len > data.len() {
171                return Err(IrError::BufferTooShort {
172                    expected: offset + default_len,
173                    actual: data.len(),
174                });
175            }
176            let default_bytes = data[offset..offset + default_len].to_vec();
177            offset += default_len;
178
179            slots.push(SlotEntry {
180                slot_id,
181                name_str_idx,
182                type_hint,
183                source,
184                default_bytes,
185            });
186        }
187
188        Ok(SlotTable { slots })
189    }
190
191    /// Number of slots in the table.
192    pub fn len(&self) -> usize {
193        self.slots.len()
194    }
195
196    /// Returns true if the slot table is empty.
197    pub fn is_empty(&self) -> bool {
198        self.slots.is_empty()
199    }
200
201    /// Access the underlying slot entries.
202    pub fn entries(&self) -> &[SlotEntry] {
203        &self.slots
204    }
205}
206
207// ---------------------------------------------------------------------------
208// IslandTableParsed
209// ---------------------------------------------------------------------------
210
211/// Parsed island table — an indexed collection of island entries.
212///
213/// Binary format: `count(u16)`, then `count` variable-length entries of
214/// `[id(u16), trigger(u8), props_mode(u8), name_str_idx(u32), byte_offset(u32), slot_count(u16), [slot_id(u16) x slot_count]]`.
215#[derive(Debug, Clone, PartialEq, Eq)]
216pub struct IslandTableParsed {
217    islands: Vec<IslandEntry>,
218}
219
220/// Minimum size of a single island entry in bytes (without slot_ids).
221/// id(2) + trigger(1) + props_mode(1) + name_str_idx(4) + byte_offset(4) + slot_count(2) = 14
222const ISLAND_ENTRY_MIN_SIZE: usize = 14;
223
224impl IslandTableParsed {
225    /// Parse an island table from its section data.
226    pub fn parse(data: &[u8]) -> Result<Self, IrError> {
227        if data.len() < 2 {
228            return Err(IrError::BufferTooShort {
229                expected: 2,
230                actual: data.len(),
231            });
232        }
233
234        let count = le_u16(&data[0..2]) as usize;
235        // See StringTable::parse — an IslandEntry is 14 bytes on disk but
236        // ~40 bytes in memory. `data.len() >= 2` guaranteed above.
237        let max_entries = (data.len() - 2) / ISLAND_ENTRY_MIN_SIZE;
238        if count > max_entries {
239            return Err(IrError::BufferTooShort {
240                expected: 2usize.saturating_add(count.saturating_mul(ISLAND_ENTRY_MIN_SIZE)),
241                actual: data.len(),
242            });
243        }
244        let mut islands = Vec::with_capacity(count);
245        let mut offset = 2;
246
247        for _ in 0..count {
248            // Minimum: id(2) + trigger(1) + props_mode(1) + name_str_idx(4) + byte_offset(4) + slot_count(2) = 14
249            if offset + ISLAND_ENTRY_MIN_SIZE > data.len() {
250                return Err(IrError::BufferTooShort {
251                    expected: offset + ISLAND_ENTRY_MIN_SIZE,
252                    actual: data.len(),
253                });
254            }
255
256            let id = le_u16(&data[offset..offset + 2]);
257            let trigger = IslandTrigger::from_byte(data[offset + 2])?;
258            let props_mode = PropsMode::from_byte(data[offset + 3])?;
259            let name_str_idx = le_u32(&data[offset + 4..offset + 8]);
260            let byte_offset = le_u32(&data[offset + 8..offset + 12]);
261            let slot_count = le_u16(&data[offset + 12..offset + 14]) as usize;
262            offset += ISLAND_ENTRY_MIN_SIZE;
263
264            // Read slot_ids
265            let needed = slot_count * 2;
266            if offset + needed > data.len() {
267                return Err(IrError::BufferTooShort {
268                    expected: offset + needed,
269                    actual: data.len(),
270                });
271            }
272            let mut slot_ids = Vec::with_capacity(slot_count);
273            for _ in 0..slot_count {
274                slot_ids.push(le_u16(&data[offset..offset + 2]));
275                offset += 2;
276            }
277
278            islands.push(IslandEntry {
279                id,
280                trigger,
281                props_mode,
282                name_str_idx,
283                byte_offset,
284                slot_ids,
285            });
286        }
287
288        Ok(IslandTableParsed { islands })
289    }
290
291    /// Number of islands in the table.
292    pub fn len(&self) -> usize {
293        self.islands.len()
294    }
295
296    /// Returns true if the island table is empty.
297    pub fn is_empty(&self) -> bool {
298        self.islands.is_empty()
299    }
300
301    /// Access the underlying island entries.
302    pub fn entries(&self) -> &[IslandEntry] {
303        &self.islands
304    }
305}
306
307// ---------------------------------------------------------------------------
308// IrModule — full-file parser
309// ---------------------------------------------------------------------------
310
311/// Sentinel meaning "no entry" in the dense id → table-index maps.
312const NO_INDEX: u32 = u32::MAX;
313
314/// A fully parsed and validated IR module, ready for walking.
315///
316/// Marked `#[non_exhaustive]`: the module carries derived lookup indices that
317/// must stay in sync with the tables, so it can only be built by `parse`.
318#[derive(Debug, Clone)]
319#[non_exhaustive]
320pub struct IrModule {
321    /// Parsed 16-byte file header.
322    pub header: IrHeader,
323    /// The module's string table — every name, literal and attribute value.
324    pub strings: StringTable,
325    /// The module's slot declarations, including their defaults.
326    pub slots: SlotTable,
327    /// Raw opcode stream; the walker interprets it on the fly.
328    pub opcodes: Vec<u8>,
329    /// The module's island declarations.
330    pub islands: IslandTableParsed,
331    /// Dense `slot_id` → index into `slots.entries()`, `NO_INDEX` if absent.
332    /// Replaces a linear scan per DYN_TEXT/DYN_ATTR/SHOW_IF/LIST opcode.
333    slot_index: Vec<u32>,
334    /// Dense island `id` → index into `islands.entries()`, `NO_INDEX` if
335    /// absent. This one is not behind any feature gate, so it used to cost a
336    /// full table scan in every build configuration.
337    island_index: Vec<u32>,
338}
339
340impl IrModule {
341    /// Parse a complete FMIR binary into a validated `IrModule`.
342    pub fn parse(data: &[u8]) -> Result<Self, IrError> {
343        // 1. Parse header (first 16 bytes).
344        let header = IrHeader::parse(data)?;
345
346        // 2. Parse section table (next 32 bytes, starting at HEADER_SIZE).
347        if data.len() < HEADER_SIZE + SECTION_TABLE_SIZE {
348            return Err(IrError::BufferTooShort {
349                expected: HEADER_SIZE + SECTION_TABLE_SIZE,
350                actual: data.len(),
351            });
352        }
353        let section_table = SectionTable::parse(&data[HEADER_SIZE..])?;
354
355        // 3. Validate all section bounds against the file length.
356        section_table.validate(data.len())?;
357
358        // Section indices: 0=Bytecode, 1=Strings, 2=Slots, 3=Islands
359        let sec_bytecode = &section_table.sections[0];
360        let sec_strings = &section_table.sections[1];
361        let sec_slots = &section_table.sections[2];
362        let sec_islands = &section_table.sections[3];
363
364        // 4. Parse string table.
365        let string_data = &data[sec_strings.offset as usize
366            ..(sec_strings.offset as usize + sec_strings.size as usize)];
367        let strings = StringTable::parse(string_data)?;
368
369        // 5. Parse slot table.
370        let slot_data =
371            &data[sec_slots.offset as usize..(sec_slots.offset as usize + sec_slots.size as usize)];
372        let slots = SlotTable::parse(slot_data)?;
373
374        // 6. Extract opcode stream (just clone into Vec<u8>).
375        let opcodes = data[sec_bytecode.offset as usize
376            ..(sec_bytecode.offset as usize + sec_bytecode.size as usize)]
377            .to_vec();
378
379        // 7. Parse island table.
380        let island_data = &data[sec_islands.offset as usize
381            ..(sec_islands.offset as usize + sec_islands.size as usize)];
382        let islands = IslandTableParsed::parse(island_data)?;
383
384        // 8. Build dense id → index maps. Insert-if-vacant preserves the
385        //    first-match semantics of the linear scans these replace, so a
386        //    module with duplicate ids (rejected below) can never render
387        //    differently through the index than it did through `find`.
388        let slot_index = build_id_index(slots.entries().iter().map(|e| e.slot_id));
389        let island_index = build_id_index(islands.entries().iter().map(|e| e.id));
390
391        let module = IrModule {
392            header,
393            strings,
394            slots,
395            opcodes,
396            islands,
397            slot_index,
398            island_index,
399        };
400
401        // 9. Run cross-table and structural validation.
402        module.validate()?;
403
404        Ok(module)
405    }
406
407    /// Look up a slot table entry by `slot_id` in O(1).
408    pub fn slot_entry(&self, slot_id: u16) -> Option<&SlotEntry> {
409        match self.slot_index.get(slot_id as usize) {
410            Some(&idx) if idx != NO_INDEX => self.slots.entries().get(idx as usize),
411            _ => None,
412        }
413    }
414
415    /// Look up an island table entry by island id in O(1).
416    pub fn island_entry(&self, island_id: u16) -> Option<&IslandEntry> {
417        match self.island_index.get(island_id as usize) {
418            Some(&idx) if idx != NO_INDEX => self.islands.entries().get(idx as usize),
419            _ => None,
420        }
421    }
422
423    /// Validate cross-table references and opcode-stream structure.
424    ///
425    /// Runs once at parse time (O(n) in the bytecode length) so the walker can
426    /// stay cheap and so malformed IR fails at load rather than rendering
427    /// mystery HTML on some later request.
428    pub fn validate(&self) -> Result<(), IrError> {
429        let str_count = self.strings.len();
430
431        // Validate all slot name_str_idx values are within string table bounds,
432        // and that slot ids are unique (a duplicate makes "which slot does this
433        // id mean" ambiguous, and the answer would differ between lookups).
434        let mut seen_slot = vec![false; self.slot_index.len()];
435        for slot in self.slots.entries() {
436            if slot.name_str_idx as usize >= str_count {
437                return Err(IrError::StringIndexOutOfBounds {
438                    index: slot.name_str_idx,
439                    len: str_count,
440                });
441            }
442            let idx = slot.slot_id as usize;
443            if seen_slot[idx] {
444                return Err(IrError::DuplicateSlotId(slot.slot_id));
445            }
446            seen_slot[idx] = true;
447        }
448
449        // Validate all island name_str_idx values are within string table
450        // bounds, ids are unique, every referenced slot exists, and the
451        // byte_offset really points at this island's own ISLAND_START.
452        let mut seen_island = vec![false; self.island_index.len()];
453        for island in self.islands.entries() {
454            if island.name_str_idx as usize >= str_count {
455                return Err(IrError::StringIndexOutOfBounds {
456                    index: island.name_str_idx,
457                    len: str_count,
458                });
459            }
460            let idx = island.id as usize;
461            if seen_island[idx] {
462                return Err(IrError::DuplicateIslandId(island.id));
463            }
464            seen_island[idx] = true;
465
466            for &slot_id in &island.slot_ids {
467                if self.slot_entry(slot_id).is_none() {
468                    return Err(IrError::UnknownSlotId(slot_id));
469                }
470            }
471
472            // A bounds check alone is not enough: an in-range offset landing
473            // mid-operand on a stray 0x0B byte would pass it. Require the
474            // opcode *and* its island id to match.
475            let off = island.byte_offset as usize;
476            let id_matches = self.opcodes.get(off) == Some(&(Opcode::IslandStart as u8))
477                && crate::walker::read_u16(&self.opcodes, off + 1) == Ok(island.id);
478            if !id_matches {
479                return Err(IrError::IslandOffsetMismatch {
480                    island_id: island.id,
481                    byte_offset: island.byte_offset,
482                });
483            }
484        }
485
486        // Structural pass over the bytecode.
487        self.scan_region(0, self.opcodes.len(), 0)?;
488
489        Ok(())
490    }
491
492    /// Linearly decode `opcodes[start..end]`, requiring that it tiles exactly
493    /// and that every slot id it names is declared.
494    ///
495    /// "Tiles exactly" means each control-flow region's declared length lines
496    /// up with the opcodes inside it: decoding a SHOW_IF then-body must land
497    /// precisely on `then_end`, the next byte must be SHOW_ELSE, and so on.
498    /// The walker's own `<= end` checks cannot catch an over-long length at
499    /// the top level (where `end == opcodes.len()` already), which is why this
500    /// pass exists.
501    ///
502    /// RESIDUAL: an *under*-long length whose orphaned bytes still decode as a
503    /// legal sibling sequence (`else_len = 0`, `body_len = 0`) is structurally
504    /// indistinguishable from a legitimate module, so no length check can
505    /// reject it. Closing that class needs module integrity — the FMIR header
506    /// already reserves `source_hash` at bytes 8..16 and the JS compiler
507    /// leaves it zero; populating and verifying it is a coordinated
508    /// compiler + runtime change, filed separately.
509    fn scan_region(&self, start: usize, end: usize, depth: usize) -> Result<(), IrError> {
510        if depth > MAX_VALIDATION_DEPTH {
511            return Err(IrError::RecursionLimitExceeded);
512        }
513        let ops = &self.opcodes;
514        let mut pos = start;
515        // Range-local, like the walker's: a TRY_START and its FALLBACK are
516        // emitted in the same region by construction.
517        let mut fallback_stack: Vec<usize> = Vec::new();
518
519        while pos < end {
520            let op_start = pos;
521            let opcode = Opcode::from_byte(ops[pos])?;
522            pos += 1;
523
524            match opcode {
525                Opcode::OpenTag | Opcode::VoidTag => {
526                    let attr_count = read_u16_at(ops, pos + 4, end)? as usize;
527                    pos = bounded(pos, 6 + attr_count * 8, end, op_start)?;
528                }
529                Opcode::CloseTag
530                | Opcode::Text
531                | Opcode::Comment
532                | Opcode::ListItemKey
533                | Opcode::DynText => {
534                    if opcode == Opcode::DynText {
535                        self.check_slot(read_u16_at(ops, pos, end)?)?;
536                    }
537                    pos = bounded(pos, 4, end, op_start)?;
538                }
539                Opcode::DynAttr => {
540                    self.check_slot(read_u16_at(ops, pos + 4, end)?)?;
541                    pos = bounded(pos, 6, end, op_start)?;
542                }
543                Opcode::IslandStart => {
544                    // An ISLAND_START whose id is absent from the island table
545                    // renders its `<!--f:iN-->` markers but no
546                    // `data-forma-*` attributes, so the client never hydrates
547                    // it and nothing anywhere reports why. Reject at load.
548                    let island_id = read_u16_at(ops, pos, end)?;
549                    if self.island_entry(island_id).is_none() {
550                        return Err(IrError::UnknownIslandId(island_id));
551                    }
552                    pos = bounded(pos, 2, end, op_start)?;
553                }
554                Opcode::IslandEnd => {
555                    pos = bounded(pos, 2, end, op_start)?;
556                }
557                Opcode::ShowElse => {
558                    // Stray SHOW_ELSE: the walker tolerates it as a no-op.
559                }
560                Opcode::Preload => {
561                    pos = bounded(pos, 5, end, op_start)?;
562                }
563                Opcode::Prop => {
564                    self.check_slot(read_u16_at(ops, pos, end)?)?;
565                    self.check_slot(read_u16_at(ops, pos + 6, end)?)?;
566                    pos = bounded(pos, 8, end, op_start)?;
567                }
568                Opcode::ShowIf => {
569                    self.check_slot(read_u16_at(ops, pos, end)?)?;
570                    let then_len = read_u32_at(ops, pos + 2, end)? as usize;
571                    let else_len = read_u32_at(ops, pos + 6, end)? as usize;
572                    let then_start = bounded(pos, 10, end, op_start)?;
573                    let then_end = bounded(then_start, then_len, end, op_start)?;
574                    // The SHOW_ELSE marker byte must itself be inside the
575                    // region — a SHOW_IF at the tail must not index a byte its
576                    // parent never granted.
577                    if then_end >= end || ops[then_end] != Opcode::ShowElse as u8 {
578                        return Err(IrError::MalformedControlFlow { pos: then_end });
579                    }
580                    let else_start = then_end + 1;
581                    let else_end = bounded(else_start, else_len, end, op_start)?;
582                    self.scan_region(then_start, then_end, depth + 1)?;
583                    self.scan_region(else_start, else_end, depth + 1)?;
584                    pos = else_end;
585                }
586                Opcode::Switch => {
587                    self.check_slot(read_u16_at(ops, pos, end)?)?;
588                    let case_count = read_u16_at(ops, pos + 2, end)? as usize;
589                    let mut header_pos = bounded(pos, 4, end, op_start)?;
590                    let mut lens =
591                        Vec::with_capacity(case_count.min(end.saturating_sub(header_pos) / 8));
592                    for _ in 0..case_count {
593                        let body_len = read_u32_at(ops, header_pos + 4, end)? as usize;
594                        lens.push(body_len);
595                        header_pos = bounded(header_pos, 8, end, op_start)?;
596                    }
597                    let mut body_pos = header_pos;
598                    for body_len in lens {
599                        let case_end = bounded(body_pos, body_len, end, op_start)?;
600                        self.scan_region(body_pos, case_end, depth + 1)?;
601                        body_pos = case_end;
602                    }
603                    pos = body_pos;
604                }
605                Opcode::List => {
606                    self.check_slot(read_u16_at(ops, pos, end)?)?;
607                    self.check_slot(read_u16_at(ops, pos + 2, end)?)?;
608                    let body_len = read_u32_at(ops, pos + 4, end)? as usize;
609                    let body_start = bounded(pos, 8, end, op_start)?;
610                    let body_end = bounded(body_start, body_len, end, op_start)?;
611                    self.scan_region(body_start, body_end, depth + 1)?;
612                    pos = body_end;
613                }
614                Opcode::TryStart => {
615                    let fallback_len = read_u32_at(ops, pos, end)? as usize;
616                    pos = bounded(pos, 4, end, op_start)?;
617                    fallback_stack.push(fallback_len);
618                }
619                Opcode::Fallback => {
620                    // The fallback body is skipped, exactly as the walker
621                    // skips it — Phase 2 always renders the main body, so
622                    // those bytes are never executed and are not scanned.
623                    //
624                    // A FALLBACK with no open TRY_START is rejected rather
625                    // than tolerated: the walker would leave `pos` where it
626                    // is and walk the fallback body as though it were main
627                    // content, which is a wrong page rendered silently.
628                    match fallback_stack.pop() {
629                        Some(skip) => pos = bounded(pos, skip, end, op_start)?,
630                        None => return Err(IrError::MalformedControlFlow { pos: op_start }),
631                    }
632                }
633            }
634        }
635
636        // A TRY_START whose FALLBACK never arrives leaves the region's main
637        // body unbounded: the walker renders it, but nothing marks where the
638        // try block ended, so a compiler that truncated the block ships a page
639        // missing its fallback with no diagnostic.
640        if let Some(_unmatched) = fallback_stack.pop() {
641            return Err(IrError::MalformedControlFlow { pos: end });
642        }
643
644        Ok(())
645    }
646
647    /// Reject an opcode operand naming a slot the module never declared.
648    ///
649    /// Without this a LIST whose `item_slot_id` is undeclared silently renders
650    /// every row identically, and a PROP writes into nowhere.
651    fn check_slot(&self, slot_id: u16) -> Result<(), IrError> {
652        if self.slot_entry(slot_id).is_none() {
653            return Err(IrError::UnknownSlotId(slot_id));
654        }
655        Ok(())
656    }
657
658    /// Look up a slot ID by its name. Returns `None` if no slot with that name exists.
659    ///
660    /// DUPLICATE-NAME RULE: slot *names* are not required to be unique — the
661    /// compiler derives some of them per-element (`text:<index>`, `attr:class`)
662    /// rather than per-module — so a name can address more than one slot. The
663    /// whole crate resolves that collision the same way: **the first entry in
664    /// slot-table order wins**, here, in [`crate::slot::SlotData::from_json`], and in the
665    /// walker's island-props builder. They used to disagree three ways (first,
666    /// last, and last-by-ascending-slot-id), so setting a value by name filled
667    /// one slot and left its twin blank while the island's props reported a
668    /// third answer.
669    ///
670    /// Verified by: crates/forma-ir/src/parser.rs > "duplicate_slot_names_resolve_first_wins_everywhere"
671    pub fn slot_id_by_name(&self, name: &str) -> Option<u16> {
672        for slot in self.slots.entries() {
673            if let Ok(slot_name) = self.strings.get(slot.name_str_idx) {
674                if slot_name == name {
675                    return Some(slot.slot_id);
676                }
677            }
678        }
679        None
680    }
681}
682
683/// Maximum control-flow nesting depth accepted by the structural validator.
684/// Matches the walker's `MAX_RECURSION_DEPTH`.
685const MAX_VALIDATION_DEPTH: usize = 64;
686
687/// Build a dense id → index map, keeping the FIRST entry for any duplicate id
688/// so the map agrees with the linear `find` it replaces.
689fn build_id_index(ids: impl Iterator<Item = u16> + Clone) -> Vec<u32> {
690    let max_id = ids.clone().max();
691    let Some(max_id) = max_id else {
692        return Vec::new();
693    };
694    // Sized to max_id + 1 (not a flat 65,536) with a u32 sentinel rather than
695    // Option<u32>: real pages have tens of slots, so this is a few hundred
696    // bytes, paid once at parse.
697    let mut index = vec![NO_INDEX; max_id as usize + 1];
698    for (i, id) in ids.enumerate() {
699        let entry = &mut index[id as usize];
700        if *entry == NO_INDEX {
701            *entry = i as u32;
702        }
703    }
704    index
705}
706
707/// `base + len`, rejected if it overflows or leaves the enclosing region.
708///
709/// Plain `+` on `usize` cannot wrap on 64-bit but can on wasm32, which this
710/// crate ships a build for.
711fn bounded(base: usize, len: usize, end: usize, op_start: usize) -> Result<usize, IrError> {
712    base.checked_add(len)
713        .filter(|e| *e <= end)
714        .ok_or(IrError::MalformedControlFlow { pos: op_start })
715}
716
717/// Read a u16 that must lie fully inside the region being scanned.
718fn read_u16_at(ops: &[u8], pos: usize, end: usize) -> Result<u16, IrError> {
719    if pos.checked_add(2).map(|e| e > end).unwrap_or(true) {
720        return Err(IrError::MalformedControlFlow { pos });
721    }
722    crate::walker::read_u16(ops, pos)
723}
724
725/// Read a u32 that must lie fully inside the region being scanned.
726fn read_u32_at(ops: &[u8], pos: usize, end: usize) -> Result<u32, IrError> {
727    if pos.checked_add(4).map(|e| e > end).unwrap_or(true) {
728        return Err(IrError::MalformedControlFlow { pos });
729    }
730    crate::walker::read_u32(ops, pos)
731}
732
733// ---------------------------------------------------------------------------
734// Test helpers — always compiled so external crates can use them in tests.
735// ---------------------------------------------------------------------------
736
737/// Binary encoding helpers for building FMIR test data.
738///
739/// Behind the `test-helpers` feature, not `#[cfg(test)]`: other crates
740/// (`forma-server`, and anyone writing a fixture) need them from their own
741/// test targets, which `#[cfg(test)]` cannot reach. The feature keeps them out
742/// of consumers' *release* builds, where a byte-exact FMIR encoder is
743/// unwanted API surface.
744///
745/// This crate's own tests get it from a self dev-dependency in Cargo.toml.
746#[cfg(any(test, feature = "test-helpers"))]
747// Fixture encoders: a fixture that cannot be encoded is a broken test, and
748// panicking says so at the line that built it. Never in a consumer's build.
749#[allow(clippy::panic, clippy::unwrap_used, clippy::expect_used)]
750pub mod test_helpers {
751    use crate::format::{HEADER_SIZE, SECTION_TABLE_SIZE};
752
753    /// Narrow to `u16`, panicking rather than silently truncating.
754    ///
755    /// These encoders write the on-disk widths, so a plain `as` cast turns a
756    /// module with more than 65,535 strings/attrs/slots into a corrupt file
757    /// with no error at all — the exact failure the parser exists to catch,
758    /// manufactured by the test fixture that was supposed to prove it.
759    fn to_u16(n: usize) -> [u8; 2] {
760        u16::try_from(n)
761            .unwrap_or_else(|_| panic!("{n} does not fit the u16 field this encoder writes"))
762            .to_le_bytes()
763    }
764
765    /// Narrow to `u32`, panicking rather than silently truncating.
766    fn to_u32(n: usize) -> [u8; 4] {
767        u32::try_from(n)
768            .unwrap_or_else(|_| panic!("{n} does not fit the u32 field this encoder writes"))
769            .to_le_bytes()
770    }
771
772    /// Build valid string table binary data from a slice of strings.
773    pub fn build_string_table(strings: &[&str]) -> Vec<u8> {
774        let mut buf = Vec::new();
775        buf.extend_from_slice(&to_u32(strings.len()));
776        for s in strings {
777            let bytes = s.as_bytes();
778            buf.extend_from_slice(&to_u16(bytes.len()));
779            buf.extend_from_slice(bytes);
780        }
781        buf
782    }
783
784    /// Build valid v2 slot table binary data from a slice of
785    /// (slot_id, name_str_idx, type_hint_byte, source_byte, default_bytes).
786    pub fn build_slot_table(entries: &[(u16, u32, u8, u8, &[u8])]) -> Vec<u8> {
787        let mut buf = Vec::new();
788        buf.extend_from_slice(&to_u16(entries.len()));
789        for &(slot_id, name_str_idx, type_hint, source, default_bytes) in entries {
790            buf.extend_from_slice(&slot_id.to_le_bytes());
791            buf.extend_from_slice(&name_str_idx.to_le_bytes());
792            buf.push(type_hint);
793            buf.push(source);
794            buf.extend_from_slice(&to_u16(default_bytes.len()));
795            buf.extend_from_slice(default_bytes);
796        }
797        buf
798    }
799
800    /// Build valid island table binary data with slot_ids support.
801    ///
802    /// Tuple: `(id, trigger, props_mode, name_str_idx, byte_offset, slot_ids)`
803    pub fn build_island_table(entries: &[(u16, u8, u8, u32, u32, &[u16])]) -> Vec<u8> {
804        let mut buf = Vec::new();
805        buf.extend_from_slice(&to_u16(entries.len()));
806        for &(id, trigger, props_mode, name_str_idx, byte_offset, slot_ids) in entries {
807            buf.extend_from_slice(&id.to_le_bytes());
808            buf.push(trigger);
809            buf.push(props_mode);
810            buf.extend_from_slice(&name_str_idx.to_le_bytes());
811            buf.extend_from_slice(&byte_offset.to_le_bytes());
812            buf.extend_from_slice(&to_u16(slot_ids.len()));
813            for &slot_id in slot_ids.iter() {
814                buf.extend_from_slice(&slot_id.to_le_bytes());
815            }
816        }
817        buf
818    }
819
820    // -- Opcode encoding helpers --------------------------------------------
821
822    /// Encode an OPEN_TAG opcode: opcode(1) + str_idx(4) + attr_count(2) + [(key(4), val(4))]
823    pub fn encode_open_tag(str_idx: u32, attrs: &[(u32, u32)]) -> Vec<u8> {
824        let mut buf = Vec::new();
825        buf.push(0x01); // Opcode::OpenTag
826        buf.extend_from_slice(&str_idx.to_le_bytes());
827        buf.extend_from_slice(&to_u16(attrs.len()));
828        for &(key_idx, val_idx) in attrs {
829            buf.extend_from_slice(&key_idx.to_le_bytes());
830            buf.extend_from_slice(&val_idx.to_le_bytes());
831        }
832        buf
833    }
834
835    /// Encode a CLOSE_TAG opcode: opcode(1) + str_idx(4)
836    pub fn encode_close_tag(str_idx: u32) -> Vec<u8> {
837        let mut buf = Vec::new();
838        buf.push(0x02); // Opcode::CloseTag
839        buf.extend_from_slice(&str_idx.to_le_bytes());
840        buf
841    }
842
843    /// Encode a VOID_TAG opcode: opcode(1) + str_idx(4) + attr_count(2) + [(key(4), val(4))]
844    pub fn encode_void_tag(str_idx: u32, attrs: &[(u32, u32)]) -> Vec<u8> {
845        let mut buf = Vec::new();
846        buf.push(0x03); // Opcode::VoidTag
847        buf.extend_from_slice(&str_idx.to_le_bytes());
848        buf.extend_from_slice(&to_u16(attrs.len()));
849        for &(key_idx, val_idx) in attrs {
850            buf.extend_from_slice(&key_idx.to_le_bytes());
851            buf.extend_from_slice(&val_idx.to_le_bytes());
852        }
853        buf
854    }
855
856    /// Encode a TEXT opcode: opcode(1) + str_idx(4)
857    pub fn encode_text(str_idx: u32) -> Vec<u8> {
858        let mut buf = Vec::new();
859        buf.push(0x04); // Opcode::Text
860        buf.extend_from_slice(&str_idx.to_le_bytes());
861        buf
862    }
863
864    /// Encode a SHOW_IF opcode with then/else branches.
865    ///
866    /// Binary layout:
867    /// ```text
868    /// [SHOW_IF(0x07)] [slot_id(u16)] [then_len(u32)] [else_len(u32)]
869    /// [then_ops bytes...] [SHOW_ELSE(0x08)] [else_ops bytes...]
870    /// ```
871    pub fn encode_show_if(slot_id: u16, then_ops: &[u8], else_ops: &[u8]) -> Vec<u8> {
872        let mut buf = Vec::new();
873        buf.push(0x07); // Opcode::ShowIf
874        buf.extend_from_slice(&slot_id.to_le_bytes());
875        buf.extend_from_slice(&to_u32(then_ops.len()));
876        buf.extend_from_slice(&to_u32(else_ops.len()));
877        buf.extend_from_slice(then_ops); // then branch body
878        buf.push(0x08); // SHOW_ELSE marker
879        buf.extend_from_slice(else_ops); // else branch body
880        buf
881    }
882
883    /// Encode a LIST opcode: opcode(1) + slot_id(2) + item_slot_id(2) + body_len(4) + body
884    pub fn encode_list(slot_id: u16, item_slot_id: u16, body_ops: &[u8]) -> Vec<u8> {
885        let mut buf = Vec::new();
886        buf.push(0x0A); // Opcode::List
887        buf.extend_from_slice(&slot_id.to_le_bytes());
888        buf.extend_from_slice(&item_slot_id.to_le_bytes());
889        buf.extend_from_slice(&to_u32(body_ops.len()));
890        buf.extend_from_slice(body_ops);
891        buf
892    }
893
894    /// Encode a SWITCH opcode with case headers and bodies.
895    ///
896    /// Binary layout:
897    /// ```text
898    /// [SWITCH(0x09)] [slot_id(u16)] [case_count(u16)]
899    /// [val_str_idx(u32) body_len(u32)] x case_count   -- case headers
900    /// [...body opcodes for case 0...]
901    /// [...body opcodes for case 1...]
902    /// ...
903    /// ```
904    pub fn encode_switch(slot_id: u16, cases: &[(u32, &[u8])]) -> Vec<u8> {
905        let mut buf = Vec::new();
906        buf.push(0x09); // Opcode::Switch
907        buf.extend_from_slice(&slot_id.to_le_bytes());
908        buf.extend_from_slice(&to_u16(cases.len()));
909        // Case headers
910        for (val_str_idx, body) in cases {
911            buf.extend_from_slice(&val_str_idx.to_le_bytes());
912            buf.extend_from_slice(&to_u32(body.len()));
913        }
914        // Case bodies
915        for (_, body) in cases {
916            buf.extend_from_slice(body);
917        }
918        buf
919    }
920
921    /// Encode a TRY_START/FALLBACK block.
922    ///
923    /// Binary layout:
924    /// ```text
925    /// [TRY_START(0x0D)] [fallback_len(u32)]
926    /// [...main_ops...]
927    /// [FALLBACK(0x0E)]
928    /// [...fallback_ops...]
929    /// ```
930    pub fn encode_try(main_ops: &[u8], fallback_ops: &[u8]) -> Vec<u8> {
931        let mut buf = Vec::new();
932        buf.push(0x0D); // Opcode::TryStart
933        buf.extend_from_slice(&to_u32(fallback_ops.len()));
934        buf.extend_from_slice(main_ops);
935        buf.push(0x0E); // Opcode::Fallback
936        buf.extend_from_slice(fallback_ops);
937        buf
938    }
939
940    /// Encode a PRELOAD opcode: opcode(1) + resource_type(1) + url_str_idx(4)
941    pub fn encode_preload(resource_type: u8, url_str_idx: u32) -> Vec<u8> {
942        let mut buf = Vec::new();
943        buf.push(0x0F); // Opcode::Preload
944        buf.push(resource_type);
945        buf.extend_from_slice(&url_str_idx.to_le_bytes());
946        buf
947    }
948
949    // -- Full-file builder --------------------------------------------------
950
951    /// Build a minimal valid FMIR v2 binary file for testing.
952    ///
953    /// * `strings` -- list of string table entries
954    /// * `slots` -- list of `(slot_id, name_str_idx, type_hint_byte, source_byte, default_bytes)` tuples
955    /// * `opcodes` -- raw opcode bytes (already assembled)
956    /// * `islands` -- list of `(id, trigger_byte, props_mode_byte, name_str_idx, byte_offset, slot_ids)` tuples
957    pub fn build_minimal_ir(
958        strings: &[&str],
959        slots: &[(u16, u32, u8, u8, &[u8])],
960        opcodes: &[u8],
961        islands: &[(u16, u8, u8, u32, u32, &[u16])],
962    ) -> Vec<u8> {
963        let string_section = build_string_table(strings);
964        let slot_section = build_slot_table(slots);
965        let island_section = build_island_table(islands);
966
967        // Section layout: header(16) + section_table(32) = 48 bytes before first section.
968        let data_start = HEADER_SIZE + SECTION_TABLE_SIZE;
969
970        // Order of sections in the file: bytecode, strings, slots, islands
971        let bytecode_offset = data_start;
972        let bytecode_size = opcodes.len();
973
974        let string_offset = bytecode_offset + bytecode_size;
975        let string_size = string_section.len();
976
977        let slot_offset = string_offset + string_size;
978        let slot_size = slot_section.len();
979
980        let island_offset = slot_offset + slot_size;
981        let island_size = island_section.len();
982
983        // Build header
984        let mut buf = Vec::new();
985        buf.extend_from_slice(b"FMIR");
986        buf.extend_from_slice(&2u16.to_le_bytes()); // version
987        buf.extend_from_slice(&0u16.to_le_bytes()); // flags
988        buf.extend_from_slice(&0u64.to_le_bytes()); // source_hash
989
990        // Build section table (4 sections: bytecode, strings, slots, islands)
991        buf.extend_from_slice(&to_u32(bytecode_offset));
992        buf.extend_from_slice(&to_u32(bytecode_size));
993        buf.extend_from_slice(&to_u32(string_offset));
994        buf.extend_from_slice(&to_u32(string_size));
995        buf.extend_from_slice(&to_u32(slot_offset));
996        buf.extend_from_slice(&to_u32(slot_size));
997        buf.extend_from_slice(&to_u32(island_offset));
998        buf.extend_from_slice(&to_u32(island_size));
999
1000        // Append section data
1001        buf.extend_from_slice(opcodes);
1002        buf.extend_from_slice(&string_section);
1003        buf.extend_from_slice(&slot_section);
1004        buf.extend_from_slice(&island_section);
1005
1006        buf
1007    }
1008}
1009
1010// ---------------------------------------------------------------------------
1011// Tests
1012// ---------------------------------------------------------------------------
1013
1014#[cfg(test)]
1015mod tests {
1016    use super::test_helpers::*;
1017    use super::*;
1018    use crate::format::{IrError, IslandTrigger, PropsMode, SlotSource, SlotType};
1019
1020    // -- StringTable tests --------------------------------------------------
1021
1022    #[test]
1023    fn parse_string_table() {
1024        let data = build_string_table(&["div", "class", "container"]);
1025        let table = StringTable::parse(&data).unwrap();
1026
1027        assert_eq!(table.len(), 3);
1028        assert_eq!(table.get(0).unwrap(), "div");
1029        assert_eq!(table.get(1).unwrap(), "class");
1030        assert_eq!(table.get(2).unwrap(), "container");
1031
1032        // Out of bounds
1033        let err = table.get(3).unwrap_err();
1034        assert_eq!(err, IrError::StringIndexOutOfBounds { index: 3, len: 3 });
1035    }
1036
1037    #[test]
1038    fn parse_string_table_empty() {
1039        let data = build_string_table(&[]);
1040        let table = StringTable::parse(&data).unwrap();
1041        assert_eq!(table.len(), 0);
1042    }
1043
1044    #[test]
1045    fn parse_string_table_unicode() {
1046        let data = build_string_table(&["héllo"]);
1047        let table = StringTable::parse(&data).unwrap();
1048
1049        assert_eq!(table.len(), 1);
1050        assert_eq!(table.get(0).unwrap(), "héllo");
1051    }
1052
1053    #[test]
1054    fn parse_string_table_truncated() {
1055        // Count says 2 strings but data is truncated after the first string's length prefix.
1056        let mut data = Vec::new();
1057        data.extend_from_slice(&2u32.to_le_bytes()); // count = 2
1058        data.extend_from_slice(&3u16.to_le_bytes()); // first string len = 3
1059        data.extend_from_slice(b"div"); // first string bytes
1060                                        // Second string is missing entirely
1061
1062        let err = StringTable::parse(&data).unwrap_err();
1063        match err {
1064            IrError::BufferTooShort { .. } => {} // expected
1065            other => panic!("expected BufferTooShort, got {other:?}"),
1066        }
1067    }
1068
1069    // -- SlotTable tests ----------------------------------------------------
1070
1071    #[test]
1072    fn parse_slot_table() {
1073        let data = build_slot_table(&[
1074            (1, 0, 0x01, 0x00, &[]), // slot_id=1, name_str_idx=0, type=Text, source=Server, no default
1075            (2, 1, 0x03, 0x01, &[0x42]), // slot_id=2, name_str_idx=1, type=Number, source=Client, 1-byte default
1076        ]);
1077        let table = SlotTable::parse(&data).unwrap();
1078
1079        assert_eq!(table.len(), 2);
1080
1081        let entries = table.entries();
1082        assert_eq!(entries[0].slot_id, 1);
1083        assert_eq!(entries[0].name_str_idx, 0);
1084        assert_eq!(entries[0].type_hint, SlotType::Text);
1085        assert_eq!(entries[0].source, SlotSource::Server);
1086        assert_eq!(entries[0].default_bytes, Vec::<u8>::new());
1087
1088        assert_eq!(entries[1].slot_id, 2);
1089        assert_eq!(entries[1].name_str_idx, 1);
1090        assert_eq!(entries[1].type_hint, SlotType::Number);
1091        assert_eq!(entries[1].source, SlotSource::Client);
1092        assert_eq!(entries[1].default_bytes, vec![0x42]);
1093    }
1094
1095    #[test]
1096    fn parse_slot_table_empty() {
1097        let data = build_slot_table(&[]);
1098        let table = SlotTable::parse(&data).unwrap();
1099        assert_eq!(table.len(), 0);
1100    }
1101
1102    // -- IslandTableParsed tests --------------------------------------------
1103
1104    #[test]
1105    fn parse_island_table() {
1106        let data = build_island_table(&[
1107            (1, 0x02, 0x01, 5, 0, &[]), // id=1, trigger=Visible, props=Inline, name_str_idx=5, byte_offset=0, no slots
1108        ]);
1109        let table = IslandTableParsed::parse(&data).unwrap();
1110
1111        assert_eq!(table.len(), 1);
1112
1113        let entry = &table.entries()[0];
1114        assert_eq!(entry.id, 1);
1115        assert_eq!(entry.trigger, IslandTrigger::Visible);
1116        assert_eq!(entry.props_mode, PropsMode::Inline);
1117        assert_eq!(entry.name_str_idx, 5);
1118        assert_eq!(entry.slot_ids, Vec::<u16>::new());
1119    }
1120
1121    #[test]
1122    fn parse_island_table_with_slot_ids() {
1123        let data = build_island_table(&[
1124            (0, 0x01, 0x01, 0, 0, &[0, 1]), // id=0, Load, Inline, name=0, byte_offset=0, slots=[0, 1]
1125        ]);
1126        let table = IslandTableParsed::parse(&data).unwrap();
1127        assert_eq!(table.len(), 1);
1128        let entry = &table.entries()[0];
1129        assert_eq!(entry.id, 0);
1130        assert_eq!(entry.trigger, IslandTrigger::Load);
1131        assert_eq!(entry.props_mode, PropsMode::Inline);
1132        assert_eq!(entry.slot_ids, vec![0, 1]);
1133    }
1134
1135    #[test]
1136    fn parse_island_table_empty() {
1137        let data = build_island_table(&[]);
1138        let table = IslandTableParsed::parse(&data).unwrap();
1139        assert_eq!(table.len(), 0);
1140    }
1141
1142    // -- IrModule tests -----------------------------------------------------
1143
1144    #[test]
1145    fn parse_minimal_ir_file() {
1146        // 1 string ("div"), empty slots/islands, opcodes = [OPEN_TAG "div", CLOSE_TAG "div"]
1147        let mut opcodes = Vec::new();
1148        opcodes.extend_from_slice(&encode_open_tag(0, &[]));
1149        opcodes.extend_from_slice(&encode_close_tag(0));
1150
1151        let data = build_minimal_ir(&["div"], &[], &opcodes, &[]);
1152        let module = IrModule::parse(&data).unwrap();
1153
1154        assert_eq!(module.header.version, 2);
1155        assert_eq!(module.strings.get(0).unwrap(), "div");
1156        assert_eq!(module.strings.len(), 1);
1157        assert_eq!(module.slots.len(), 0);
1158        assert_eq!(module.islands.len(), 0);
1159        assert_eq!(module.opcodes.len(), opcodes.len());
1160    }
1161
1162    #[test]
1163    fn parse_ir_with_slots() {
1164        let opcodes = encode_text(0);
1165        let data = build_minimal_ir(
1166            &["greeting", "count", "Hello"],
1167            &[
1168                (1, 0, 0x01, 0x00, &[]), // slot_id=1, name="greeting", type=Text, source=Server
1169                (2, 1, 0x03, 0x00, &[]), // slot_id=2, name="count", type=Number, source=Server
1170            ],
1171            &opcodes,
1172            &[],
1173        );
1174        let module = IrModule::parse(&data).unwrap();
1175
1176        assert_eq!(module.slots.len(), 2);
1177        let entries = module.slots.entries();
1178        assert_eq!(entries[0].slot_id, 1);
1179        assert_eq!(entries[0].name_str_idx, 0);
1180        assert_eq!(entries[0].type_hint, SlotType::Text);
1181        assert_eq!(entries[1].slot_id, 2);
1182        assert_eq!(entries[1].name_str_idx, 1);
1183        assert_eq!(entries[1].type_hint, SlotType::Number);
1184    }
1185
1186    #[test]
1187    fn parse_ir_with_islands() {
1188        // byte_offset must point at this island's own ISLAND_START opcode —
1189        // validate() rejects an island table that names an offset with no
1190        // matching ISLAND_START(id) there.
1191        let mut opcodes = vec![0x0B]; // ISLAND_START
1192        opcodes.extend_from_slice(&1u16.to_le_bytes());
1193        opcodes.extend_from_slice(&encode_text(1));
1194        opcodes.push(0x0C); // ISLAND_END
1195        opcodes.extend_from_slice(&1u16.to_le_bytes());
1196        let data = build_minimal_ir(
1197            &["Counter", "Hello"],
1198            &[],
1199            &opcodes,
1200            &[
1201                (1, 0x01, 0x01, 0, 0, &[]), // id=1, trigger=Load, props=Inline, name="Counter", byte_offset=0, no slots
1202            ],
1203        );
1204        let module = IrModule::parse(&data).unwrap();
1205
1206        assert_eq!(module.islands.len(), 1);
1207        let entry = &module.islands.entries()[0];
1208        assert_eq!(entry.id, 1);
1209        assert_eq!(entry.trigger, IslandTrigger::Load);
1210        assert_eq!(entry.props_mode, PropsMode::Inline);
1211        assert_eq!(entry.name_str_idx, 0);
1212        assert_eq!(module.strings.get(entry.name_str_idx).unwrap(), "Counter");
1213    }
1214
1215    #[test]
1216    fn parse_ir_rejects_truncated() {
1217        // File shorter than HEADER_SIZE (16 bytes)
1218        let data = b"FMIR\x02\x00";
1219        let err = IrModule::parse(data).unwrap_err();
1220        match err {
1221            IrError::BufferTooShort {
1222                expected: 16,
1223                actual: 6,
1224            } => {}
1225            other => panic!("expected BufferTooShort(16, 6), got {other:?}"),
1226        }
1227    }
1228
1229    #[test]
1230    fn parse_ir_rejects_bad_section_bounds() {
1231        // Build a valid file, then corrupt a section descriptor so it extends past EOF.
1232        let opcodes = encode_text(0);
1233        let mut data = build_minimal_ir(&["x"], &[], &opcodes, &[]);
1234
1235        // Corrupt section 3 (island table) size to be huge.
1236        // Section table starts at offset 16. Section 3 is at 16 + 3*8 = 40.
1237        // Size field is at offset 44 (40 + 4).
1238        let big_size: u32 = 99999;
1239        data[44..48].copy_from_slice(&big_size.to_le_bytes());
1240
1241        let err = IrModule::parse(&data).unwrap_err();
1242        match err {
1243            IrError::SectionOutOfBounds { section: 3, .. } => {}
1244            other => panic!("expected SectionOutOfBounds for section 3, got {other:?}"),
1245        }
1246    }
1247
1248    #[test]
1249    fn validate_catches_bad_slot_str_idx() {
1250        // Slot references string index 99, but only 3 strings exist.
1251        let opcodes = encode_text(0);
1252        let data = build_minimal_ir(
1253            &["a", "b", "c"],
1254            &[(1, 99, 0x01, 0x00, &[])], // name_str_idx=99, out of bounds
1255            &opcodes,
1256            &[],
1257        );
1258        let err = IrModule::parse(&data).unwrap_err();
1259        assert_eq!(err, IrError::StringIndexOutOfBounds { index: 99, len: 3 });
1260    }
1261
1262    #[test]
1263    fn validate_catches_bad_island_str_idx() {
1264        // Island references string index 99, but only 3 strings exist.
1265        let opcodes = encode_text(0);
1266        let mut island_opcodes = vec![0x0B];
1267        island_opcodes.extend_from_slice(&1u16.to_le_bytes());
1268        island_opcodes.extend_from_slice(&opcodes);
1269        island_opcodes.push(0x0C);
1270        island_opcodes.extend_from_slice(&1u16.to_le_bytes());
1271        let data = build_minimal_ir(
1272            &["a", "b", "c"],
1273            &[],
1274            &island_opcodes,
1275            &[(1, 0x01, 0x01, 99, 0, &[])],
1276        );
1277        let err = IrModule::parse(&data).unwrap_err();
1278        assert_eq!(err, IrError::StringIndexOutOfBounds { index: 99, len: 3 });
1279    }
1280
1281    #[test]
1282    fn duplicate_slot_names_resolve_first_wins_everywhere() {
1283        // The compiler derives some slot names per-element, so a name can name
1284        // two slots. `slot_id_by_name` returned the first while
1285        // `SlotData::from_json` (a HashMap insert) took the last — so setting
1286        // "dup" by name filled slot 1 while every lookup resolved slot 0.
1287        let opcodes = encode_text(0);
1288        let data = build_minimal_ir(
1289            &["dup"],
1290            &[(0, 0, 0x01, 0x00, &[]), (1, 0, 0x01, 0x00, &[])],
1291            &opcodes,
1292            &[],
1293        );
1294        let module = IrModule::parse(&data).unwrap();
1295
1296        assert_eq!(module.slot_id_by_name("dup"), Some(0));
1297
1298        let slots = crate::slot::SlotData::from_json(r#"{"dup":"x"}"#, &module).unwrap();
1299        assert_eq!(slots.get(0).to_text(), "x", "the first slot must be filled");
1300        assert_eq!(slots.get(1).to_text(), "", "the second must stay untouched");
1301    }
1302
1303    #[test]
1304    fn validate_rejects_list_item_slot_not_in_table() {
1305        // A LIST whose item_slot_id is not declared used to bind every row's
1306        // item into nowhere, so every row rendered identically with no error.
1307        // The slot table declares only slot 0; the LIST binds items to slot 7.
1308        let body = {
1309            let mut b = vec![0x05]; // DYN_TEXT
1310            b.extend_from_slice(&7u16.to_le_bytes());
1311            b.extend_from_slice(&0u16.to_le_bytes());
1312            b
1313        };
1314        let opcodes = encode_list(0, 7, &body);
1315        let data = build_minimal_ir(&["items"], &[(0, 0, 0x04, 0x00, &[])], &opcodes, &[]);
1316        assert_eq!(
1317            IrModule::parse(&data).unwrap_err(),
1318            IrError::UnknownSlotId(7)
1319        );
1320    }
1321
1322    #[test]
1323    fn validate_rejects_island_start_missing_from_table() {
1324        // An ISLAND_START with no island-table entry renders its markers but
1325        // no data-forma-* attributes, so the client silently never hydrates
1326        // it. Rejected at load instead.
1327        let mut opcodes = vec![0x0B]; // ISLAND_START
1328        opcodes.extend_from_slice(&3u16.to_le_bytes());
1329        opcodes.extend_from_slice(&encode_text(0));
1330        opcodes.push(0x0C); // ISLAND_END
1331        opcodes.extend_from_slice(&3u16.to_le_bytes());
1332
1333        let data = build_minimal_ir(&["hi"], &[], &opcodes, &[]);
1334        assert_eq!(
1335            IrModule::parse(&data).unwrap_err(),
1336            IrError::UnknownIslandId(3)
1337        );
1338    }
1339
1340    // -- Allocation guards (F-03 / F-13) ------------------------------------
1341
1342    #[test]
1343    fn string_table_rejects_count_the_section_cannot_hold() {
1344        // A string entry costs at least 2 bytes on disk but 24 bytes of
1345        // `String` header in the reservation, so the old `count > data.len()`
1346        // guard permitted a 12x blow-up — and `Vec::with_capacity` OOM aborts
1347        // the process rather than returning an error.
1348        let mut data = Vec::new();
1349        data.extend_from_slice(&1_000_000u32.to_le_bytes()); // count
1350        data.extend_from_slice(&[0u8; 16]); // only 16 bytes of payload
1351        let err = StringTable::parse(&data).unwrap_err();
1352        assert!(matches!(err, IrError::BufferTooShort { .. }));
1353    }
1354
1355    #[test]
1356    fn slot_table_rejects_count_the_section_cannot_hold() {
1357        // 2-byte section declaring 65,535 entries used to reserve ~2 MB.
1358        let data = 65535u16.to_le_bytes().to_vec();
1359        let err = SlotTable::parse(&data).unwrap_err();
1360        assert!(matches!(err, IrError::BufferTooShort { .. }));
1361    }
1362
1363    #[test]
1364    fn island_table_rejects_count_the_section_cannot_hold() {
1365        let data = 65535u16.to_le_bytes().to_vec();
1366        let err = IslandTableParsed::parse(&data).unwrap_err();
1367        assert!(matches!(err, IrError::BufferTooShort { .. }));
1368    }
1369
1370    // -- Cross-table validation (F-12 / F-16) -------------------------------
1371
1372    #[test]
1373    fn validate_rejects_duplicate_slot_ids() {
1374        let opcodes = encode_text(0);
1375        let data = build_minimal_ir(
1376            &["a", "b"],
1377            &[(0, 0, 0x01, 0x00, &[]), (0, 1, 0x01, 0x00, &[])],
1378            &opcodes,
1379            &[],
1380        );
1381        assert_eq!(
1382            IrModule::parse(&data).unwrap_err(),
1383            IrError::DuplicateSlotId(0)
1384        );
1385    }
1386
1387    #[test]
1388    fn validate_rejects_duplicate_island_ids() {
1389        let mut opcodes = vec![0x0B];
1390        opcodes.extend_from_slice(&0u16.to_le_bytes());
1391        opcodes.push(0x0C);
1392        opcodes.extend_from_slice(&0u16.to_le_bytes());
1393        let data = build_minimal_ir(
1394            &["Comp"],
1395            &[],
1396            &opcodes,
1397            &[(0, 0x01, 0x01, 0, 0, &[]), (0, 0x01, 0x01, 0, 0, &[])],
1398        );
1399        assert_eq!(
1400            IrModule::parse(&data).unwrap_err(),
1401            IrError::DuplicateIslandId(0)
1402        );
1403    }
1404
1405    #[test]
1406    fn validate_rejects_island_slot_id_absent_from_slot_table() {
1407        // The seam the compiler change that populates island slot_ids sits on:
1408        // a stale id used to be silently dropped from the props object, so the
1409        // island's SSR props disagreed with what the client expected, with
1410        // zero diagnostics.
1411        let mut opcodes = vec![0x0B];
1412        opcodes.extend_from_slice(&0u16.to_le_bytes());
1413        opcodes.push(0x0C);
1414        opcodes.extend_from_slice(&0u16.to_le_bytes());
1415        let data = build_minimal_ir(&["Comp"], &[], &opcodes, &[(0, 0x01, 0x01, 0, 0, &[7])]);
1416        assert_eq!(
1417            IrModule::parse(&data).unwrap_err(),
1418            IrError::UnknownSlotId(7)
1419        );
1420    }
1421
1422    #[test]
1423    fn validate_rejects_island_offset_landing_mid_operand() {
1424        // An in-bounds offset that happens to sit on a stray 0x0B byte passes
1425        // a bare bounds check — hence the structural check reads the opcode
1426        // AND the island id that follows it. Here byte_offset=4 lands inside a
1427        // TEXT operand whose bytes decode as ISLAND_START(0), while the entry
1428        // claims island 1.
1429        let mut opcodes = vec![0x0B];
1430        opcodes.extend_from_slice(&1u16.to_le_bytes());
1431        // TEXT with str_idx bytes containing 0x0B
1432        opcodes.push(0x04);
1433        opcodes.extend_from_slice(&0x0Bu32.to_le_bytes());
1434        opcodes.push(0x0C);
1435        opcodes.extend_from_slice(&1u16.to_le_bytes());
1436
1437        let data = build_minimal_ir(&["Comp"], &[], &opcodes, &[(1, 0x01, 0x01, 0, 4, &[])]);
1438        assert_eq!(
1439            IrModule::parse(&data).unwrap_err(),
1440            IrError::IslandOffsetMismatch {
1441                island_id: 1,
1442                byte_offset: 4
1443            }
1444        );
1445    }
1446
1447    #[test]
1448    fn validate_rejects_opcode_slot_id_absent_from_slot_table() {
1449        // DYN_TEXT naming slot 9 with an empty slot table.
1450        let mut opcodes = vec![0x05];
1451        opcodes.extend_from_slice(&9u16.to_le_bytes());
1452        opcodes.extend_from_slice(&0u16.to_le_bytes());
1453        let data = build_minimal_ir(&["a"], &[], &opcodes, &[]);
1454        assert_eq!(
1455            IrModule::parse(&data).unwrap_err(),
1456            IrError::UnknownSlotId(9)
1457        );
1458    }
1459
1460    #[test]
1461    fn slot_and_island_lookups_are_indexed() {
1462        let mut opcodes = vec![0x0B];
1463        opcodes.extend_from_slice(&3u16.to_le_bytes());
1464        opcodes.push(0x0C);
1465        opcodes.extend_from_slice(&3u16.to_le_bytes());
1466        let data = build_minimal_ir(
1467            &["Comp", "title"],
1468            &[(4, 1, 0x01, 0x00, &[])],
1469            &opcodes,
1470            &[(3, 0x01, 0x01, 0, 0, &[4])],
1471        );
1472        let module = IrModule::parse(&data).unwrap();
1473
1474        assert_eq!(module.slot_entry(4).unwrap().slot_id, 4);
1475        assert!(module.slot_entry(0).is_none());
1476        assert_eq!(module.island_entry(3).unwrap().id, 3);
1477        assert!(module.island_entry(0).is_none());
1478        // Out-of-range ids must not panic on the dense index.
1479        assert!(module.slot_entry(u16::MAX).is_none());
1480        assert!(module.island_entry(u16::MAX).is_none());
1481    }
1482}