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}