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