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/// Errors that can occur when parsing FMIR binary data.
111///
112/// Marked `#[non_exhaustive]`: new failure modes are added as the parser is
113/// hardened, and downstream crates must not break when that happens. Match
114/// with a `_ =>` arm (or just use `Display`).
115#[derive(Debug, Clone, PartialEq, Eq)]
116#[non_exhaustive]
117pub enum IrError {
118    /// Input buffer is too short to contain the expected structure.
119    BufferTooShort {
120        /// Bytes the structure needed.
121        expected: usize,
122        /// Bytes actually available.
123        actual: usize,
124    },
125    /// Signature is not the FMIR v3 signature.
126    BadSignature([u8; 8]),
127    /// A legacy FMIR header needs a rebuild.
128    LegacyModule {
129        /// Legacy header version.
130        version: u16,
131    },
132    /// Writer base version differs from the reader's Cargo version.
133    UnsupportedWriter {
134        /// Writer major/minor/patch.
135        writer: [u16; 3],
136        /// Reader major/minor/patch.
137        reader: [u16; 3],
138    },
139    /// Declared body length differs from the actual file.
140    LengthMismatch {
141        /// Declared body bytes.
142        declared: usize,
143        /// Actual body bytes.
144        actual: usize,
145    },
146    /// CRC-32 does not match the file excluding its CRC field.
147    ChecksumMismatch {
148        /// Stored checksum.
149        expected: u32,
150        /// Computed checksum.
151        computed: u32,
152    },
153    /// Reserved bits must be zero.
154    ReservedBits {
155        /// Field that carried the bits.
156        field: &'static str,
157    },
158    /// Section ids must be strictly ascending and unique.
159    DuplicateSection(u16),
160    /// Section offsets must cover exactly the data after the table.
161    SectionsDoNotTile,
162    /// A required section was absent.
163    MissingSection(u16),
164    /// An unknown section was marked required.
165    UnknownRequiredSection {
166        /// Section identifier.
167        id: u16,
168        /// Writer base version.
169        writer: [u16; 3],
170    },
171    /// An input-controlled size or count exceeds its ceiling.
172    LimitExceeded(Box<LimitViolation>),
173    /// Known sections must consume their complete payload.
174    TrailingBytes {
175        /// Section identifier.
176        section: u16,
177    },
178    /// Invalid props schema type or reference.
179    InvalidSchema(&'static str),
180    /// Invalid key-path entry.
181    InvalidKeyPath(&'static str),
182    /// Invalid source-position entry.
183    InvalidSourcePosition(&'static str),
184    /// A slot default does not parse for its declared type.
185    InvalidDefault(u16),
186    /// A section extends beyond the file boundary.
187    SectionOutOfBounds {
188        /// Index of the offending section in the section table.
189        section: usize,
190        /// The section's declared byte offset.
191        offset: u32,
192        /// The section's declared byte length.
193        size: u32,
194        /// Length of the file the offsets were read from.
195        file_len: usize,
196    },
197    /// An opcode byte does not map to a known opcode.
198    InvalidOpcode(u8),
199    /// A slot-type byte does not map to a known slot type.
200    InvalidSlotType(u8),
201    /// An island-trigger byte does not map to a known trigger.
202    InvalidIslandTrigger(u8),
203    /// A slot-source byte does not map to a known source.
204    InvalidSlotSource(u8),
205    /// A string index is out of bounds.
206    StringIndexOutOfBounds {
207        /// The index that was asked for.
208        index: u32,
209        /// Number of entries the string table actually holds.
210        len: usize,
211    },
212    /// A byte sequence is not valid UTF-8.
213    InvalidUtf8(String),
214    /// Nested LIST depth exceeded the maximum allowed.
215    ListDepthExceeded {
216        /// The nesting limit that was exceeded.
217        max: u8,
218    },
219    /// An island with the given id was not found in the island table.
220    IslandNotFound(u16),
221    /// Failed to parse JSON input.
222    JsonParseError(String),
223    /// A section descriptor has invalid values (e.g. integer overflow).
224    ///
225    /// Also returned for opcode-stream offsets that overflow or point outside
226    /// the region they were read from (branch/body lengths in SHOW_IF, SWITCH,
227    /// LIST and TRY_START/FALLBACK).
228    InvalidSection,
229    /// Recursive opcode nesting (SHOW_IF, LIST, SWITCH, etc.) exceeded the
230    /// maximum allowed depth.
231    RecursionLimitExceeded,
232    /// The rendered HTML exceeded the walker's configured output budget.
233    OutputLimitExceeded {
234        /// The byte budget that was exceeded.
235        limit: usize,
236    },
237    /// Total LIST item iterations exceeded the walker's per-render budget.
238    /// Distinct from [`IrError::ListDepthExceeded`], which bounds *nesting*.
239    ListBudgetExceeded {
240        /// The total-iteration budget that was exceeded.
241        max: usize,
242    },
243    /// A tag or attribute name from the string table is not a legal HTML name.
244    InvalidName(String),
245    /// The slot table declares the same `slot_id` more than once.
246    DuplicateSlotId(u16),
247    /// The island table declares the same island `id` more than once.
248    DuplicateIslandId(u16),
249    /// An island's `byte_offset` does not point at an ISLAND_START opcode
250    /// carrying that island's own id.
251    IslandOffsetMismatch {
252        /// The island whose table entry is wrong.
253        island_id: u16,
254        /// The byte offset that entry declared.
255        byte_offset: u32,
256    },
257    /// A slot id referenced by an island entry or an opcode is not declared
258    /// in the slot table.
259    UnknownSlotId(u16),
260    /// The opcode stream's control-flow regions do not tile exactly: a
261    /// branch/body length does not line up with the opcodes it contains.
262    MalformedControlFlow {
263        /// Byte offset in the opcode stream where the mismatch was detected.
264        pos: usize,
265    },
266    /// An ISLAND_START opcode names an island id the island table does not
267    /// declare. Such an island emits its `<!--f:iN-->` markers but no
268    /// `data-forma-*` attributes, so the client can never hydrate it.
269    UnknownIslandId(u16),
270    /// Two ISLAND_START opcodes are pending on the same element: the outer
271    /// island's body opens no tag of its own before the inner island starts,
272    /// so only one of them can carry the `data-forma-island` attribute set and
273    /// the other would silently never hydrate.
274    NestedIslandRoot {
275        /// The island still waiting for a root element.
276        outer: u16,
277        /// The island that started before it got one.
278        inner: u16,
279    },
280    /// The walk panicked, and [`crate::walker::walk_to_html_counted`] caught
281    /// the unwind. This reports a defect in the walker, not in the module or
282    /// the data. Under `panic = "abort"` there is nothing to catch and the
283    /// process ends instead.
284    Panicked {
285        /// The panic's message, when it carried one.
286        message: String,
287    },
288}
289
290impl fmt::Display for IrError {
291    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
292        match self {
293            IrError::BufferTooShort { expected, actual } => {
294                write!(
295                    f,
296                    "buffer too short: expected at least {expected} bytes, got {actual}"
297                )
298            }
299            IrError::BadSignature(got) => write!(f, "bad signature: {got:02x?}"),
300            IrError::LegacyModule { version } => write!(
301                f,
302                "an FMIR module before v3 (header version {version}): rebuild with a 3.x compiler"
303            ),
304            IrError::UnsupportedWriter { writer, reader } => write!(
305                f,
306                "writer {}.{}.{} is incompatible with reader {}.{}.{}",
307                writer[0], writer[1], writer[2], reader[0], reader[1], reader[2]
308            ),
309            IrError::LengthMismatch { declared, actual } => write!(
310                f,
311                "length mismatch: declared body {declared}, actual {actual}"
312            ),
313            IrError::ChecksumMismatch { expected, computed } => write!(
314                f,
315                "checksum mismatch: expected {expected:08x}, computed {computed:08x}"
316            ),
317            IrError::ReservedBits { field } => write!(f, "reserved bits in {field}"),
318            IrError::DuplicateSection(id) => write!(f, "duplicate or unordered section 0x{id:04x}"),
319            IrError::SectionsDoNotTile => write!(f, "sections do not tile the file"),
320            IrError::MissingSection(id) => write!(f, "missing required section 0x{id:04x}"),
321            IrError::UnknownRequiredSection { id, writer } => {
322                let reader = reader_version();
323                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])
324            }
325            IrError::LimitExceeded(details) => {
326                let LimitViolation { limit, max, actual } = details.as_ref();
327                write!(f, "{limit} exceeds limit {max}: {actual}")
328            }
329            IrError::TrailingBytes { section } => {
330                write!(f, "trailing bytes in section 0x{section:04x}")
331            }
332            IrError::InvalidSchema(reason) => write!(f, "invalid props schema: {reason}"),
333            IrError::InvalidKeyPath(reason) => write!(f, "invalid key path: {reason}"),
334            IrError::InvalidSourcePosition(reason) => {
335                write!(f, "invalid source position: {reason}")
336            }
337            IrError::InvalidDefault(id) => {
338                write!(f, "default for slot {id} does not parse for its type")
339            }
340            IrError::SectionOutOfBounds {
341                section,
342                offset,
343                size,
344                file_len,
345            } => {
346                write!(
347                    f,
348                    "section {section} out of bounds: offset={offset}, size={size}, file_len={file_len}"
349                )
350            }
351            IrError::InvalidOpcode(b) => write!(f, "invalid opcode: 0x{b:02x}"),
352            IrError::InvalidSlotType(b) => write!(f, "invalid slot type: 0x{b:02x}"),
353            IrError::InvalidIslandTrigger(b) => {
354                write!(f, "invalid island trigger: 0x{b:02x}")
355            }
356            IrError::InvalidSlotSource(b) => write!(f, "invalid slot source: 0x{b:02x}"),
357            IrError::StringIndexOutOfBounds { index, len } => {
358                write!(
359                    f,
360                    "string index {index} out of bounds (table has {len} entries)"
361                )
362            }
363            IrError::InvalidUtf8(msg) => write!(f, "invalid UTF-8: {msg}"),
364            IrError::ListDepthExceeded { max } => {
365                write!(f, "nested LIST depth exceeded maximum of {max}")
366            }
367            IrError::IslandNotFound(id) => {
368                write!(f, "island with id {id} not found in island table")
369            }
370            IrError::JsonParseError(msg) => {
371                write!(f, "JSON parse error: {msg}")
372            }
373            IrError::InvalidSection => {
374                write!(f, "invalid section or opcode offset (out of range)")
375            }
376            IrError::RecursionLimitExceeded => {
377                write!(f, "recursion limit exceeded (maximum depth: 64)")
378            }
379            IrError::OutputLimitExceeded { limit } => {
380                write!(f, "rendered output exceeded the limit of {limit} bytes")
381            }
382            IrError::ListBudgetExceeded { max } => {
383                write!(f, "total LIST iterations exceeded the budget of {max}")
384            }
385            IrError::InvalidName(name) => {
386                write!(f, "invalid tag or attribute name: {name:?}")
387            }
388            IrError::DuplicateSlotId(id) => {
389                write!(f, "duplicate slot id {id} in slot table")
390            }
391            IrError::DuplicateIslandId(id) => {
392                write!(f, "duplicate island id {id} in island table")
393            }
394            IrError::IslandOffsetMismatch {
395                island_id,
396                byte_offset,
397            } => {
398                write!(
399                    f,
400                    "island {island_id} byte_offset {byte_offset} does not point at its ISLAND_START opcode"
401                )
402            }
403            IrError::UnknownSlotId(id) => {
404                write!(f, "slot id {id} is not declared in the slot table")
405            }
406            IrError::MalformedControlFlow { pos } => {
407                write!(
408                    f,
409                    "malformed control flow: region lengths do not tile at byte {pos}"
410                )
411            }
412            IrError::UnknownIslandId(id) => {
413                write!(f, "island id {id} is not declared in the island table")
414            }
415            IrError::NestedIslandRoot { outer, inner } => {
416                write!(
417                    f,
418                    "island {inner} starts before island {outer} has opened its root element — \
419                     one of them could never carry its data-forma-* attributes"
420                )
421            }
422            IrError::Panicked { message } => write!(f, "the walk panicked: {message}"),
423        }
424    }
425}
426
427impl IrError {
428    /// Stable loader diagnostic code, shared with forma-server.
429    pub fn code(&self) -> &'static str {
430        match self {
431            Self::BadSignature(_) | Self::LegacyModule { .. } => LOAD_102,
432            Self::LengthMismatch { .. } | Self::ChecksumMismatch { .. } => LOAD_104,
433            Self::MissingSection(_) => LOAD_105,
434            Self::UnknownRequiredSection { .. } => LOAD_106,
435            Self::LimitExceeded(_) => LOAD_107,
436            Self::UnsupportedWriter { .. } => LOAD_108,
437            _ => LOAD_103,
438        }
439    }
440}
441impl std::error::Error for IrError {}
442
443/// Validated FMIR v3 header (24 bytes).
444#[derive(Debug, Clone, Copy, PartialEq, Eq)]
445pub struct IrHeader {
446    /// Writer's base semantic version.
447    pub writer: [u16; 3],
448    /// Bytes after the header.
449    pub body_len: u32,
450    /// CRC-32 of the file excluding bytes 18 through 21.
451    pub crc: u32,
452}
453
454impl IrHeader {
455    /// Validate a complete file's header, including exact length and CRC, before section parsing.
456    pub fn parse(data: &[u8]) -> Result<Self, IrError> {
457        Self::parse_for_reader(data, reader_version())
458    }
459
460    // Private: production readers always use their own Cargo package version.
461    fn parse_for_reader(data: &[u8], reader: [u16; 3]) -> Result<Self, IrError> {
462        limit("module bytes", data.len(), MAX_MODULE_SIZE)?;
463        if data.len() < HEADER_SIZE {
464            return Err(IrError::BufferTooShort {
465                expected: HEADER_SIZE,
466                actual: data.len(),
467            });
468        }
469        if data[..8] != SIGNATURE {
470            if data[..4] == *b"FMIR" {
471                return Err(IrError::LegacyModule {
472                    version: le_u16(&data[4..6]),
473                });
474            }
475            let mut got = [0; 8];
476            got.copy_from_slice(&data[..8]);
477            return Err(IrError::BadSignature(got));
478        }
479        let writer = [
480            le_u16(&data[8..10]),
481            le_u16(&data[10..12]),
482            le_u16(&data[12..14]),
483        ];
484        if !accepts_writer(reader, writer) {
485            return Err(IrError::UnsupportedWriter { writer, reader });
486        }
487        let body_len = le_u32(&data[14..18]);
488        let actual = data.len() - HEADER_SIZE;
489        if body_len as usize != actual {
490            return Err(IrError::LengthMismatch {
491                declared: body_len as usize,
492                actual,
493            });
494        }
495        let crc = le_u32(&data[18..22]);
496        let computed = crc32(&[&data[..18], &data[22..]]);
497        if computed != crc {
498            return Err(IrError::ChecksumMismatch {
499                expected: crc,
500                computed,
501            });
502        }
503        if data[22..24] != [0, 0] {
504            return Err(IrError::ReservedBits { field: "header" });
505        }
506        Ok(Self {
507            writer,
508            body_len,
509            crc,
510        })
511    }
512}
513
514/// A 12-byte section descriptor.
515#[derive(Debug, Clone, Copy, PartialEq, Eq)]
516pub struct SectionDescriptor {
517    /// Section kind, unique and ascending.
518    pub id: u16,
519    /// Bit 0: required; all other bits reserved.
520    pub flags: u16,
521    /// Absolute file offset.
522    pub offset: u32,
523    /// Payload length.
524    pub size: u32,
525}
526
527/// Validated variable-length section table following the header.
528#[derive(Debug, Clone, PartialEq, Eq)]
529pub struct SectionTable {
530    /// Descriptors in file order.
531    pub sections: Vec<SectionDescriptor>,
532}
533impl SectionTable {
534    /// Parse the body prefix; the caller has already checked header integrity.
535    pub fn parse(data: &[u8]) -> Result<Self, IrError> {
536        if data.len() < 2 {
537            return Err(IrError::BufferTooShort {
538                expected: 2,
539                actual: data.len(),
540            });
541        }
542        let count = le_u16(data) as usize;
543        limit("sections", count, MAX_SECTIONS)?;
544        let end = TABLE_PREFIX_SIZE + count * SECTION_ENTRY_SIZE;
545        if end > data.len() {
546            return Err(IrError::BufferTooShort {
547                expected: end,
548                actual: data.len(),
549            });
550        }
551        if le_u16(&data[2..4]) != 0 {
552            return Err(IrError::ReservedBits {
553                field: "section table",
554            });
555        }
556        let mut sections = Vec::with_capacity(count);
557        let mut previous = None;
558        for entry in data[TABLE_PREFIX_SIZE..end].chunks_exact(SECTION_ENTRY_SIZE) {
559            let id = le_u16(entry);
560            let flags = le_u16(&entry[2..]);
561            if previous.is_some_and(|p| id <= p) {
562                return Err(IrError::DuplicateSection(id));
563            }
564            if flags & !SECTION_REQUIRED != 0 {
565                return Err(IrError::ReservedBits {
566                    field: "section flags",
567                });
568            }
569            sections.push(SectionDescriptor {
570                id,
571                flags,
572                offset: le_u32(&entry[4..]),
573                size: le_u32(&entry[8..]),
574            });
575            previous = Some(id);
576        }
577        Ok(Self { sections })
578    }
579    /// Require exact tiling, including unknown optional payloads.
580    pub fn validate(&self, file_len: usize) -> Result<(), IrError> {
581        let mut expected =
582            HEADER_SIZE + TABLE_PREFIX_SIZE + self.sections.len() * SECTION_ENTRY_SIZE;
583        for section in &self.sections {
584            if section.offset as usize != expected {
585                return Err(IrError::SectionsDoNotTile);
586            }
587            expected = expected
588                .checked_add(section.size as usize)
589                .ok_or(IrError::InvalidSection)?;
590            if expected > file_len {
591                return Err(IrError::SectionsDoNotTile);
592            }
593        }
594        if expected != file_len {
595            return Err(IrError::SectionsDoNotTile);
596        }
597        Ok(())
598    }
599    /// Find a descriptor by id.
600    pub fn get(&self, id: u16) -> Option<&SectionDescriptor> {
601        self.sections.iter().find(|s| s.id == id)
602    }
603}
604
605// ---------------------------------------------------------------------------
606// Opcode enum (12 active opcodes; six reserved v2 values)
607//
608// Six unused v2 values remain reserved forever: Switch, TryStart, Fallback,
609// Preload, Comment and ListItemKey. From v3 they are refused at load and have
610// no walker arms. Key paths use a separate typed section (FORMA-181).
611// ---------------------------------------------------------------------------
612
613/// Bytecode opcodes for the FMIR instruction stream.
614#[derive(Debug, Clone, Copy, PartialEq, Eq)]
615#[repr(u8)]
616pub enum Opcode {
617    /// Open an element. `str_idx(u32) + attr_count(u16) + [(key u32, val u32)]`
618    OpenTag = OP_OPEN_TAG,
619    /// Close the named element. `str_idx(u32)`
620    CloseTag = OP_CLOSE_TAG,
621    /// A void element, which takes no CLOSE_TAG. Same operands as OPEN_TAG.
622    VoidTag = OP_VOID_TAG,
623    /// Static text from the string table. `str_idx(u32)`
624    Text = OP_TEXT,
625    /// Slot-bound text wrapped in `<!--f:tN-->` markers.
626    /// `slot_id(u16) + marker_id(u16)`
627    DynText = OP_DYN_TEXT,
628    /// Slot-bound attribute on the tag currently being opened.
629    /// `attr_str_idx(u32) + slot_id(u16)`
630    DynAttr = OP_DYN_ATTR,
631    /// Conditional region wrapped in `<!--f:sN-->` markers.
632    /// `slot_id(u16) + then_len(u32) + else_len(u32)`, then the two bodies
633    /// separated by a [`Opcode::ShowElse`] byte.
634    ShowIf = OP_SHOW_IF,
635    /// Separator byte between a SHOW_IF's then- and else-bodies.
636    ShowElse = OP_SHOW_ELSE,
637    /// Repeat a body once per element of an array slot, wrapped in
638    /// `<!--f:lN-->` markers. `slot_id(u16) + item_slot_id(u16) + body_len(u32)`
639    List = OP_LIST,
640    /// Begin an island region. `island_id(u16)`
641    IslandStart = OP_ISLAND_START,
642    /// End an island region. `island_id(u16)`
643    IslandEnd = OP_ISLAND_END,
644    /// Extract a named property from an Object slot into a target slot.
645    /// Format: src_slot_id(u16) + prop_str_idx(u32) + target_slot_id(u16)
646    Prop = OP_PROP,
647}
648
649impl Opcode {
650    /// Convert a raw byte to an `Opcode`.
651    pub fn from_byte(b: u8) -> Result<Self, IrError> {
652        match b {
653            OP_OPEN_TAG => Ok(Opcode::OpenTag),
654            OP_CLOSE_TAG => Ok(Opcode::CloseTag),
655            OP_VOID_TAG => Ok(Opcode::VoidTag),
656            OP_TEXT => Ok(Opcode::Text),
657            OP_DYN_TEXT => Ok(Opcode::DynText),
658            OP_DYN_ATTR => Ok(Opcode::DynAttr),
659            OP_SHOW_IF => Ok(Opcode::ShowIf),
660            OP_SHOW_ELSE => Ok(Opcode::ShowElse),
661            OP_LIST => Ok(Opcode::List),
662            OP_ISLAND_START => Ok(Opcode::IslandStart),
663            OP_ISLAND_END => Ok(Opcode::IslandEnd),
664            OP_PROP => Ok(Opcode::Prop),
665            _ => Err(IrError::InvalidOpcode(b)),
666        }
667    }
668}
669
670// ---------------------------------------------------------------------------
671// SlotType enum (5 types)
672// ---------------------------------------------------------------------------
673
674/// Data type hint for a slot entry.
675#[derive(Debug, Clone, Copy, PartialEq, Eq)]
676#[repr(u8)]
677pub enum SlotType {
678    /// A string.
679    Text = SLOT_TYPE_TEXT,
680    /// A boolean.
681    Bool = SLOT_TYPE_BOOL,
682    /// An IEEE-754 double, formatted the way ECMAScript formats it.
683    Number = SLOT_TYPE_NUMBER,
684    /// An array, the only type a LIST may iterate.
685    Array = SLOT_TYPE_ARRAY,
686    /// A key/value object, the only type PROP may read from.
687    Object = SLOT_TYPE_OBJECT,
688}
689
690impl SlotType {
691    /// Convert a raw byte to a `SlotType`.
692    pub fn from_byte(b: u8) -> Result<Self, IrError> {
693        match b {
694            SLOT_TYPE_TEXT => Ok(SlotType::Text),
695            SLOT_TYPE_BOOL => Ok(SlotType::Bool),
696            SLOT_TYPE_NUMBER => Ok(SlotType::Number),
697            SLOT_TYPE_ARRAY => Ok(SlotType::Array),
698            SLOT_TYPE_OBJECT => Ok(SlotType::Object),
699            _ => Err(IrError::InvalidSlotType(b)),
700        }
701    }
702}
703
704// ---------------------------------------------------------------------------
705// IslandTrigger enum (4 triggers)
706// ---------------------------------------------------------------------------
707
708/// When an island should be hydrated.
709#[derive(Debug, Clone, Copy, PartialEq, Eq)]
710#[repr(u8)]
711pub enum IslandTrigger {
712    /// Hydrate as soon as the page's JS runs.
713    Load = TRIGGER_LOAD,
714    /// Hydrate when the island scrolls into view.
715    Visible = TRIGGER_VISIBLE,
716    /// Hydrate on the first user interaction with the island.
717    Interaction = TRIGGER_INTERACTION,
718    /// Hydrate when the main thread next goes idle.
719    Idle = TRIGGER_IDLE,
720}
721
722impl IslandTrigger {
723    /// Convert a raw byte to an `IslandTrigger`.
724    pub fn from_byte(b: u8) -> Result<Self, IrError> {
725        match b {
726            TRIGGER_LOAD => Ok(IslandTrigger::Load),
727            TRIGGER_VISIBLE => Ok(IslandTrigger::Visible),
728            TRIGGER_INTERACTION => Ok(IslandTrigger::Interaction),
729            TRIGGER_IDLE => Ok(IslandTrigger::Idle),
730            _ => Err(IrError::InvalidIslandTrigger(b)),
731        }
732    }
733}
734
735// ---------------------------------------------------------------------------
736// SlotSource enum (2 sources)
737// ---------------------------------------------------------------------------
738
739/// Where the slot value originates at runtime.
740#[derive(Debug, Clone, Copy, PartialEq, Eq)]
741#[repr(u8)]
742pub enum SlotSource {
743    /// Filled by the page handler before the walk. Still `Null` at render
744    /// time means the handler probably has a bug, and the walker warns.
745    Server = SLOT_SOURCE_SERVER,
746    /// Owned by the client runtime; the slot table's default is what SSR
747    /// renders.
748    Client = SLOT_SOURCE_CLIENT,
749}
750
751impl SlotSource {
752    /// Convert a raw byte to a `SlotSource`.
753    pub fn from_byte(b: u8) -> Result<Self, IrError> {
754        match b {
755            SLOT_SOURCE_SERVER => Ok(SlotSource::Server),
756            SLOT_SOURCE_CLIENT => Ok(SlotSource::Client),
757            _ => Err(IrError::InvalidSlotSource(b)),
758        }
759    }
760}
761
762// ---------------------------------------------------------------------------
763// SlotEntry
764// ---------------------------------------------------------------------------
765
766/// A slot declaration in the slot table.
767#[derive(Debug, Clone, PartialEq, Eq)]
768pub struct SlotEntry {
769    /// Unique slot identifier within the module.
770    pub slot_id: u16,
771    /// Index into the string table for the slot name.
772    pub name_str_idx: u32,
773    /// Expected data type for this slot.
774    pub type_hint: SlotType,
775    /// Where the slot value originates at runtime.
776    pub source: SlotSource,
777    /// Default value bytes (empty if no default).
778    pub default_bytes: Vec<u8>,
779}
780
781// ---------------------------------------------------------------------------
782// IslandEntry
783// ---------------------------------------------------------------------------
784
785/// An island declaration in the island table.
786#[derive(Debug, Clone, PartialEq, Eq)]
787pub struct IslandEntry {
788    /// Unique island identifier within the module.
789    pub id: u16,
790    /// When this island should hydrate.
791    pub trigger: IslandTrigger,
792    /// Props-schema object index, or 0xffff until declared.
793    pub schema_object: u16,
794    /// Opaque digest bytes; FORMA-105 defines their use.
795    pub digest: Vec<u8>,
796    /// Unknown trailing entry fields retained for lossless dumps.
797    pub extension: Vec<u8>,
798    /// Index into the string table for the island name.
799    pub name_str_idx: u32,
800    /// Byte offset of the ISLAND_START opcode in the bytecode stream.
801    pub byte_offset: u32,
802    /// Which slots belong to this island.
803    pub slot_ids: Vec<u16>,
804}
805
806#[cfg(test)]
807mod compatibility_tests {
808    use super::*;
809    #[test]
810    fn shared_version_pairs_pass_through_the_actual_header_reader() {
811        let pairs: serde_json::Value = serde_json::from_str(include_str!(
812            "../tests/fixtures/compatibility/version-pairs.json"
813        ))
814        .unwrap();
815        let base = |s: &str| -> [u16; 3] {
816            let parts: Vec<u16> = s
817                .split('-')
818                .next()
819                .unwrap()
820                .split('.')
821                .map(|p| p.parse().unwrap())
822                .collect();
823            [parts[0], parts[1], parts[2]]
824        };
825        for pair in pairs.as_array().unwrap() {
826            let reader = base(pair["reader"].as_str().unwrap());
827            let writer = base(pair["writer"].as_str().unwrap());
828            let mut bytes = crate::parser::test_helpers::build_minimal_ir(&[], &[], &[], &[]);
829            for (i, value) in writer.iter().enumerate() {
830                bytes[8 + i * 2..10 + i * 2].copy_from_slice(&value.to_le_bytes());
831            }
832            let crc = crc32(&[&bytes[..18], &bytes[22..]]);
833            bytes[18..22].copy_from_slice(&crc.to_le_bytes());
834            let result = IrHeader::parse_for_reader(&bytes, reader);
835            assert_eq!(
836                result.is_ok(),
837                pair["accepted"].as_bool().unwrap(),
838                "{pair}: {result:?}"
839            );
840            if let Err(error) = result {
841                assert_eq!(error.code(), "LOAD-108");
842                for version in [reader, writer] {
843                    assert!(error
844                        .to_string()
845                        .contains(&format!("{}.{}.{}", version[0], version[1], version[2])));
846                }
847            }
848        }
849    }
850}