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