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