Skip to main content

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}