Skip to main content

forma_ir/
format.rs

1//! FMIR binary format definition and parsers.
2//!
3//! Defines the on-disk layout for Forma Module IR files:
4//! - 24-byte signature/version/length/CRC header
5//! - Variable section table (count + reserved + 12-byte descriptors)
6//! - Opcode, SlotType, IslandTrigger enums
7//! - SlotEntry and IslandEntry structs
8//!
9//! All multi-byte integers are little-endian.
10
11use std::fmt;
12
13// ---------------------------------------------------------------------------
14// Constants
15// ---------------------------------------------------------------------------
16
17// Shared FMIR constants, generated from the root fmir-constants.json.
18include!("fmir_constants.rs");
19
20/// Base version of this reader, taken from Cargo package metadata.
21pub fn reader_version() -> [u16; 3] {
22    // Cargo validates these components. Invalid/out-of-u16 metadata cannot accept a module.
23    [
24        env!("CARGO_PKG_VERSION_MAJOR").parse().unwrap_or(u16::MAX),
25        env!("CARGO_PKG_VERSION_MINOR").parse().unwrap_or(u16::MAX),
26        env!("CARGO_PKG_VERSION_PATCH").parse().unwrap_or(u16::MAX),
27    ]
28}
29
30/// Whether a reader accepts a writer: the same major and no newer minor.
31/// Patch versions and package prerelease suffixes do not narrow the wire window.
32pub const fn accepts_writer(reader: [u16; 3], writer: [u16; 3]) -> bool {
33    reader[0] == writer[0] && writer[1] <= reader[1]
34}
35
36/// CRC-32/ISO-HDLC of a sequence of byte slices, without adding bytes at their boundaries.
37pub fn crc32(chunks: &[&[u8]]) -> u32 {
38    const fn table() -> [u32; 256] {
39        let mut out = [0; 256];
40        let mut i = 0;
41        while i < 256 {
42            let mut c = i as u32;
43            let mut bit = 0;
44            while bit < 8 {
45                c = (c >> 1) ^ if c & 1 != 0 { CRC_POLYNOMIAL } else { 0 };
46                bit += 1;
47            }
48            out[i] = c;
49            i += 1;
50        }
51        out
52    }
53    const TABLE: [u32; 256] = table();
54    let mut c = 0xffffffff;
55    for chunk in chunks {
56        for &b in *chunk {
57            c = TABLE[((c ^ u32::from(b)) & 255) as usize] ^ (c >> 8);
58        }
59    }
60    c ^ 0xffffffff
61}
62
63/// Refuse an input-driven count before allocation when it exceeds its named ceiling.
64pub(crate) fn limit(name: &'static str, actual: usize, max: usize) -> Result<(), IrError> {
65    if actual > max {
66        Err(IrError::LimitExceeded(Box::new(LimitViolation {
67            limit: name,
68            max,
69            actual,
70        })))
71    } else {
72        Ok(())
73    }
74}
75
76// ---------------------------------------------------------------------------
77// Little-endian reads
78// ---------------------------------------------------------------------------
79//
80// Each call site has already checked that its slice is in bounds and of the
81// width read; these index rather than `try_into().unwrap()` so the crate
82// carries no `unwrap` (DR-12). The indexing cannot fail where the slice could
83// be taken.
84
85/// A `u16` from the first 2 bytes of `b`.
86pub(crate) fn le_u16(b: &[u8]) -> u16 {
87    u16::from_le_bytes([b[0], b[1]])
88}
89
90/// A `u32` from the first 4 bytes of `b`.
91pub(crate) fn le_u32(b: &[u8]) -> u32 {
92    u32::from_le_bytes([b[0], b[1], b[2], b[3]])
93}
94
95// ---------------------------------------------------------------------------
96// Error type
97// ---------------------------------------------------------------------------
98
99/// Details of an input limit refusal, boxed to keep the common error enum small.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct LimitViolation {
102    /// Which named limit was exceeded.
103    pub limit: &'static str,
104    /// Maximum accepted value.
105    pub max: usize,
106    /// Actual input value.
107    pub actual: usize,
108}
109
110/// A declared island input which could not be snapshotted during rendering.
111/// Boxed in IrError so path diagnostics do not enlarge every common error.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct IslandStateViolation {
114    /// Static island identifier.
115    pub island: u16,
116    /// Declared field/index path within its props.
117    pub path: String,
118    /// Stable reason for refusing the snapshot.
119    pub reason: &'static str,
120}
121
122/// A keyed list row whose identity could not be written (FORMA-181, DR-8).
123///
124/// The walker reads each row's value at the list's key path (FMIR section
125/// `0x0006`) and writes its canonical text as the row root's
126/// `data-forma-key`. The whole render is refused, as `RENDER-503`, when the
127/// value is not a row identity, when two rows of one list execution share a
128/// key, or when the row did not render exactly one root element to carry it.
129/// Boxed in [`IrError`] so the path and ordinals do not enlarge every common
130/// error. `#[non_exhaustive]`: a field can be added without a breaking
131/// change; build one with [`ListKeyViolation::new`].
132#[derive(Debug, Clone, PartialEq, Eq)]
133#[non_exhaustive]
134pub struct ListKeyViolation {
135    /// Byte offset of the `LIST` opcode in the bytecode, as section `0x0006`
136    /// names it.
137    pub list_offset: u32,
138    /// Zero-based position of the refused row in the array this execution of
139    /// the list iterated.
140    pub row: usize,
141    /// The key path, dotted (`id`, `user.id`); a segment holding a dot or a
142    /// bracket, or an empty one, is quoted in brackets (`["user.id"]`).
143    pub path: String,
144    /// What was wrong with the row's key.
145    pub reason: ListKeyReason,
146}
147
148impl ListKeyViolation {
149    /// A keyed row's refusal: the LIST's bytecode offset, the zero-based row,
150    /// the key path as diagnostics show it, and the reason.
151    pub fn new(
152        list_offset: u32,
153        row: usize,
154        path: impl Into<String>,
155        reason: ListKeyReason,
156    ) -> Self {
157        ListKeyViolation {
158            list_offset,
159            row,
160            path: path.into(),
161            reason,
162        }
163    }
164}
165
166/// Why a keyed row was refused ([`ListKeyViolation::reason`]).
167#[derive(Debug, Clone, PartialEq, Eq)]
168#[non_exhaustive]
169pub enum ListKeyReason {
170    /// The row, or an object on the path, has no field of that name.
171    MissingField {
172        /// The segment that was not found.
173        segment: String,
174    },
175    /// The row, or an object on the path, declares the field twice, so the
176    /// key would be whichever one a reader happened to take.
177    DuplicateField {
178        /// The repeated segment.
179        segment: String,
180    },
181    /// A value on the path before the last segment is not an object.
182    NotAnObject {
183        /// The segment that could not be read.
184        segment: String,
185    },
186    /// The value at the path is not a string or a safe integer: `null`, a
187    /// boolean, an array or an object.
188    NotAnIdentity {
189        /// The kind of value found, for the message.
190        kind: &'static str,
191    },
192    /// The value at the path is a number that is not an integer within
193    /// ±(2^53 − 1): a fraction, an infinity, NaN or a larger magnitude.
194    NotASafeInteger,
195    /// Another row of the same list execution already rendered this key.
196    /// The key itself is not carried: it is the row's data, and a
197    /// diagnostic names positions, never private values.
198    Duplicate {
199        /// Zero-based position of the row that rendered it first.
200        first_row: usize,
201    },
202    /// The row body did not render exactly one root element to carry the
203    /// key: no element, more than one, or text, a dynamic range or an island
204    /// envelope that does not wholly enclose the root beside it.
205    RowShape {
206        /// Elements opened directly at the row's level.
207        roots: usize,
208        /// Whether text, a range, or an island envelope that does not wholly
209        /// enclose the root was written directly at the row's level.
210        stray_content: bool,
211    },
212    /// The row's root carries an authored `data-forma-key` (static or
213    /// dynamic); the attribute is the compiler-written identity and nothing
214    /// else may set it.
215    AuthoredKey,
216    /// The row closed an element it did not open, or left one open at its
217    /// end, so its root is not one balanced element at the row's level.
218    UnbalancedRow,
219}
220
221impl fmt::Display for ListKeyReason {
222    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
223        match self {
224            ListKeyReason::MissingField { segment } => {
225                write!(f, "the row has no field `{segment}`")
226            }
227            ListKeyReason::DuplicateField { segment } => {
228                write!(f, "the row declares the field `{segment}` more than once")
229            }
230            ListKeyReason::NotAnObject { segment } => {
231                write!(f, "`{segment}` is not an object, so the path cannot continue")
232            }
233            ListKeyReason::NotAnIdentity { kind } => {
234                write!(f, "the key is {kind}, not a string or a safe integer")
235            }
236            ListKeyReason::NotASafeInteger => write!(
237                f,
238                "the key number is not an integer within the safe range (a fraction, an infinity, NaN or beyond 2^53-1)"
239            ),
240            ListKeyReason::Duplicate { first_row } => {
241                write!(f, "the row repeats the key row {first_row} already rendered")
242            }
243            ListKeyReason::RowShape {
244                roots,
245                stray_content,
246            } => write!(
247                f,
248                "the row must render exactly one root element to carry its key ({roots} root element{}{})",
249                if *roots == 1 { "" } else { "s" },
250                if *stray_content {
251                    ", with text, a range or an island envelope beside it"
252                } else {
253                    ""
254                }
255            ),
256            ListKeyReason::AuthoredKey => write!(
257                f,
258                "the row root carries an authored data-forma-key; the attribute is reserved for the row identity"
259            ),
260            ListKeyReason::UnbalancedRow => write!(
261                f,
262                "the row closes an element it did not open or leaves one open, so it has no single balanced root"
263            ),
264        }
265    }
266}
267
268/// Errors that can occur when parsing FMIR binary data.
269///
270/// Marked `#[non_exhaustive]`: new failure modes are added as the parser is
271/// hardened, and downstream crates must not break when that happens. Match
272/// with a `_ =>` arm (or just use `Display`).
273#[derive(Debug, Clone, PartialEq, Eq)]
274#[non_exhaustive]
275pub enum IrError {
276    /// Input buffer is too short to contain the expected structure.
277    BufferTooShort {
278        /// Bytes the structure needed.
279        expected: usize,
280        /// Bytes actually available.
281        actual: usize,
282    },
283    /// Signature is not the FMIR v3 signature.
284    BadSignature([u8; 8]),
285    /// A legacy FMIR header needs a rebuild.
286    LegacyModule {
287        /// Legacy header version.
288        version: u16,
289    },
290    /// Writer base version differs from the reader's Cargo version.
291    UnsupportedWriter {
292        /// Writer major/minor/patch.
293        writer: [u16; 3],
294        /// Reader major/minor/patch.
295        reader: [u16; 3],
296    },
297    /// Declared body length differs from the actual file.
298    LengthMismatch {
299        /// Declared body bytes.
300        declared: usize,
301        /// Actual body bytes.
302        actual: usize,
303    },
304    /// CRC-32 does not match the file excluding its CRC field.
305    ChecksumMismatch {
306        /// Stored checksum.
307        expected: u32,
308        /// Computed checksum.
309        computed: u32,
310    },
311    /// Reserved bits must be zero.
312    ReservedBits {
313        /// Field that carried the bits.
314        field: &'static str,
315    },
316    /// Section ids must be strictly ascending and unique.
317    DuplicateSection(u16),
318    /// Section offsets must cover exactly the data after the table.
319    SectionsDoNotTile,
320    /// A required section was absent.
321    MissingSection(u16),
322    /// An unknown section was marked required.
323    UnknownRequiredSection {
324        /// Section identifier.
325        id: u16,
326        /// Writer base version.
327        writer: [u16; 3],
328    },
329    /// An input-controlled size or count exceeds its ceiling.
330    LimitExceeded(Box<LimitViolation>),
331    /// Known sections must consume their complete payload.
332    TrailingBytes {
333        /// Section identifier.
334        section: u16,
335    },
336    /// Invalid props schema type or reference.
337    InvalidSchema(&'static str),
338    /// Invalid key-path entry.
339    InvalidKeyPath(&'static str),
340    /// Invalid source-position entry.
341    InvalidSourcePosition(&'static str),
342    /// A slot default does not parse for its declared type.
343    InvalidDefault(u16),
344    /// A section extends beyond the file boundary.
345    SectionOutOfBounds {
346        /// Index of the offending section in the section table.
347        section: usize,
348        /// The section's declared byte offset.
349        offset: u32,
350        /// The section's declared byte length.
351        size: u32,
352        /// Length of the file the offsets were read from.
353        file_len: usize,
354    },
355    /// An opcode byte does not map to a known opcode.
356    InvalidOpcode(u8),
357    /// A slot-type byte does not map to a known slot type.
358    InvalidSlotType(u8),
359    /// An island-trigger byte does not map to a known trigger.
360    InvalidIslandTrigger(u8),
361    /// A slot-source byte does not map to a known source.
362    InvalidSlotSource(u8),
363    /// A string index is out of bounds.
364    StringIndexOutOfBounds {
365        /// The index that was asked for.
366        index: u32,
367        /// Number of entries the string table actually holds.
368        len: usize,
369    },
370    /// A byte sequence is not valid UTF-8.
371    InvalidUtf8(String),
372    /// Nested LIST depth exceeded the maximum allowed.
373    ListDepthExceeded {
374        /// The nesting limit that was exceeded.
375        max: u8,
376    },
377    /// An island with the given id was not found in the island table.
378    IslandNotFound(u16),
379    /// Failed to parse JSON input.
380    JsonParseError(String),
381    /// A section descriptor has invalid values (e.g. integer overflow).
382    ///
383    /// Also returned for opcode-stream offsets that overflow or point outside
384    /// the region they were read from (branch/body lengths in SHOW_IF, SWITCH,
385    /// LIST and TRY_START/FALLBACK).
386    InvalidSection,
387    /// Recursive opcode nesting (SHOW_IF, LIST, SWITCH, etc.) exceeded the
388    /// maximum allowed depth.
389    RecursionLimitExceeded,
390    /// The rendered HTML exceeded the walker's configured output budget.
391    OutputLimitExceeded {
392        /// The byte budget that was exceeded.
393        limit: usize,
394    },
395    /// An executed island's declared request state cannot be projected.
396    InvalidIslandState(Box<IslandStateViolation>),
397    /// The complete escaped state envelope exceeds its fixed byte ceiling.
398    IslandStateLimitExceeded {
399        /// Maximum complete envelope bytes.
400        limit: usize,
401    },
402    /// RENDER-503: a keyed list row has no usable identity, repeats another
403    /// row's key, or did not render one root element to carry it
404    /// (FORMA-181). No partial page is returned.
405    InvalidListKey(Box<ListKeyViolation>),
406    /// Total LIST item iterations exceeded the walker's per-render budget.
407    /// Distinct from [`IrError::ListDepthExceeded`], which bounds *nesting*.
408    ListBudgetExceeded {
409        /// The total-iteration budget that was exceeded.
410        max: usize,
411    },
412    /// A tag or attribute name from the string table is not a legal HTML name.
413    InvalidName(String),
414    /// The slot table declares the same `slot_id` more than once.
415    DuplicateSlotId(u16),
416    /// The island table declares the same island `id` more than once.
417    DuplicateIslandId(u16),
418    /// An island's `byte_offset` does not point at an ISLAND_START opcode
419    /// carrying that island's own id.
420    IslandOffsetMismatch {
421        /// The island whose table entry is wrong.
422        island_id: u16,
423        /// The byte offset that entry declared.
424        byte_offset: u32,
425    },
426    /// A slot id referenced by an island entry or an opcode is not declared
427    /// in the slot table.
428    UnknownSlotId(u16),
429    /// The opcode stream's control-flow regions do not tile exactly: a
430    /// branch/body length does not line up with the opcodes it contains.
431    MalformedControlFlow {
432        /// Byte offset in the opcode stream where the mismatch was detected.
433        pos: usize,
434    },
435    /// An ISLAND_START opcode names an island id the island table does not
436    /// declare. Such an island emits its `<!--f:iN-->` markers but no
437    /// `data-forma-*` attributes, so the client can never hydrate it.
438    UnknownIslandId(u16),
439    /// Two ISLAND_START opcodes are pending on the same element: the outer
440    /// island's body opens no tag of its own before the inner island starts,
441    /// so only one of them can carry the `data-forma-island` attribute set and
442    /// the other would silently never hydrate.
443    NestedIslandRoot {
444        /// The island still waiting for a root element.
445        outer: u16,
446        /// The island that started before it got one.
447        inner: u16,
448    },
449    /// The walk panicked, and [`crate::walker::walk_to_html_counted`] caught
450    /// the unwind. This reports a defect in the walker, not in the module or
451    /// the data. Under `panic = "abort"` there is nothing to catch and the
452    /// process ends instead.
453    Panicked {
454        /// The panic's message, when it carried one.
455        message: String,
456    },
457}
458
459impl fmt::Display for IrError {
460    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
461        match self {
462            IrError::BufferTooShort { expected, actual } => {
463                write!(
464                    f,
465                    "buffer too short: expected at least {expected} bytes, got {actual}"
466                )
467            }
468            IrError::BadSignature(got) => write!(f, "bad signature: {got:02x?}"),
469            IrError::LegacyModule { version } => write!(
470                f,
471                "an FMIR module before v3 (header version {version}): rebuild with a 3.x compiler"
472            ),
473            IrError::UnsupportedWriter { writer, reader } => write!(
474                f,
475                "writer {}.{}.{} is incompatible with reader {}.{}.{}",
476                writer[0], writer[1], writer[2], reader[0], reader[1], reader[2]
477            ),
478            IrError::LengthMismatch { declared, actual } => write!(
479                f,
480                "length mismatch: declared body {declared}, actual {actual}"
481            ),
482            IrError::ChecksumMismatch { expected, computed } => write!(
483                f,
484                "checksum mismatch: expected {expected:08x}, computed {computed:08x}"
485            ),
486            IrError::ReservedBits { field } => write!(f, "reserved bits in {field}"),
487            IrError::DuplicateSection(id) => write!(f, "duplicate or unordered section 0x{id:04x}"),
488            IrError::SectionsDoNotTile => write!(f, "sections do not tile the file"),
489            IrError::MissingSection(id) => write!(f, "missing required section 0x{id:04x}"),
490            IrError::UnknownRequiredSection { id, writer } => {
491                let reader = reader_version();
492                write!(f, "unknown required section 0x{id:04x} written by {}.{}.{}; this reader is {}.{}.{}",writer[0],writer[1],writer[2],reader[0],reader[1],reader[2])
493            }
494            IrError::LimitExceeded(details) => {
495                let LimitViolation { limit, max, actual } = details.as_ref();
496                write!(f, "{limit} exceeds limit {max}: {actual}")
497            }
498            IrError::TrailingBytes { section } => {
499                write!(f, "trailing bytes in section 0x{section:04x}")
500            }
501            IrError::InvalidSchema(reason) => write!(f, "invalid props schema: {reason}"),
502            IrError::InvalidKeyPath(reason) => write!(f, "invalid key path: {reason}"),
503            IrError::InvalidSourcePosition(reason) => {
504                write!(f, "invalid source position: {reason}")
505            }
506            IrError::InvalidDefault(id) => {
507                write!(f, "default for slot {id} does not parse for its type")
508            }
509            IrError::SectionOutOfBounds {
510                section,
511                offset,
512                size,
513                file_len,
514            } => {
515                write!(
516                    f,
517                    "section {section} out of bounds: offset={offset}, size={size}, file_len={file_len}"
518                )
519            }
520            IrError::InvalidOpcode(b) => write!(f, "invalid opcode: 0x{b:02x}"),
521            IrError::InvalidSlotType(b) => write!(f, "invalid slot type: 0x{b:02x}"),
522            IrError::InvalidIslandTrigger(b) => {
523                write!(f, "invalid island trigger: 0x{b:02x}")
524            }
525            IrError::InvalidSlotSource(b) => write!(f, "invalid slot source: 0x{b:02x}"),
526            IrError::StringIndexOutOfBounds { index, len } => {
527                write!(
528                    f,
529                    "string index {index} out of bounds (table has {len} entries)"
530                )
531            }
532            IrError::InvalidUtf8(msg) => write!(f, "invalid UTF-8: {msg}"),
533            IrError::ListDepthExceeded { max } => {
534                write!(f, "nested LIST depth exceeded maximum of {max}")
535            }
536            IrError::IslandNotFound(id) => {
537                write!(f, "island with id {id} not found in island table")
538            }
539            IrError::JsonParseError(msg) => {
540                write!(f, "JSON parse error: {msg}")
541            }
542            IrError::InvalidSection => {
543                write!(f, "invalid section or opcode offset (out of range)")
544            }
545            IrError::RecursionLimitExceeded => {
546                write!(f, "recursion limit exceeded (maximum depth: 64)")
547            }
548            IrError::OutputLimitExceeded { limit } => {
549                write!(f, "rendered output exceeded the limit of {limit} bytes")
550            }
551            IrError::InvalidIslandState(problem) => {
552                write!(
553                    f,
554                    "RENDER-501: island {} {}: {}",
555                    problem.island, problem.path, problem.reason
556                )
557            }
558            IrError::IslandStateLimitExceeded { limit } => {
559                write!(f, "RENDER-502: island state exceeded {limit} encoded bytes")
560            }
561            IrError::InvalidListKey(violation) => {
562                let ListKeyViolation {
563                    list_offset,
564                    row,
565                    path,
566                    reason,
567                } = violation.as_ref();
568                write!(
569                    f,
570                    "RENDER-503: LIST at bytecode offset {list_offset}, row {row}, key path `{path}`: {reason}"
571                )
572            }
573            IrError::ListBudgetExceeded { max } => {
574                write!(f, "total LIST iterations exceeded the budget of {max}")
575            }
576            IrError::InvalidName(name) => {
577                write!(f, "invalid tag or attribute name: {name:?}")
578            }
579            IrError::DuplicateSlotId(id) => {
580                write!(f, "duplicate slot id {id} in slot table")
581            }
582            IrError::DuplicateIslandId(id) => {
583                write!(f, "duplicate island id {id} in island table")
584            }
585            IrError::IslandOffsetMismatch {
586                island_id,
587                byte_offset,
588            } => {
589                write!(
590                    f,
591                    "island {island_id} byte_offset {byte_offset} does not point at its ISLAND_START opcode"
592                )
593            }
594            IrError::UnknownSlotId(id) => {
595                write!(f, "slot id {id} is not declared in the slot table")
596            }
597            IrError::MalformedControlFlow { pos } => {
598                write!(
599                    f,
600                    "malformed control flow: region lengths do not tile at byte {pos}"
601                )
602            }
603            IrError::UnknownIslandId(id) => {
604                write!(f, "island id {id} is not declared in the island table")
605            }
606            IrError::NestedIslandRoot { outer, inner } => {
607                write!(
608                    f,
609                    "island {inner} starts before island {outer} has opened its root element — \
610                     one of them could never carry its data-forma-* attributes"
611                )
612            }
613            IrError::Panicked { message } => write!(f, "the walk panicked: {message}"),
614        }
615    }
616}
617
618impl IrError {
619    /// Stable loader diagnostic code, shared with forma-server.
620    pub fn code(&self) -> &'static str {
621        match self {
622            Self::BadSignature(_) | Self::LegacyModule { .. } => LOAD_102,
623            Self::LengthMismatch { .. } | Self::ChecksumMismatch { .. } => LOAD_104,
624            Self::MissingSection(_) => LOAD_105,
625            Self::UnknownRequiredSection { .. } => LOAD_106,
626            Self::LimitExceeded(_) => LOAD_107,
627            Self::UnsupportedWriter { .. } => LOAD_108,
628            _ => LOAD_103,
629        }
630    }
631}
632impl std::error::Error for IrError {}
633
634/// Validated FMIR v3 header (24 bytes).
635#[derive(Debug, Clone, Copy, PartialEq, Eq)]
636pub struct IrHeader {
637    /// Writer's base semantic version.
638    pub writer: [u16; 3],
639    /// Bytes after the header.
640    pub body_len: u32,
641    /// CRC-32 of the file excluding bytes 18 through 21.
642    pub crc: u32,
643}
644
645impl IrHeader {
646    /// Validate a complete file's header, including exact length and CRC, before section parsing.
647    pub fn parse(data: &[u8]) -> Result<Self, IrError> {
648        Self::parse_for_reader(data, reader_version())
649    }
650
651    // Private: production readers always use their own Cargo package version.
652    fn parse_for_reader(data: &[u8], reader: [u16; 3]) -> Result<Self, IrError> {
653        limit("module bytes", data.len(), MAX_MODULE_SIZE)?;
654        if data.len() < HEADER_SIZE {
655            return Err(IrError::BufferTooShort {
656                expected: HEADER_SIZE,
657                actual: data.len(),
658            });
659        }
660        if data[..8] != SIGNATURE {
661            if data[..4] == *b"FMIR" {
662                return Err(IrError::LegacyModule {
663                    version: le_u16(&data[4..6]),
664                });
665            }
666            let mut got = [0; 8];
667            got.copy_from_slice(&data[..8]);
668            return Err(IrError::BadSignature(got));
669        }
670        let writer = [
671            le_u16(&data[8..10]),
672            le_u16(&data[10..12]),
673            le_u16(&data[12..14]),
674        ];
675        if !accepts_writer(reader, writer) {
676            return Err(IrError::UnsupportedWriter { writer, reader });
677        }
678        let body_len = le_u32(&data[14..18]);
679        let actual = data.len() - HEADER_SIZE;
680        if body_len as usize != actual {
681            return Err(IrError::LengthMismatch {
682                declared: body_len as usize,
683                actual,
684            });
685        }
686        let crc = le_u32(&data[18..22]);
687        let computed = crc32(&[&data[..18], &data[22..]]);
688        if computed != crc {
689            return Err(IrError::ChecksumMismatch {
690                expected: crc,
691                computed,
692            });
693        }
694        if data[22..24] != [0, 0] {
695            return Err(IrError::ReservedBits { field: "header" });
696        }
697        Ok(Self {
698            writer,
699            body_len,
700            crc,
701        })
702    }
703}
704
705/// A 12-byte section descriptor.
706#[derive(Debug, Clone, Copy, PartialEq, Eq)]
707pub struct SectionDescriptor {
708    /// Section kind, unique and ascending.
709    pub id: u16,
710    /// Bit 0: required; all other bits reserved.
711    pub flags: u16,
712    /// Absolute file offset.
713    pub offset: u32,
714    /// Payload length.
715    pub size: u32,
716}
717
718/// Validated variable-length section table following the header.
719#[derive(Debug, Clone, PartialEq, Eq)]
720pub struct SectionTable {
721    /// Descriptors in file order.
722    pub sections: Vec<SectionDescriptor>,
723}
724impl SectionTable {
725    /// Parse the body prefix; the caller has already checked header integrity.
726    pub fn parse(data: &[u8]) -> Result<Self, IrError> {
727        if data.len() < 2 {
728            return Err(IrError::BufferTooShort {
729                expected: 2,
730                actual: data.len(),
731            });
732        }
733        let count = le_u16(data) as usize;
734        limit("sections", count, MAX_SECTIONS)?;
735        let end = TABLE_PREFIX_SIZE + count * SECTION_ENTRY_SIZE;
736        if end > data.len() {
737            return Err(IrError::BufferTooShort {
738                expected: end,
739                actual: data.len(),
740            });
741        }
742        if le_u16(&data[2..4]) != 0 {
743            return Err(IrError::ReservedBits {
744                field: "section table",
745            });
746        }
747        let mut sections = Vec::with_capacity(count);
748        let mut previous = None;
749        for entry in data[TABLE_PREFIX_SIZE..end].chunks_exact(SECTION_ENTRY_SIZE) {
750            let id = le_u16(entry);
751            let flags = le_u16(&entry[2..]);
752            if previous.is_some_and(|p| id <= p) {
753                return Err(IrError::DuplicateSection(id));
754            }
755            if flags & !SECTION_REQUIRED != 0 {
756                return Err(IrError::ReservedBits {
757                    field: "section flags",
758                });
759            }
760            sections.push(SectionDescriptor {
761                id,
762                flags,
763                offset: le_u32(&entry[4..]),
764                size: le_u32(&entry[8..]),
765            });
766            previous = Some(id);
767        }
768        Ok(Self { sections })
769    }
770    /// Require exact tiling, including unknown optional payloads.
771    pub fn validate(&self, file_len: usize) -> Result<(), IrError> {
772        let mut expected =
773            HEADER_SIZE + TABLE_PREFIX_SIZE + self.sections.len() * SECTION_ENTRY_SIZE;
774        for section in &self.sections {
775            if section.offset as usize != expected {
776                return Err(IrError::SectionsDoNotTile);
777            }
778            expected = expected
779                .checked_add(section.size as usize)
780                .ok_or(IrError::InvalidSection)?;
781            if expected > file_len {
782                return Err(IrError::SectionsDoNotTile);
783            }
784        }
785        if expected != file_len {
786            return Err(IrError::SectionsDoNotTile);
787        }
788        Ok(())
789    }
790    /// Find a descriptor by id.
791    pub fn get(&self, id: u16) -> Option<&SectionDescriptor> {
792        self.sections.iter().find(|s| s.id == id)
793    }
794}
795
796// ---------------------------------------------------------------------------
797// Opcode enum (13 active opcodes; six reserved v2 values)
798//
799// Six unused v2 values remain reserved forever: Switch, TryStart, Fallback,
800// Preload, Comment and ListItemKey. From v3 they are refused at load and have
801// no walker arms. Key paths use a separate typed section (FORMA-181).
802// ---------------------------------------------------------------------------
803
804/// Bytecode opcodes for the FMIR instruction stream.
805#[derive(Debug, Clone, Copy, PartialEq, Eq)]
806#[repr(u8)]
807pub enum Opcode {
808    /// Open an element. `str_idx(u32) + attr_count(u16) + [(key u32, val u32)]`
809    OpenTag = OP_OPEN_TAG,
810    /// Close the named element. `str_idx(u32)`
811    CloseTag = OP_CLOSE_TAG,
812    /// A void element, which takes no CLOSE_TAG. Same operands as OPEN_TAG.
813    VoidTag = OP_VOID_TAG,
814    /// Static text from the string table. `str_idx(u32)`
815    Text = OP_TEXT,
816    /// Slot-bound text wrapped in `<!--f:tN-->` markers.
817    /// `slot_id(u16) + marker_id(u16)`
818    DynText = OP_DYN_TEXT,
819    /// Slot-bound attribute on the tag currently being opened.
820    /// `attr_str_idx(u32) + slot_id(u16)`
821    DynAttr = OP_DYN_ATTR,
822    /// Conditional region wrapped in `<!--f:sN-->` markers.
823    /// `slot_id(u16) + then_len(u32) + else_len(u32)`, then the two bodies
824    /// separated by a [`Opcode::ShowElse`] byte.
825    ShowIf = OP_SHOW_IF,
826    /// Separator byte between a SHOW_IF's then- and else-bodies.
827    ShowElse = OP_SHOW_ELSE,
828    /// Repeat a body once per element of an array slot, wrapped in
829    /// `<!--f:lN-->` markers. `slot_id(u16) + item_slot_id(u16) + body_len(u32)`
830    List = OP_LIST,
831    /// Begin an island region. `island_id(u16)`
832    IslandStart = OP_ISLAND_START,
833    /// End an island region. `island_id(u16)`
834    IslandEnd = OP_ISLAND_END,
835    /// Extract a named property from an Object slot into a target slot.
836    /// Format: src_slot_id(u16) + prop_str_idx(u32) + target_slot_id(u16)
837    Prop = OP_PROP,
838    /// Select fallback only when source is Null. `source u16 + fallback u16 + destination u16`.
839    Coalesce = OP_COALESCE,
840}
841
842impl Opcode {
843    /// Convert a raw byte to an `Opcode`.
844    pub fn from_byte(b: u8) -> Result<Self, IrError> {
845        match b {
846            OP_OPEN_TAG => Ok(Opcode::OpenTag),
847            OP_CLOSE_TAG => Ok(Opcode::CloseTag),
848            OP_VOID_TAG => Ok(Opcode::VoidTag),
849            OP_TEXT => Ok(Opcode::Text),
850            OP_DYN_TEXT => Ok(Opcode::DynText),
851            OP_DYN_ATTR => Ok(Opcode::DynAttr),
852            OP_SHOW_IF => Ok(Opcode::ShowIf),
853            OP_SHOW_ELSE => Ok(Opcode::ShowElse),
854            OP_LIST => Ok(Opcode::List),
855            OP_ISLAND_START => Ok(Opcode::IslandStart),
856            OP_ISLAND_END => Ok(Opcode::IslandEnd),
857            OP_PROP => Ok(Opcode::Prop),
858            OP_COALESCE => Ok(Opcode::Coalesce),
859            _ => Err(IrError::InvalidOpcode(b)),
860        }
861    }
862}
863
864// ---------------------------------------------------------------------------
865// SlotType enum (5 types)
866// ---------------------------------------------------------------------------
867
868/// Data type hint for a slot entry.
869#[derive(Debug, Clone, Copy, PartialEq, Eq)]
870#[repr(u8)]
871pub enum SlotType {
872    /// A string.
873    Text = SLOT_TYPE_TEXT,
874    /// A boolean.
875    Bool = SLOT_TYPE_BOOL,
876    /// An IEEE-754 double, formatted the way ECMAScript formats it.
877    Number = SLOT_TYPE_NUMBER,
878    /// An array, the only type a LIST may iterate.
879    Array = SLOT_TYPE_ARRAY,
880    /// A key/value object, the only type PROP may read from.
881    Object = SLOT_TYPE_OBJECT,
882}
883
884impl SlotType {
885    /// Convert a raw byte to a `SlotType`.
886    pub fn from_byte(b: u8) -> Result<Self, IrError> {
887        match b {
888            SLOT_TYPE_TEXT => Ok(SlotType::Text),
889            SLOT_TYPE_BOOL => Ok(SlotType::Bool),
890            SLOT_TYPE_NUMBER => Ok(SlotType::Number),
891            SLOT_TYPE_ARRAY => Ok(SlotType::Array),
892            SLOT_TYPE_OBJECT => Ok(SlotType::Object),
893            _ => Err(IrError::InvalidSlotType(b)),
894        }
895    }
896}
897
898// ---------------------------------------------------------------------------
899// IslandTrigger enum (4 triggers)
900// ---------------------------------------------------------------------------
901
902/// When an island should be hydrated.
903#[derive(Debug, Clone, Copy, PartialEq, Eq)]
904#[repr(u8)]
905pub enum IslandTrigger {
906    /// Hydrate as soon as the page's JS runs.
907    Load = TRIGGER_LOAD,
908    /// Hydrate when the island scrolls into view.
909    Visible = TRIGGER_VISIBLE,
910    /// Hydrate on the first user interaction with the island.
911    Interaction = TRIGGER_INTERACTION,
912    /// Hydrate when the main thread next goes idle.
913    Idle = TRIGGER_IDLE,
914}
915
916impl IslandTrigger {
917    /// Convert a raw byte to an `IslandTrigger`.
918    pub fn from_byte(b: u8) -> Result<Self, IrError> {
919        match b {
920            TRIGGER_LOAD => Ok(IslandTrigger::Load),
921            TRIGGER_VISIBLE => Ok(IslandTrigger::Visible),
922            TRIGGER_INTERACTION => Ok(IslandTrigger::Interaction),
923            TRIGGER_IDLE => Ok(IslandTrigger::Idle),
924            _ => Err(IrError::InvalidIslandTrigger(b)),
925        }
926    }
927}
928
929// ---------------------------------------------------------------------------
930// SlotSource enum (2 sources)
931// ---------------------------------------------------------------------------
932
933/// Where the slot value originates at runtime.
934#[derive(Debug, Clone, Copy, PartialEq, Eq)]
935#[repr(u8)]
936pub enum SlotSource {
937    /// Filled by the page handler before the walk. Still `Null` at render
938    /// time means the handler probably has a bug, and the walker warns.
939    Server = SLOT_SOURCE_SERVER,
940    /// Owned by the client runtime; the slot table's default is what SSR
941    /// renders.
942    Client = SLOT_SOURCE_CLIENT,
943}
944
945impl SlotSource {
946    /// Convert a raw byte to a `SlotSource`.
947    pub fn from_byte(b: u8) -> Result<Self, IrError> {
948        match b {
949            SLOT_SOURCE_SERVER => Ok(SlotSource::Server),
950            SLOT_SOURCE_CLIENT => Ok(SlotSource::Client),
951            _ => Err(IrError::InvalidSlotSource(b)),
952        }
953    }
954}
955
956// ---------------------------------------------------------------------------
957// SlotEntry
958// ---------------------------------------------------------------------------
959
960/// A slot declaration in the slot table.
961#[derive(Debug, Clone, PartialEq, Eq)]
962pub struct SlotEntry {
963    /// Unique slot identifier within the module.
964    pub slot_id: u16,
965    /// Index into the string table for the slot name.
966    pub name_str_idx: u32,
967    /// Expected data type for this slot.
968    pub type_hint: SlotType,
969    /// Where the slot value originates at runtime.
970    pub source: SlotSource,
971    /// Default value bytes (empty if no default).
972    pub default_bytes: Vec<u8>,
973}
974
975// ---------------------------------------------------------------------------
976// IslandEntry
977// ---------------------------------------------------------------------------
978
979/// An island declaration in the island table.
980#[derive(Debug, Clone, PartialEq, Eq)]
981pub struct IslandEntry {
982    /// Unique island identifier within the module.
983    pub id: u16,
984    /// When this island should hydrate.
985    pub trigger: IslandTrigger,
986    /// Props-schema object index, or 0xffff until declared.
987    pub schema_object: u16,
988    /// Opaque digest bytes; FORMA-105 defines their use.
989    pub digest: Vec<u8>,
990    /// Unknown trailing entry fields retained for lossless dumps.
991    pub extension: Vec<u8>,
992    /// Index into the string table for the island name.
993    pub name_str_idx: u32,
994    /// Byte offset of the ISLAND_START opcode in the bytecode stream.
995    pub byte_offset: u32,
996    /// Which slots belong to this island.
997    pub slot_ids: Vec<u16>,
998}
999
1000#[cfg(test)]
1001mod compatibility_tests {
1002    use super::*;
1003    #[test]
1004    fn shared_version_pairs_pass_through_the_actual_header_reader() {
1005        let pairs: serde_json::Value = serde_json::from_str(include_str!(
1006            "../tests/fixtures/compatibility/version-pairs.json"
1007        ))
1008        .unwrap();
1009        let base = |s: &str| -> [u16; 3] {
1010            let parts: Vec<u16> = s
1011                .split('-')
1012                .next()
1013                .unwrap()
1014                .split('.')
1015                .map(|p| p.parse().unwrap())
1016                .collect();
1017            [parts[0], parts[1], parts[2]]
1018        };
1019        for pair in pairs.as_array().unwrap() {
1020            let reader = base(pair["reader"].as_str().unwrap());
1021            let writer = base(pair["writer"].as_str().unwrap());
1022            let mut bytes = crate::parser::test_helpers::build_minimal_ir(&[], &[], &[], &[]);
1023            for (i, value) in writer.iter().enumerate() {
1024                bytes[8 + i * 2..10 + i * 2].copy_from_slice(&value.to_le_bytes());
1025            }
1026            let crc = crc32(&[&bytes[..18], &bytes[22..]]);
1027            bytes[18..22].copy_from_slice(&crc.to_le_bytes());
1028            let result = IrHeader::parse_for_reader(&bytes, reader);
1029            assert_eq!(
1030                result.is_ok(),
1031                pair["accepted"].as_bool().unwrap(),
1032                "{pair}: {result:?}"
1033            );
1034            if let Err(error) = result {
1035                assert_eq!(error.code(), "LOAD-108");
1036                for version in [reader, writer] {
1037                    assert!(error
1038                        .to_string()
1039                        .contains(&format!("{}.{}.{}", version[0], version[1], version[2])));
1040                }
1041            }
1042        }
1043    }
1044}