lumen_ir/artifact.rs
1//! AOT compiled-app artifact - the on-disk product of `lumenc build`.
2//!
3//! Today `lumenc run` parses `main.lmn` + `main.css` from source on every
4//! launch. The AOT model resolves that work **once** at build time and bakes
5//! the result - a fully cascaded [`LayoutIR`] plus the combined script
6//! source - into a compact binary blob the runtime deserializes directly,
7//! with the markup parser removed from the shipped binary (see the
8//! `runtime-parse` cargo feature).
9//!
10//! This mirrors how other toolkits ship compiled UI:
11//!
12//! - **Qt** precompiles `.qml` into bytecode via `qmlcachegen` / the QML disk
13//! cache and bakes it into the binary, so the QML *parser* need not run (or
14//! even ship) at launch. This module is the direct analogue: the artifact
15//! is Lumen's "QML cache".
16//! - **Slint** compiles `.slint` to Rust at build time. We keep Lumen's model
17//! data-driven rather than codegen (an artifact, not generated Rust) so the
18//! same runtime consumes it - but the "resolve markup once at build time"
19//! principle is identical.
20//!
21//! Where these conflict with Lumen philosophy, philosophy wins: the artifact
22//! carries the *design-token-reachable* cascaded stylesheet
23//! ([`LayoutIR::combined_stylesheet`]) verbatim, so colors/metrics stay
24//! CSS-reachable at runtime (the `<for>` reconciler re-applies it) - the AOT
25//! step resolves the cascade, it does not freeze visuals into hardcoded
26//! values.
27//!
28//! ## Container layout
29//!
30//! ```text
31//! magic : 4 bytes b"LMNA" (Lumen Markup Native Artifact)
32//! version: 2 bytes u16 LE (FORMAT_VERSION)
33//! body : bincode(CompiledApp)
34//! ```
35//!
36//! bincode is used over RON/JSON because the artifact is shipped/embedded and
37//! binary size is the priority: bincode stores no field names or schema, so a
38//! cascaded stylesheet with hundreds of declarations costs a fraction of its
39//! textual form. The magic + version prefix lets the loader reject a stale or
40//! foreign file cleanly instead of failing deep inside the decoder.
41
42use crate::fragment::FragmentTable;
43use crate::layout_ir::LayoutIR;
44use serde::{Deserialize, Serialize};
45use std::path::Path;
46
47/// Upper bound on the decoded bincode body, in bytes. The trust model is
48/// "built by our own `lumenc`", so this is cheap hardening rather than a
49/// security boundary: it stops a truncated/corrupt length prefix from making
50/// the decoder pre-allocate an absurd buffer. 512 MiB is far above any real
51/// compiled app.
52const MAX_ARTIFACT_BODY: u64 = 512 * 1024 * 1024;
53
54/// Container magic - "Lumen Markup Native Artifact".
55pub const MAGIC: [u8; 4] = *b"LMNA";
56
57/// Container format version. Bump on any incompatible change to
58/// [`CompiledApp`] or the framing. The loader refuses mismatches so a
59/// runtime never mis-decodes an artifact built by a different toolchain.
60///
61/// `2`: the skin-tokens CSS batch adds a run of new fields to
62/// [`crate::layout_ir::Attributes`] (`knob-inset`, `thumb-size`,
63/// `popup-gap`, `progress-chunk`, `disabled-opacity`, `caret-width`,
64/// `caret-blink`, `password-character`, `line-height`, and the
65/// `scrollbar-*` geometry/timing/paint properties), changing the
66/// bincode wire shape.
67///
68/// `3`: the `translatable="<key>"` attribute adds a field to
69/// [`crate::layout_ir::Attributes`].
70///
71/// `4`: [`CompiledApp::scripts`] records which engine runs each part of the
72/// app's script, so an app that ships more than one language keeps running
73/// under one host per language after compilation, and
74/// [`CompiledApp::pages`] carries a multi-page app's page set so navigation
75/// works without the page files on disk.
76///
77/// `5`: the web target's three additions and the fragment table, which land
78/// together because one bump costs the same as four.
79/// [`crate::layout_ir::Attributes`] gains `markup_styles`, the styling
80/// attributes an element was given in markup, so a surface where CSS outranks
81/// markup can replay them and keep Lumen's precedence; `widget`, the authored
82/// widget an element desugared from, so an emitter can write the semantic
83/// form back; and `alt`, an image's own alternative text. [`CompiledScript`]
84/// gains `bytecode`, the compiled `.cdlb` image of a candela program, so a
85/// runtime without the compiler can run it. [`CompiledApp::fragments`]
86/// carries the app's declared fragments, and [`crate::layout_ir::Element`]
87/// gains the fragment-use slot that names one, so a shipped app instantiates
88/// a fragment without the declaring source.
89///
90/// `6`: [`crate::fragment::Fragment`] gains `components`, the candela function
91/// names that reach a fragment, so a `.lmn` file can write a component as a
92/// tag.
93///
94/// `7`: [`crate::fragment::FragmentComponent`] gains `params`, and a use site
95/// the build cannot stand in for stays in the tree as the marker a runtime
96/// call fills, so a component that works a value out is usable from markup.
97///
98/// `8`: the `format="<spec>"` attribute adds a field to
99/// [`crate::layout_ir::Attributes`], so an element's text can be rendered
100/// for the app's locale.
101///
102/// `9`: [`crate::layout_ir::TooltipSpec`] gains `translatable`, so a
103/// `<tooltip>` names its own catalogue key and a popup body reads in the
104/// app's language rather than the one it was written in.
105///
106/// `10`: [`crate::css::Stylesheet`] gains `at_rules`, the at-rules the
107/// cascade does not implement, so a `@keyframes` block an app wrote reaches
108/// the web target instead of being dropped at parse time.
109///
110/// `11`: [`CompiledApp::i18n`] carries the app's translation catalogues and
111/// the fallback chain, so an app, and a server rendering one, reads in every
112/// language it ships with no `locale/` directory beside it.
113///
114/// `12`: [`CompiledApp::addons`] carries the browser add-ons the app depends
115/// on, their functions and their elements, so every target that runs the
116/// program binds the same names.
117///
118/// `13`: [`crate::layout_ir::BgSpec`] gains `Image` and
119/// [`crate::layout_ir::Attributes`] gains `bg_fit`, so `bg` can name an
120/// image and say how it fills the box.
121///
122/// `14`: [`CompiledApp::unlinked`] lists the optional subsystems the engine
123/// shipped beside the app was linked without, so a script calling one of
124/// their builtins is told why nothing happens.
125///
126/// `15`: [`CompiledScript`] gains `module`, the runtime module that runs the
127/// program, and a program compiled to bytecode carries no source; the
128/// flattened `script_source` field is gone.
129///
130/// `16`: [`CompiledScript`] gains `source_map`, and
131/// [`crate::layout_ir::LayoutIR`] gains `script_map`, so an error in a program
132/// shipped as source names the script file and line the author wrote rather
133/// than the markup file.
134///
135/// `17`: a tag's defaults (a `<column>`'s direction, an `<overlay>`'s
136/// placement) are user-agent rules in the combined stylesheet rather than
137/// values the parser writes into [`crate::layout_ir::Attributes`], and the
138/// parts a widget expands into carry their geometry in `markup_styles`. A
139/// restyle rebuilds an element from those, so an artifact written before
140/// would lose its tags' defaults the first time it restyled.
141///
142/// `18`: [`crate::layout_ir::Attributes`] gains `widget_attributes`, the
143/// attributes an element with a registered custom tag was written with, so
144/// the widget registered for the tag is handed them when the tree spawns.
145///
146/// A second consumer rides this constant: compiler plugins (`lumenc-plugin`)
147/// bake it into their descriptor and exchange bincode [`LayoutIR`] payloads
148/// with the loader, so a bump obsoletes every built plugin until it is
149/// rebuilt against the new tag.
150pub const FORMAT_VERSION: u16 = 18;
151
152/// The navigable page set of a compiled multi-page app.
153///
154/// The tree in [`CompiledApp::ir`] already holds every page, each behind the
155/// gate that mounts it while it is the active one, exactly as a from-source
156/// load assembles it. What a compiled app still needs is the routing data:
157/// which page names exist, and which one is home. The run path reads that from
158/// here rather than by looking for `.lmn` files, which a shipped app does not
159/// carry.
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct CompiledPages {
162 /// Home page key: the page that mounts before any navigation happens, and
163 /// the fallback for a path matching no page.
164 pub entry: String,
165 /// Every navigable page key, longest first, which is the order path
166 /// resolution walks them in.
167 pub keys: Vec<String>,
168}
169
170/// The app's translations, as baked at build time.
171///
172/// Each catalogue is the Fluent source of one `locale/<tag>.ftl` file, kept as
173/// text: which locale an app runs in is decided when it runs, and every
174/// target parses a catalogue through the same function. The compiler parses
175/// each one before writing it here, so a catalogue that is not Fluent fails
176/// the build rather than the app. A loose file on disk
177/// wins over the copy here, so a translator still edits the file beside an
178/// app and sees the change.
179#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
180pub struct CompiledI18n {
181 /// Each locale's catalogue: its BCP-47 tag, and the Fluent source.
182 /// Ordered by tag, so the same catalogues write the same bytes.
183 pub catalogues: Vec<(String, String)>,
184 /// The chain a key missing from the active catalogue falls through, as
185 /// `[app] fallback_locale` names it. Empty when the app names none, which
186 /// takes the default chain.
187 pub fallback: Vec<String>,
188}
189
190impl CompiledI18n {
191 /// True when the app carries no catalogue.
192 pub fn is_empty(&self) -> bool {
193 self.catalogues.is_empty()
194 }
195}
196
197/// One language's whole program, as baked at build time.
198///
199/// A script file belongs to the language its extension names, so an app that
200/// mixes languages compiles to one entry per language. The language is stored
201/// by name, the one its host module registers.
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct CompiledScript {
204 /// The language's name.
205 pub engine: String,
206 /// The runtime module that runs the program: the host a shipped app
207 /// loads for it. Empty when the build knew of none.
208 pub module: String,
209 /// Every source file the language owns, concatenated in source order.
210 /// Empty for a program compiled to [`Self::bytecode`], which a shipped app
211 /// runs without its source.
212 pub source: String,
213 /// The program compiled ahead of time, for a language that has a bytecode
214 /// form; `None` for one that runs from [`Self::source`].
215 pub bytecode: Option<Vec<u8>>,
216 /// Where each piece of [`Self::source`] was read from. Empty for a program
217 /// compiled to bytecode.
218 pub source_map: crate::source_map::SourceMap,
219}
220
221/// The precompiled application: everything the runtime needs to spawn the UI
222/// without touching the markup / CSS parser.
223#[derive(Debug, Clone, Default, Serialize, Deserialize)]
224pub struct CompiledApp {
225 /// Fully-resolved layout IR: includes cascaded inline attributes plus the
226 /// combined (skin + user) stylesheet on
227 /// [`LayoutIR::combined_stylesheet`], with `<include>` / `@import`
228 /// directives spliced away. A relative `<image src>` resolves against the
229 /// directory the artifact is run with; an absolute one is used as written.
230 pub ir: LayoutIR,
231 /// The app's program split by the language that runs each part, sorted by
232 /// language name. Empty when the app ships no script.
233 pub scripts: Vec<CompiledScript>,
234 /// The page set, for an app with more than one page. `None` for a
235 /// single-page app, which needs no routing at all.
236 pub pages: Option<CompiledPages>,
237 /// Every fragment the app declares, from markup `<template>` elements
238 /// and from `lmn!` blocks alike. The tree in [`Self::ir`] names them at
239 /// its use sites, so the table travels with it. Empty for an app that
240 /// declares none.
241 pub fragments: FragmentTable,
242 /// The app's translation catalogues and fallback chain. Empty for an app
243 /// with no `locale/` directory.
244 pub i18n: CompiledI18n,
245 /// What the web halves of the modules the app depends on for the target
246 /// it was compiled for declare, in the order its dependencies sort: the
247 /// functions its program was compiled against. A page binds them to the
248 /// modules it loads, and a build-time render or a server to a body that
249 /// says they run only in a browser. A desktop run reads none of it: the
250 /// module libraries it loads register the same functions. Empty for an
251 /// app that declares none.
252 pub addons: Vec<crate::addon::Addon>,
253 /// The optional subsystems the engine packaged with this app was linked
254 /// without, each with the builtin names that reach it. `lumenc package`
255 /// fills it when it relinks the engine for the app; every other build
256 /// leaves it empty. The names are opaque here: the runtime warns once
257 /// when a script calls a builtin one of them names, and reads nothing
258 /// else into them.
259 pub unlinked: Vec<UnlinkedCapability>,
260}
261
262/// One optional subsystem an app's engine was linked without.
263#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
264pub struct UnlinkedCapability {
265 /// The name the app's `[capabilities]` table keys it by.
266 pub name: String,
267 /// The text a call to one of its builtins contains, the same markers the
268 /// package step scanned the sources for. One ending in `(` names the
269 /// builtin itself; one without names every builtin it is part of.
270 pub markers: Vec<String>,
271}
272
273/// Errors from (de)serializing or reading/writing an artifact.
274#[derive(Debug, thiserror::Error)]
275pub enum ArtifactError {
276 /// I/O failure reading or writing the artifact file.
277 #[error("artifact io {0}: {1}")]
278 Io(std::path::PathBuf, std::io::Error),
279 /// The file is too short to contain the container header.
280 #[error("artifact too small ({0} bytes) - not a Lumen artifact")]
281 TooSmall(usize),
282 /// The 4-byte magic did not match [`MAGIC`].
283 #[error("bad artifact magic - not a Lumen artifact")]
284 BadMagic,
285 /// The container version is not one this runtime understands.
286 #[error("unsupported artifact version {found} (this build reads {expected})")]
287 Version {
288 /// Version read from the file.
289 found: u16,
290 /// Version this build supports ([`FORMAT_VERSION`]).
291 expected: u16,
292 },
293 /// The bincode body failed to decode.
294 #[error("artifact decode: {0}")]
295 Decode(String),
296 /// The [`CompiledApp`] failed to encode.
297 #[error("artifact encode: {0}")]
298 Encode(String),
299}
300
301/// Serialize a [`CompiledApp`] into the framed container bytes.
302pub fn serialize(app: &CompiledApp) -> Result<Vec<u8>, ArtifactError> {
303 let body = bincode::serialize(app).map_err(|e| ArtifactError::Encode(e.to_string()))?;
304 let mut out = Vec::with_capacity(body.len() + MAGIC.len() + 2);
305 out.extend_from_slice(&MAGIC);
306 out.extend_from_slice(&FORMAT_VERSION.to_le_bytes());
307 out.extend_from_slice(&body);
308 Ok(out)
309}
310
311/// Deserialize framed container bytes back into a [`CompiledApp`], validating
312/// the magic and version first.
313pub fn deserialize(bytes: &[u8]) -> Result<CompiledApp, ArtifactError> {
314 const HEADER: usize = 4 + 2;
315 if bytes.len() < HEADER {
316 return Err(ArtifactError::TooSmall(bytes.len()));
317 }
318 if bytes[0..4] != MAGIC {
319 return Err(ArtifactError::BadMagic);
320 }
321 let version = u16::from_le_bytes([bytes[4], bytes[5]]);
322 if version != FORMAT_VERSION {
323 return Err(ArtifactError::Version {
324 found: version,
325 expected: FORMAT_VERSION,
326 });
327 }
328 // Match the wire format of `bincode::serialize` (fixint, little-endian,
329 // reject-trailing) exactly - `DefaultOptions` alone defaults to *varint*,
330 // which would mis-decode - while adding a size limit so a bad length
331 // prefix can't trigger an enormous pre-allocation.
332 use bincode::Options;
333 bincode::DefaultOptions::new()
334 .with_fixint_encoding()
335 .with_limit(MAX_ARTIFACT_BODY)
336 .deserialize(&bytes[HEADER..])
337 .map_err(|e| ArtifactError::Decode(e.to_string()))
338}
339
340/// Write an artifact to `path`.
341pub fn write(path: &Path, app: &CompiledApp) -> Result<(), ArtifactError> {
342 let bytes = serialize(app)?;
343 std::fs::write(path, bytes).map_err(|e| ArtifactError::Io(path.to_path_buf(), e))
344}
345
346/// Read and decode an artifact from `path`.
347pub fn read(path: &Path) -> Result<CompiledApp, ArtifactError> {
348 let bytes = std::fs::read(path).map_err(|e| ArtifactError::Io(path.to_path_buf(), e))?;
349 deserialize(&bytes)
350}
351
352/// Decode an artifact from an in-memory byte slice: the byte-slice
353/// counterpart of [`read`]. Used by the link-not-embed launcher path, where
354/// the compiler produces LMNA bytes in-process and hands them across the
355/// C-ABI (`lumen_app_new_from_lmna`) instead of writing a file. Thin alias
356/// over [`deserialize`]; validates magic + version identically.
357pub fn read_bytes(bytes: &[u8]) -> Result<CompiledApp, ArtifactError> {
358 deserialize(bytes)
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364 use crate::fragment::{Fragment, FragmentKind, FragmentOrigin, FragmentParam};
365 use crate::layout_ir::{Attributes, Element, FragmentUse, InterpolationSlot};
366
367 fn fragments() -> FragmentTable {
368 let mut table = FragmentTable::new();
369 table
370 .insert(Fragment {
371 key: "card".to_string(),
372 params: vec![
373 FragmentParam {
374 name: "title".to_string(),
375 default: None,
376 },
377 FragmentParam {
378 name: "tone".to_string(),
379 default: Some("plain".to_string()),
380 },
381 ],
382 body: vec![Element {
383 tag: "label".to_string(),
384 attrs: Attributes {
385 text: Some("{title}".to_string()),
386 ..Attributes::default()
387 },
388 interpolations: vec![InterpolationSlot::Arg("title".to_string())],
389 ..Element::default()
390 }],
391 origins: vec![
392 FragmentOrigin {
393 file: "main.lmn".to_string(),
394 line: 12,
395 col: 3,
396 },
397 FragmentOrigin {
398 file: "parts.lmn".to_string(),
399 line: 4,
400 col: 1,
401 },
402 ],
403 kind: FragmentKind::Template,
404 components: Vec::new(),
405 })
406 .expect("card is the first declaration");
407 table
408 .insert(Fragment {
409 key: "badge".to_string(),
410 params: Vec::new(),
411 body: vec![Element {
412 tag: "tile".to_string(),
413 ..Element::default()
414 }],
415 origins: vec![FragmentOrigin {
416 file: "app.cdl".to_string(),
417 line: 30,
418 col: 9,
419 }],
420 kind: FragmentKind::Markup,
421 components: vec![crate::fragment::FragmentComponent {
422 name: "Badge".to_string(),
423 params: vec!["tone".to_string()],
424 inlinable: true,
425 }],
426 })
427 .expect("badge takes its own key");
428 table
429 }
430
431 fn sample() -> CompiledApp {
432 let mut ir = LayoutIR::default();
433 ir.root.tag = "root".to_string();
434 ir.root.children = vec![Element {
435 tag: "card".to_string(),
436 frag_use: Some(Box::new(FragmentUse {
437 key: "card".to_string(),
438 args: vec![("title".to_string(), "Inbox".to_string())],
439 slot_children: true,
440 })),
441 ..Element::default()
442 }];
443 CompiledApp {
444 ir,
445 scripts: vec![
446 CompiledScript {
447 engine: "toy".to_string(),
448 module: "toy-host".to_string(),
449 source: "let x = 1;".to_string(),
450 bytecode: None,
451 source_map: Default::default(),
452 },
453 CompiledScript {
454 engine: "candela".to_string(),
455 module: "lumen-candela".to_string(),
456 source: String::new(),
457 bytecode: Some(vec![0xCD, 0x1B, 0x00, 0xFF]),
458 source_map: Default::default(),
459 },
460 ],
461 pages: Some(CompiledPages {
462 entry: "index".to_string(),
463 keys: vec!["settings".to_string(), "index".to_string()],
464 }),
465 fragments: fragments(),
466 i18n: CompiledI18n {
467 catalogues: vec![
468 ("de-DE".to_string(), "greeting = Hallo\n".to_string()),
469 ("en-US".to_string(), "greeting = Hello\n".to_string()),
470 ],
471 fallback: vec!["fr-FR".to_string()],
472 },
473 addons: vec![crate::addon::Addon {
474 name: "echo".to_string(),
475 namespace: "echo".to_string(),
476 functions: vec![crate::addon::AddonFunction {
477 name: "later".to_string(),
478 params: vec![crate::addon::AddonParam {
479 name: "ms".to_string(),
480 ty: "int".to_string(),
481 }],
482 returns: "null".to_string(),
483 event: Some("on_echo".to_string()),
484 doc: String::new(),
485 }],
486 elements: vec![crate::addon::AddonElement {
487 tag: "echo-view".to_string(),
488 html: "div".to_string(),
489 void: false,
490 }],
491 }],
492 unlinked: vec![UnlinkedCapability {
493 name: "os-tray".to_string(),
494 markers: vec!["tray_icon".to_string()],
495 }],
496 }
497 }
498
499 #[test]
500 fn round_trip_bytes() {
501 let app = sample();
502 let bytes = serialize(&app).expect("serialize");
503 assert_eq!(&bytes[0..4], &MAGIC);
504 let back = deserialize(&bytes).expect("deserialize");
505 assert_eq!(back.scripts.len(), 2);
506 assert_eq!(back.scripts[0].engine, "toy");
507 assert_eq!(back.scripts[0].module, "toy-host");
508 assert_eq!(back.scripts[0].source, "let x = 1;");
509 assert_eq!(back.scripts[0].bytecode, None);
510 assert_eq!(back.scripts[1].module, "lumen-candela");
511 assert_eq!(
512 back.scripts[1].bytecode.as_deref(),
513 Some([0xCD, 0x1B, 0x00, 0xFF].as_slice()),
514 "a compiled candela image survives the round trip byte for byte"
515 );
516 let pages = back.pages.expect("the page set round-trips");
517 assert_eq!(pages.entry, "index");
518 assert_eq!(pages.keys.len(), 2);
519 assert_eq!(back.i18n, app.i18n);
520 assert_eq!(back.addons, app.addons);
521 assert_eq!(
522 back.unlinked, app.unlinked,
523 "the subsystems the engine was linked without travel with the app"
524 );
525 }
526
527 #[test]
528 fn fragments_round_trip() {
529 let bytes = serialize(&sample()).expect("serialize");
530 let back = deserialize(&bytes).expect("deserialize");
531
532 assert_eq!(back.fragments.len(), 2);
533 let card = back.fragments.get("card").expect("card survives the codec");
534 assert_eq!(card.kind, FragmentKind::Template);
535 assert_eq!(card.params.len(), 2);
536 assert_eq!(card.params[1].default.as_deref(), Some("plain"));
537 assert_eq!(card.origins.len(), 2);
538 assert_eq!(card.origins[1].file, "parts.lmn");
539 assert_eq!(
540 card.body[0].interpolations,
541 vec![InterpolationSlot::Arg("title".to_string())]
542 );
543
544 let badge = back.fragments.get("badge").expect("badge survives too");
545 assert_eq!(badge.kind, FragmentKind::Markup);
546 assert!(badge.params.is_empty());
547 // The component name travels too, so a use site that wrote the candela
548 // function as a tag still resolves with no source in hand.
549 assert_eq!(
550 back.fragments.by_component("Badge").map(|(f, _)| &f.key),
551 Some(&"badge".to_string())
552 );
553
554 let used = back.ir.root.children[0]
555 .frag_use
556 .as_ref()
557 .expect("the use site survives");
558 assert_eq!(used.key, "card");
559 assert_eq!(used.args, vec![("title".to_string(), "Inbox".to_string())]);
560 assert!(used.slot_children);
561 }
562
563 #[test]
564 fn encoding_is_deterministic() {
565 let first = serialize(&sample()).expect("serialize");
566 let second = serialize(&sample()).expect("serialize again");
567 assert_eq!(first, second);
568 }
569
570 #[test]
571 fn rejects_the_previous_version() {
572 let mut bytes = serialize(&sample()).expect("serialize");
573 bytes[4..6].copy_from_slice(&4u16.to_le_bytes());
574 assert!(matches!(
575 deserialize(&bytes),
576 Err(ArtifactError::Version {
577 found: 4,
578 expected: FORMAT_VERSION
579 })
580 ));
581 }
582
583 #[test]
584 fn read_bytes_matches_deserialize() {
585 let app = sample();
586 let bytes = serialize(&app).expect("serialize");
587 let back = read_bytes(&bytes).expect("read_bytes");
588 assert_eq!(back.scripts[0].source, app.scripts[0].source);
589 }
590
591 #[test]
592 fn rejects_foreign_bytes() {
593 assert!(matches!(
594 deserialize(b"not-an-artifact-blob"),
595 Err(ArtifactError::BadMagic)
596 ));
597 assert!(matches!(
598 deserialize(b"LM"),
599 Err(ArtifactError::TooSmall(_))
600 ));
601 }
602
603 #[test]
604 fn rejects_bad_version() {
605 let mut bytes = serialize(&sample()).expect("serialize");
606 bytes[4] = 0xFF;
607 bytes[5] = 0xFF;
608 assert!(matches!(
609 deserialize(&bytes),
610 Err(ArtifactError::Version { .. })
611 ));
612 }
613}