forma_ir/format.rs
1//! FMIR binary format definition and parsers.
2//!
3//! Defines the on-disk layout for Forma Module IR files:
4//! - 16-byte header (magic, version, flags, source hash)
5//! - 32-byte section table (4 sections x 8 bytes each)
6//! - Opcode, SlotType, IslandTrigger, PropsMode 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/// Magic bytes identifying an FMIR file.
18pub const MAGIC: &[u8; 4] = b"FMIR";
19
20/// Size of the file header in bytes.
21pub const HEADER_SIZE: usize = 16;
22
23/// Size of the section table in bytes (4 sections x 8 bytes).
24pub const SECTION_TABLE_SIZE: usize = 32;
25
26/// Current IR format version.
27pub const IR_VERSION: u16 = 2;
28
29// ---------------------------------------------------------------------------
30// Little-endian reads
31// ---------------------------------------------------------------------------
32//
33// Each call site has already checked that its slice is in bounds and of the
34// width read; these index rather than `try_into().unwrap()` so the crate
35// carries no `unwrap` (DR-12). The indexing cannot fail where the slice could
36// be taken.
37
38/// A `u16` from the first 2 bytes of `b`.
39pub(crate) fn le_u16(b: &[u8]) -> u16 {
40 u16::from_le_bytes([b[0], b[1]])
41}
42
43/// A `u32` from the first 4 bytes of `b`.
44pub(crate) fn le_u32(b: &[u8]) -> u32 {
45 u32::from_le_bytes([b[0], b[1], b[2], b[3]])
46}
47
48/// A `u64` from the first 8 bytes of `b`.
49pub(crate) fn le_u64(b: &[u8]) -> u64 {
50 u64::from_le_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]])
51}
52
53// ---------------------------------------------------------------------------
54// Error type
55// ---------------------------------------------------------------------------
56
57/// Errors that can occur when parsing FMIR binary data.
58///
59/// Marked `#[non_exhaustive]`: new failure modes are added as the parser is
60/// hardened, and downstream crates must not break when that happens. Match
61/// with a `_ =>` arm (or just use `Display`).
62#[derive(Debug, Clone, PartialEq, Eq)]
63#[non_exhaustive]
64pub enum IrError {
65 /// Input buffer is too short to contain the expected structure.
66 BufferTooShort {
67 /// Bytes the structure needed.
68 expected: usize,
69 /// Bytes actually available.
70 actual: usize,
71 },
72 /// Magic bytes do not match "FMIR".
73 BadMagic([u8; 4]),
74 /// IR version is not supported.
75 UnsupportedVersion(u16),
76 /// A section extends beyond the file boundary.
77 SectionOutOfBounds {
78 /// Index of the offending section in the section table.
79 section: usize,
80 /// The section's declared byte offset.
81 offset: u32,
82 /// The section's declared byte length.
83 size: u32,
84 /// Length of the file the offsets were read from.
85 file_len: usize,
86 },
87 /// An opcode byte does not map to a known opcode.
88 InvalidOpcode(u8),
89 /// A slot-type byte does not map to a known slot type.
90 InvalidSlotType(u8),
91 /// An island-trigger byte does not map to a known trigger.
92 InvalidIslandTrigger(u8),
93 /// A props-mode byte does not map to a known mode.
94 InvalidPropsMode(u8),
95 /// A slot-source byte does not map to a known source.
96 InvalidSlotSource(u8),
97 /// A string index is out of bounds.
98 StringIndexOutOfBounds {
99 /// The index that was asked for.
100 index: u32,
101 /// Number of entries the string table actually holds.
102 len: usize,
103 },
104 /// A byte sequence is not valid UTF-8.
105 InvalidUtf8(String),
106 /// Nested LIST depth exceeded the maximum allowed.
107 ListDepthExceeded {
108 /// The nesting limit that was exceeded.
109 max: u8,
110 },
111 /// An island with the given id was not found in the island table.
112 IslandNotFound(u16),
113 /// Failed to parse JSON input.
114 JsonParseError(String),
115 /// A section descriptor has invalid values (e.g. integer overflow).
116 ///
117 /// Also returned for opcode-stream offsets that overflow or point outside
118 /// the region they were read from (branch/body lengths in SHOW_IF, SWITCH,
119 /// LIST and TRY_START/FALLBACK).
120 InvalidSection,
121 /// Recursive opcode nesting (SHOW_IF, LIST, SWITCH, etc.) exceeded the
122 /// maximum allowed depth.
123 RecursionLimitExceeded,
124 /// The rendered HTML exceeded the walker's configured output budget.
125 OutputLimitExceeded {
126 /// The byte budget that was exceeded.
127 limit: usize,
128 },
129 /// Total LIST item iterations exceeded the walker's per-render budget.
130 /// Distinct from [`IrError::ListDepthExceeded`], which bounds *nesting*.
131 ListBudgetExceeded {
132 /// The total-iteration budget that was exceeded.
133 max: usize,
134 },
135 /// The header's reserved `flags` field carried an unknown non-zero bit.
136 UnsupportedFlags(u16),
137 /// A tag or attribute name from the string table is not a legal HTML name.
138 InvalidName(String),
139 /// The slot table declares the same `slot_id` more than once.
140 DuplicateSlotId(u16),
141 /// The island table declares the same island `id` more than once.
142 DuplicateIslandId(u16),
143 /// An island's `byte_offset` does not point at an ISLAND_START opcode
144 /// carrying that island's own id.
145 IslandOffsetMismatch {
146 /// The island whose table entry is wrong.
147 island_id: u16,
148 /// The byte offset that entry declared.
149 byte_offset: u32,
150 },
151 /// A slot id referenced by an island entry or an opcode is not declared
152 /// in the slot table.
153 UnknownSlotId(u16),
154 /// The opcode stream's control-flow regions do not tile exactly: a
155 /// branch/body length does not line up with the opcodes it contains.
156 MalformedControlFlow {
157 /// Byte offset in the opcode stream where the mismatch was detected.
158 pos: usize,
159 },
160 /// An ISLAND_START opcode names an island id the island table does not
161 /// declare. Such an island emits its `<!--f:iN-->` markers but no
162 /// `data-forma-*` attributes, so the client can never hydrate it.
163 UnknownIslandId(u16),
164 /// Two ISLAND_START opcodes are pending on the same element: the outer
165 /// island's body opens no tag of its own before the inner island starts,
166 /// so only one of them can carry the `data-forma-island` attribute set and
167 /// the other would silently never hydrate.
168 NestedIslandRoot {
169 /// The island still waiting for a root element.
170 outer: u16,
171 /// The island that started before it got one.
172 inner: u16,
173 },
174 /// The walk panicked, and [`crate::walker::walk_to_html_counted`] caught
175 /// the unwind. No input is known to cause one (the parser and walker are
176 /// fuzzed), so this reports a defect in the walker, not in the module or
177 /// the data. Under `panic = "abort"` there is nothing to catch and the
178 /// process ends instead.
179 Panicked {
180 /// The panic's message, when it carried one.
181 message: String,
182 },
183}
184
185impl fmt::Display for IrError {
186 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
187 match self {
188 IrError::BufferTooShort { expected, actual } => {
189 write!(
190 f,
191 "buffer too short: expected at least {expected} bytes, got {actual}"
192 )
193 }
194 IrError::BadMagic(got) => {
195 write!(f, "bad magic: expected FMIR, got {:?}", got)
196 }
197 IrError::UnsupportedVersion(v) => {
198 write!(f, "unsupported IR version: {v} (expected {IR_VERSION})")
199 }
200 IrError::SectionOutOfBounds {
201 section,
202 offset,
203 size,
204 file_len,
205 } => {
206 write!(
207 f,
208 "section {section} out of bounds: offset={offset}, size={size}, file_len={file_len}"
209 )
210 }
211 IrError::InvalidOpcode(b) => write!(f, "invalid opcode: 0x{b:02x}"),
212 IrError::InvalidSlotType(b) => write!(f, "invalid slot type: 0x{b:02x}"),
213 IrError::InvalidIslandTrigger(b) => {
214 write!(f, "invalid island trigger: 0x{b:02x}")
215 }
216 IrError::InvalidPropsMode(b) => write!(f, "invalid props mode: 0x{b:02x}"),
217 IrError::InvalidSlotSource(b) => write!(f, "invalid slot source: 0x{b:02x}"),
218 IrError::StringIndexOutOfBounds { index, len } => {
219 write!(
220 f,
221 "string index {index} out of bounds (table has {len} entries)"
222 )
223 }
224 IrError::InvalidUtf8(msg) => write!(f, "invalid UTF-8: {msg}"),
225 IrError::ListDepthExceeded { max } => {
226 write!(f, "nested LIST depth exceeded maximum of {max}")
227 }
228 IrError::IslandNotFound(id) => {
229 write!(f, "island with id {id} not found in island table")
230 }
231 IrError::JsonParseError(msg) => {
232 write!(f, "JSON parse error: {msg}")
233 }
234 IrError::InvalidSection => {
235 write!(f, "invalid section or opcode offset (out of range)")
236 }
237 IrError::RecursionLimitExceeded => {
238 write!(f, "recursion limit exceeded (maximum depth: 64)")
239 }
240 IrError::OutputLimitExceeded { limit } => {
241 write!(f, "rendered output exceeded the limit of {limit} bytes")
242 }
243 IrError::ListBudgetExceeded { max } => {
244 write!(f, "total LIST iterations exceeded the budget of {max}")
245 }
246 IrError::UnsupportedFlags(flags) => {
247 write!(f, "unsupported header flags: 0x{flags:04x} (must be 0)")
248 }
249 IrError::InvalidName(name) => {
250 write!(f, "invalid tag or attribute name: {name:?}")
251 }
252 IrError::DuplicateSlotId(id) => {
253 write!(f, "duplicate slot id {id} in slot table")
254 }
255 IrError::DuplicateIslandId(id) => {
256 write!(f, "duplicate island id {id} in island table")
257 }
258 IrError::IslandOffsetMismatch {
259 island_id,
260 byte_offset,
261 } => {
262 write!(
263 f,
264 "island {island_id} byte_offset {byte_offset} does not point at its ISLAND_START opcode"
265 )
266 }
267 IrError::UnknownSlotId(id) => {
268 write!(f, "slot id {id} is not declared in the slot table")
269 }
270 IrError::MalformedControlFlow { pos } => {
271 write!(
272 f,
273 "malformed control flow: region lengths do not tile at byte {pos}"
274 )
275 }
276 IrError::UnknownIslandId(id) => {
277 write!(f, "island id {id} is not declared in the island table")
278 }
279 IrError::NestedIslandRoot { outer, inner } => {
280 write!(
281 f,
282 "island {inner} starts before island {outer} has opened its root element — \
283 one of them could never carry its data-forma-* attributes"
284 )
285 }
286 IrError::Panicked { message } => write!(f, "the walk panicked: {message}"),
287 }
288 }
289}
290
291impl std::error::Error for IrError {}
292
293// ---------------------------------------------------------------------------
294// Header (16 bytes)
295// ---------------------------------------------------------------------------
296
297/// FMIR file header — the first 16 bytes of every FMIR module (a `.ir` file).
298///
299/// Layout (little-endian):
300/// ```text
301/// [0..4) magic – b"FMIR"
302/// [4..6) version – u16
303/// [6..8) flags – u16 (reserved; MUST be 0 — any non-zero bit is a
304/// hard parse error, so unknown future extensions fail
305/// closed instead of being silently mis-parsed. A new
306/// flag bit therefore requires a coordinated forma-ir
307/// release before the compiler may emit it.)
308/// [8..16) source_hash – u64 (hash of original source; currently written as
309/// 0 by the JS compiler and not verified here)
310/// ```
311#[derive(Debug, Clone, Copy, PartialEq, Eq)]
312pub struct IrHeader {
313 /// FMIR format version. Always [`IR_VERSION`] for a header that parsed.
314 pub version: u16,
315 /// Reserved bit field, validated to be zero (see the layout above).
316 pub flags: u16,
317 /// Hash of the original source module. Written as 0 by the JS compiler
318 /// today and not verified here.
319 pub source_hash: u64,
320}
321
322impl IrHeader {
323 /// Parse an `IrHeader` from the first 16 bytes of `data`.
324 pub fn parse(data: &[u8]) -> Result<Self, IrError> {
325 if data.len() < HEADER_SIZE {
326 return Err(IrError::BufferTooShort {
327 expected: HEADER_SIZE,
328 actual: data.len(),
329 });
330 }
331
332 let magic: [u8; 4] = [data[0], data[1], data[2], data[3]];
333 if &magic != MAGIC {
334 return Err(IrError::BadMagic(magic));
335 }
336
337 let version = le_u16(&data[4..6]);
338 if version != IR_VERSION {
339 return Err(IrError::UnsupportedVersion(version));
340 }
341
342 // `flags` is reserved. Reject any non-zero bit so that a future
343 // extension (compression, extended section table, ...) fails closed on
344 // an old reader instead of being silently ignored and mis-parsed.
345 let flags = le_u16(&data[6..8]);
346 if flags != 0 {
347 return Err(IrError::UnsupportedFlags(flags));
348 }
349
350 let source_hash = le_u64(&data[8..16]);
351
352 Ok(IrHeader {
353 version,
354 flags,
355 source_hash,
356 })
357 }
358}
359
360// ---------------------------------------------------------------------------
361// Section table (32 bytes)
362// ---------------------------------------------------------------------------
363
364/// A single section descriptor: offset + size (both u32, little-endian).
365#[derive(Debug, Clone, Copy, PartialEq, Eq)]
366pub struct SectionDescriptor {
367 /// Byte offset of the section from the start of the file.
368 pub offset: u32,
369 /// Length of the section in bytes.
370 pub size: u32,
371}
372
373/// Section table — four section descriptors immediately following the header.
374///
375/// Sections (in order):
376/// 0. Bytecode
377/// 1. String table
378/// 2. Slot table
379/// 3. Island table
380///
381/// Layout: 4 x (offset u32 + size u32) = 32 bytes.
382#[derive(Debug, Clone, Copy, PartialEq, Eq)]
383pub struct SectionTable {
384 /// Bytecode, string table, slot table, island table — in that order.
385 pub sections: [SectionDescriptor; 4],
386}
387
388impl SectionTable {
389 /// Parse a `SectionTable` from `data` (must be at least 32 bytes).
390 pub fn parse(data: &[u8]) -> Result<Self, IrError> {
391 if data.len() < SECTION_TABLE_SIZE {
392 return Err(IrError::BufferTooShort {
393 expected: SECTION_TABLE_SIZE,
394 actual: data.len(),
395 });
396 }
397
398 let mut sections = [SectionDescriptor { offset: 0, size: 0 }; 4];
399 for (i, section) in sections.iter_mut().enumerate() {
400 let base = i * 8;
401 let offset = le_u32(&data[base..base + 4]);
402 let size = le_u32(&data[base + 4..base + 8]);
403 *section = SectionDescriptor { offset, size };
404 }
405
406 Ok(SectionTable { sections })
407 }
408
409 /// Validate that every section falls within `file_len` bytes.
410 pub fn validate(&self, file_len: usize) -> Result<(), IrError> {
411 for (i, sec) in self.sections.iter().enumerate() {
412 let end = (sec.offset as usize)
413 .checked_add(sec.size as usize)
414 .ok_or(IrError::InvalidSection)?;
415 if end > file_len {
416 return Err(IrError::SectionOutOfBounds {
417 section: i,
418 offset: sec.offset,
419 size: sec.size,
420 file_len,
421 });
422 }
423 }
424 Ok(())
425 }
426}
427
428// ---------------------------------------------------------------------------
429// Opcode enum (18 opcodes, 0x01–0x12)
430//
431// Six are decoded and walked but never emitted by @getforma/compiler today:
432// Switch, TryStart, Fallback, Preload, Comment, ListItemKey. They are reserved
433// wire space, not dead code — see docs/ARCHITECTURE.md §B1. ListItemKey in
434// particular cannot be emitted usefully as specified: its operand is a static
435// string index, so every row would carry the same key. Per-item keys need an
436// FMIR version bump.
437// ---------------------------------------------------------------------------
438
439/// Bytecode opcodes for the FMIR instruction stream.
440#[derive(Debug, Clone, Copy, PartialEq, Eq)]
441#[repr(u8)]
442pub enum Opcode {
443 /// Open an element. `str_idx(u32) + attr_count(u16) + [(key u32, val u32)]`
444 OpenTag = 0x01,
445 /// Close the named element. `str_idx(u32)`
446 CloseTag = 0x02,
447 /// A void element, which takes no CLOSE_TAG. Same operands as OPEN_TAG.
448 VoidTag = 0x03,
449 /// Static text from the string table. `str_idx(u32)`
450 Text = 0x04,
451 /// Slot-bound text wrapped in `<!--f:tN-->` markers.
452 /// `slot_id(u16) + marker_id(u16)`
453 DynText = 0x05,
454 /// Slot-bound attribute on the tag currently being opened.
455 /// `attr_str_idx(u32) + slot_id(u16)`
456 DynAttr = 0x06,
457 /// Conditional region wrapped in `<!--f:sN-->` markers.
458 /// `slot_id(u16) + then_len(u32) + else_len(u32)`, then the two bodies
459 /// separated by a [`Opcode::ShowElse`] byte.
460 ShowIf = 0x07,
461 /// Separator byte between a SHOW_IF's then- and else-bodies.
462 ShowElse = 0x08,
463 /// First-match-wins multi-way branch on a slot's text.
464 /// `slot_id(u16) + case_count(u16)`, then one
465 /// `(val_str_idx u32, body_len u32)` header per case, then the bodies.
466 Switch = 0x09,
467 /// Repeat a body once per element of an array slot, wrapped in
468 /// `<!--f:lN-->` markers. `slot_id(u16) + item_slot_id(u16) + body_len(u32)`
469 List = 0x0A,
470 /// Begin an island region. `island_id(u16)`
471 IslandStart = 0x0B,
472 /// End an island region. `island_id(u16)`
473 IslandEnd = 0x0C,
474 /// Begin a try block. `fallback_len(u32)`
475 TryStart = 0x0D,
476 /// Separator between a try block's main and fallback bodies.
477 Fallback = 0x0E,
478 /// Emit a `<link rel="preload">`. `resource_type(u8) + url_str_idx(u32)`
479 Preload = 0x0F,
480 /// An HTML comment. `str_idx(u32)`
481 Comment = 0x10,
482 /// Set `data-forma-key` on the next element opened in a LIST body.
483 /// `key_str_idx(u32)`
484 ///
485 /// LIMITATION: the operand is a *static* string-table index, so every item
486 /// in a list gets the same key. Per-item keys would need a slot-bound
487 /// operand, which is a v-bump to this opcode's layout and a coordinated
488 /// compiler change — not something the walker can work around.
489 ListItemKey = 0x11,
490 /// Extract a named property from an Object slot into a target slot.
491 /// Format: src_slot_id(u16) + prop_str_idx(u32) + target_slot_id(u16)
492 Prop = 0x12,
493}
494
495impl Opcode {
496 /// Convert a raw byte to an `Opcode`.
497 pub fn from_byte(b: u8) -> Result<Self, IrError> {
498 match b {
499 0x01 => Ok(Opcode::OpenTag),
500 0x02 => Ok(Opcode::CloseTag),
501 0x03 => Ok(Opcode::VoidTag),
502 0x04 => Ok(Opcode::Text),
503 0x05 => Ok(Opcode::DynText),
504 0x06 => Ok(Opcode::DynAttr),
505 0x07 => Ok(Opcode::ShowIf),
506 0x08 => Ok(Opcode::ShowElse),
507 0x09 => Ok(Opcode::Switch),
508 0x0A => Ok(Opcode::List),
509 0x0B => Ok(Opcode::IslandStart),
510 0x0C => Ok(Opcode::IslandEnd),
511 0x0D => Ok(Opcode::TryStart),
512 0x0E => Ok(Opcode::Fallback),
513 0x0F => Ok(Opcode::Preload),
514 0x10 => Ok(Opcode::Comment),
515 0x11 => Ok(Opcode::ListItemKey),
516 0x12 => Ok(Opcode::Prop),
517 _ => Err(IrError::InvalidOpcode(b)),
518 }
519 }
520}
521
522// ---------------------------------------------------------------------------
523// SlotType enum (5 types)
524// ---------------------------------------------------------------------------
525
526/// Data type hint for a slot entry.
527#[derive(Debug, Clone, Copy, PartialEq, Eq)]
528#[repr(u8)]
529pub enum SlotType {
530 /// A string.
531 Text = 0x01,
532 /// A boolean.
533 Bool = 0x02,
534 /// An IEEE-754 double, formatted the way ECMAScript formats it.
535 Number = 0x03,
536 /// An array, the only type a LIST may iterate.
537 Array = 0x04,
538 /// A key/value object, the only type PROP may read from.
539 Object = 0x05,
540}
541
542impl SlotType {
543 /// Convert a raw byte to a `SlotType`.
544 pub fn from_byte(b: u8) -> Result<Self, IrError> {
545 match b {
546 0x01 => Ok(SlotType::Text),
547 0x02 => Ok(SlotType::Bool),
548 0x03 => Ok(SlotType::Number),
549 0x04 => Ok(SlotType::Array),
550 0x05 => Ok(SlotType::Object),
551 _ => Err(IrError::InvalidSlotType(b)),
552 }
553 }
554}
555
556// ---------------------------------------------------------------------------
557// IslandTrigger enum (4 triggers)
558// ---------------------------------------------------------------------------
559
560/// When an island should be hydrated.
561#[derive(Debug, Clone, Copy, PartialEq, Eq)]
562#[repr(u8)]
563pub enum IslandTrigger {
564 /// Hydrate as soon as the page's JS runs.
565 Load = 0x01,
566 /// Hydrate when the island scrolls into view.
567 Visible = 0x02,
568 /// Hydrate on the first user interaction with the island.
569 Interaction = 0x03,
570 /// Hydrate when the main thread next goes idle.
571 Idle = 0x04,
572}
573
574impl IslandTrigger {
575 /// Convert a raw byte to an `IslandTrigger`.
576 pub fn from_byte(b: u8) -> Result<Self, IrError> {
577 match b {
578 0x01 => Ok(IslandTrigger::Load),
579 0x02 => Ok(IslandTrigger::Visible),
580 0x03 => Ok(IslandTrigger::Interaction),
581 0x04 => Ok(IslandTrigger::Idle),
582 _ => Err(IrError::InvalidIslandTrigger(b)),
583 }
584 }
585}
586
587// ---------------------------------------------------------------------------
588// PropsMode enum (3 modes)
589// ---------------------------------------------------------------------------
590
591/// How island props are delivered.
592#[derive(Debug, Clone, Copy, PartialEq, Eq)]
593#[repr(u8)]
594pub enum PropsMode {
595 /// Props serialized into a `data-forma-props` attribute on the island root.
596 Inline = 0x01,
597 /// Props accumulated into a shared `<script id="__forma_islands">` JSON
598 /// block at the end of the page.
599 ///
600 /// NOT YET EMITTED BY THE COMPILER — `@getforma/compiler` hardcodes
601 /// `Inline` at every `addIsland` call site, so nothing in the toolchain
602 /// produces this value today. The walker supports it because `forma-ir`
603 /// accepts FMIR from any producer, but treat it as a dormant branch rather
604 /// than a shipping feature until the compiler opts into it (the intended
605 /// trigger being props too large for an attribute).
606 ScriptTag = 0x02,
607 /// Reserved. NOT IMPLEMENTED on either side of the stack.
608 ///
609 /// The name promises props fetched by the client after hydration, but
610 /// there is no mechanism behind it: the walker emits no props and no fetch
611 /// hint (no `data-forma-props-src`, nothing), and `@getforma/core`'s
612 /// `loadIslandProps` knows only the inline attribute and the shared block.
613 /// An island declared `Deferred` is therefore indistinguishable from an
614 /// island with no props at all. Treat it as reserved wire space, not as a
615 /// feature — the compiler never emits it.
616 Deferred = 0x03,
617}
618
619impl PropsMode {
620 /// Convert a raw byte to a `PropsMode`.
621 pub fn from_byte(b: u8) -> Result<Self, IrError> {
622 match b {
623 0x01 => Ok(PropsMode::Inline),
624 0x02 => Ok(PropsMode::ScriptTag),
625 0x03 => Ok(PropsMode::Deferred),
626 _ => Err(IrError::InvalidPropsMode(b)),
627 }
628 }
629}
630
631// ---------------------------------------------------------------------------
632// SlotSource enum (2 sources)
633// ---------------------------------------------------------------------------
634
635/// Where the slot value originates at runtime.
636#[derive(Debug, Clone, Copy, PartialEq, Eq)]
637#[repr(u8)]
638pub enum SlotSource {
639 /// Filled by the page handler before the walk. Still `Null` at render
640 /// time means the handler probably has a bug, and the walker warns.
641 Server = 0x00,
642 /// Owned by the client runtime; the slot table's default is what SSR
643 /// renders.
644 Client = 0x01,
645}
646
647impl SlotSource {
648 /// Convert a raw byte to a `SlotSource`.
649 pub fn from_byte(b: u8) -> Result<Self, IrError> {
650 match b {
651 0x00 => Ok(SlotSource::Server),
652 0x01 => Ok(SlotSource::Client),
653 _ => Err(IrError::InvalidSlotSource(b)),
654 }
655 }
656}
657
658// ---------------------------------------------------------------------------
659// SlotEntry
660// ---------------------------------------------------------------------------
661
662/// A slot declaration in the slot table.
663#[derive(Debug, Clone, PartialEq, Eq)]
664pub struct SlotEntry {
665 /// Unique slot identifier within the module.
666 pub slot_id: u16,
667 /// Index into the string table for the slot name.
668 pub name_str_idx: u32,
669 /// Expected data type for this slot.
670 pub type_hint: SlotType,
671 /// Where the slot value originates at runtime.
672 pub source: SlotSource,
673 /// Default value bytes (empty if no default).
674 pub default_bytes: Vec<u8>,
675}
676
677// ---------------------------------------------------------------------------
678// IslandEntry
679// ---------------------------------------------------------------------------
680
681/// An island declaration in the island table.
682#[derive(Debug, Clone, PartialEq, Eq)]
683pub struct IslandEntry {
684 /// Unique island identifier within the module.
685 pub id: u16,
686 /// When this island should hydrate.
687 pub trigger: IslandTrigger,
688 /// How props are delivered to the island.
689 pub props_mode: PropsMode,
690 /// Index into the string table for the island name.
691 pub name_str_idx: u32,
692 /// Byte offset of the ISLAND_START opcode in the bytecode stream.
693 pub byte_offset: u32,
694 /// Which slots belong to this island.
695 pub slot_ids: Vec<u16>,
696}
697
698// ---------------------------------------------------------------------------
699// Tests
700// ---------------------------------------------------------------------------
701
702#[cfg(test)]
703mod tests {
704 use super::*;
705
706 /// Build a valid 16-byte FMIR header.
707 fn make_header(version: u16, flags: u16, source_hash: u64) -> Vec<u8> {
708 let mut buf = Vec::with_capacity(HEADER_SIZE);
709 buf.extend_from_slice(MAGIC);
710 buf.extend_from_slice(&version.to_le_bytes());
711 buf.extend_from_slice(&flags.to_le_bytes());
712 buf.extend_from_slice(&source_hash.to_le_bytes());
713 buf
714 }
715
716 #[test]
717 fn parse_valid_header() {
718 let data = make_header(2, 0, 0xDEAD_BEEF_CAFE_BABE);
719 let hdr = IrHeader::parse(&data).unwrap();
720 assert_eq!(hdr.version, 2);
721 assert_eq!(hdr.flags, 0);
722 assert_eq!(hdr.source_hash, 0xDEAD_BEEF_CAFE_BABE);
723 }
724
725 #[test]
726 fn reject_bad_magic() {
727 let mut data = make_header(2, 0, 0);
728 data[0..4].copy_from_slice(b"NOPE");
729 let err = IrHeader::parse(&data).unwrap_err();
730 assert_eq!(err, IrError::BadMagic(*b"NOPE"));
731 }
732
733 #[test]
734 fn reject_unsupported_version() {
735 let data = make_header(99, 0, 0);
736 let err = IrHeader::parse(&data).unwrap_err();
737 assert_eq!(err, IrError::UnsupportedVersion(99));
738 }
739
740 #[test]
741 fn reject_nonzero_flags() {
742 // `flags` is reserved and must be 0 — a future extension bit must fail
743 // closed on this reader rather than being silently ignored.
744 let data = make_header(2, 1, 0);
745 let err = IrHeader::parse(&data).unwrap_err();
746 assert_eq!(err, IrError::UnsupportedFlags(1));
747
748 let data = make_header(2, 0x8000, 0);
749 let err = IrHeader::parse(&data).unwrap_err();
750 assert_eq!(err, IrError::UnsupportedFlags(0x8000));
751 }
752
753 /// Build a 32-byte section table from four (offset, size) pairs.
754 fn make_section_table(sections: [(u32, u32); 4]) -> Vec<u8> {
755 let mut buf = Vec::with_capacity(SECTION_TABLE_SIZE);
756 for (offset, size) in §ions {
757 buf.extend_from_slice(&offset.to_le_bytes());
758 buf.extend_from_slice(&size.to_le_bytes());
759 }
760 buf
761 }
762
763 #[test]
764 fn parse_section_table() {
765 let data = make_section_table([
766 (48, 100), // bytecode
767 (148, 200), // string table
768 (348, 50), // slot table
769 (398, 30), // island table
770 ]);
771 let st = SectionTable::parse(&data).unwrap();
772 assert_eq!(
773 st.sections[0],
774 SectionDescriptor {
775 offset: 48,
776 size: 100
777 }
778 );
779 assert_eq!(
780 st.sections[1],
781 SectionDescriptor {
782 offset: 148,
783 size: 200
784 }
785 );
786 assert_eq!(
787 st.sections[2],
788 SectionDescriptor {
789 offset: 348,
790 size: 50
791 }
792 );
793 assert_eq!(
794 st.sections[3],
795 SectionDescriptor {
796 offset: 398,
797 size: 30
798 }
799 );
800 }
801
802 #[test]
803 fn validate_section_bounds() {
804 let data = make_section_table([
805 (48, 100),
806 (148, 200),
807 (348, 50),
808 (398, 9999), // way past end
809 ]);
810 let st = SectionTable::parse(&data).unwrap();
811 let err = st.validate(500).unwrap_err();
812 assert_eq!(
813 err,
814 IrError::SectionOutOfBounds {
815 section: 3,
816 offset: 398,
817 size: 9999,
818 file_len: 500,
819 }
820 );
821 }
822
823 #[test]
824 fn opcode_from_byte_all_valid() {
825 let expected = [
826 (0x01, Opcode::OpenTag),
827 (0x02, Opcode::CloseTag),
828 (0x03, Opcode::VoidTag),
829 (0x04, Opcode::Text),
830 (0x05, Opcode::DynText),
831 (0x06, Opcode::DynAttr),
832 (0x07, Opcode::ShowIf),
833 (0x08, Opcode::ShowElse),
834 (0x09, Opcode::Switch),
835 (0x0A, Opcode::List),
836 (0x0B, Opcode::IslandStart),
837 (0x0C, Opcode::IslandEnd),
838 (0x0D, Opcode::TryStart),
839 (0x0E, Opcode::Fallback),
840 (0x0F, Opcode::Preload),
841 (0x10, Opcode::Comment),
842 (0x11, Opcode::ListItemKey),
843 (0x12, Opcode::Prop),
844 ];
845 for (byte, op) in &expected {
846 assert_eq!(Opcode::from_byte(*byte).unwrap(), *op, "byte 0x{byte:02x}");
847 }
848 }
849
850 #[test]
851 fn opcode_from_byte_invalid() {
852 assert_eq!(
853 Opcode::from_byte(0x00).unwrap_err(),
854 IrError::InvalidOpcode(0x00)
855 );
856 assert_eq!(
857 Opcode::from_byte(0x13).unwrap_err(),
858 IrError::InvalidOpcode(0x13)
859 );
860 assert_eq!(
861 Opcode::from_byte(0xFF).unwrap_err(),
862 IrError::InvalidOpcode(0xFF)
863 );
864 }
865
866 #[test]
867 fn slot_type_from_byte() {
868 let expected = [
869 (0x01, SlotType::Text),
870 (0x02, SlotType::Bool),
871 (0x03, SlotType::Number),
872 (0x04, SlotType::Array),
873 (0x05, SlotType::Object),
874 ];
875 for (byte, st) in &expected {
876 assert_eq!(
877 SlotType::from_byte(*byte).unwrap(),
878 *st,
879 "byte 0x{byte:02x}"
880 );
881 }
882 assert_eq!(
883 SlotType::from_byte(0x00).unwrap_err(),
884 IrError::InvalidSlotType(0x00)
885 );
886 assert_eq!(
887 SlotType::from_byte(0x06).unwrap_err(),
888 IrError::InvalidSlotType(0x06)
889 );
890 }
891
892 #[test]
893 fn island_trigger_from_byte() {
894 let expected = [
895 (0x01, IslandTrigger::Load),
896 (0x02, IslandTrigger::Visible),
897 (0x03, IslandTrigger::Interaction),
898 (0x04, IslandTrigger::Idle),
899 ];
900 for (byte, trigger) in &expected {
901 assert_eq!(
902 IslandTrigger::from_byte(*byte).unwrap(),
903 *trigger,
904 "byte 0x{byte:02x}"
905 );
906 }
907 assert_eq!(
908 IslandTrigger::from_byte(0x00).unwrap_err(),
909 IrError::InvalidIslandTrigger(0x00)
910 );
911 assert_eq!(
912 IslandTrigger::from_byte(0x05).unwrap_err(),
913 IrError::InvalidIslandTrigger(0x05)
914 );
915 }
916
917 #[test]
918 fn props_mode_from_byte() {
919 let expected = [
920 (0x01, PropsMode::Inline),
921 (0x02, PropsMode::ScriptTag),
922 (0x03, PropsMode::Deferred),
923 ];
924 for (byte, mode) in &expected {
925 assert_eq!(
926 PropsMode::from_byte(*byte).unwrap(),
927 *mode,
928 "byte 0x{byte:02x}"
929 );
930 }
931 assert_eq!(
932 PropsMode::from_byte(0x00).unwrap_err(),
933 IrError::InvalidPropsMode(0x00)
934 );
935 assert_eq!(
936 PropsMode::from_byte(0x04).unwrap_err(),
937 IrError::InvalidPropsMode(0x04)
938 );
939 }
940
941 #[test]
942 fn slot_source_from_byte() {
943 assert_eq!(SlotSource::from_byte(0x00).unwrap(), SlotSource::Server);
944 assert_eq!(SlotSource::from_byte(0x01).unwrap(), SlotSource::Client);
945 assert_eq!(
946 SlotSource::from_byte(0x02).unwrap_err(),
947 IrError::InvalidSlotSource(0x02)
948 );
949 assert_eq!(
950 SlotSource::from_byte(0xFF).unwrap_err(),
951 IrError::InvalidSlotSource(0xFF)
952 );
953 }
954}