Skip to main content

lumen_assets/
bundle.rs

1//! `.lpak` resource bundle: a zip-like archive that ships an entire
2//! Lumen app's assets (main.lmn, main.css, scripts, images, fonts)
3//! as a single file. Mirrors Qt's `qrc` and GTK's `GResource`.
4//!
5//! The on-disk format is intentionally tiny - no compression, no
6//! checksums, just a header table and a packed payload region. The
7//! goal is a `lumenc bundle <dir> <out.lpak>` round trip that's
8//! comprehensible at a hex dump:
9//!
10//! ```text
11//!     +-----------+----------------+-------------------+
12//!     | magic     | "LMNB"         | 4 bytes           |
13//!     | version   | u32 LE         | 4 bytes           |
14//!     | file_count| u32 LE         | 4 bytes           |
15//!     +-----------+----------------+-------------------+
16//!     | entry[0]  | name_len: u32  | 4 bytes           |
17//!     |           | name: utf-8    | name_len bytes    |
18//!     |           | offset: u64    | 8 bytes           |
19//!     |           | size: u64      | 8 bytes           |
20//!     +-----------+----------------+-------------------+
21//!     | ...         | ...              | ...                 |
22//!     +-----------+----------------+-------------------+
23//!     | file_bytes[0]              | size bytes        |
24//!     | file_bytes[1]              | size bytes        |
25//!     | ...                          | ...                 |
26//!     +-------------------------------------------------+
27//! ```
28//!
29//! All multi-byte integers are little-endian. Offsets are absolute
30//! byte positions into the bundle blob.
31
32use std::collections::HashMap;
33use std::fs::File;
34use std::io::{Read, Seek, SeekFrom, Write};
35use std::path::Path;
36use std::sync::Arc;
37
38/// 4-byte magic prefix identifying a `.lpak` archive. ASCII "LMNB".
39pub const MAGIC: [u8; 4] = *b"LMNB";
40
41/// Format version. Bumped on incompatible header changes.
42pub const VERSION: u32 = 1;
43
44/// Errors produced when opening / reading a `.lpak`.
45#[derive(Debug, thiserror::Error)]
46pub enum BundleError {
47    /// Underlying I/O failure.
48    #[error("io: {0}")]
49    Io(#[from] std::io::Error),
50    /// Magic prefix mismatch.
51    #[error("bad magic (expected LMNB)")]
52    BadMagic,
53    /// Header version we don't know how to read.
54    #[error("unsupported bundle version {0}")]
55    UnsupportedVersion(u32),
56    /// Entry name failed UTF-8 decode.
57    #[error("entry name not utf-8")]
58    BadUtf8,
59    /// Entry's offset+size lands outside the bundle blob.
60    #[error("entry offset/size out of range")]
61    OutOfRange,
62}
63
64/// One file inside a [`LumenBundle`].
65#[derive(Debug, Clone)]
66struct Entry {
67    offset: u64,
68    size: u64,
69}
70
71/// In-memory `.lpak` archive. Holds the raw blob plus an index from
72/// virtual path -> byte range so reads are O(1).
73#[derive(Clone)]
74pub struct LumenBundle {
75    /// Raw bundle bytes. Shared via `Arc` so `read` can hand back a
76    /// fresh `Vec<u8>` without re-reading from disk.
77    blob: Arc<[u8]>,
78    /// Logical path -> `(offset, size)` lookup.
79    index: HashMap<String, Entry>,
80}
81
82impl LumenBundle {
83    /// Open a `.lpak` from disk, parse the header, and build the
84    /// in-memory index. The full blob is read into memory; typical
85    /// bundles are < 50 MiB so this is fine for the v1 use cases.
86    pub fn open(path: impl AsRef<Path>) -> Result<Self, BundleError> {
87        let mut file = File::open(path.as_ref())?;
88        let mut blob = Vec::new();
89        file.read_to_end(&mut blob)?;
90        Self::from_bytes(blob)
91    }
92
93    /// Build a bundle from raw bytes (e.g. `include_bytes!("app.lpak")`).
94    pub fn from_bytes(blob: impl Into<Vec<u8>>) -> Result<Self, BundleError> {
95        let blob = blob.into();
96        let mut cursor = std::io::Cursor::new(&blob);
97        let mut magic = [0u8; 4];
98        cursor.read_exact(&mut magic)?;
99        if magic != MAGIC {
100            return Err(BundleError::BadMagic);
101        }
102        let version = read_u32(&mut cursor)?;
103        if version != VERSION {
104            return Err(BundleError::UnsupportedVersion(version));
105        }
106        let file_count = read_u32(&mut cursor)?;
107        let mut index = HashMap::with_capacity(file_count as usize);
108        for _ in 0..file_count {
109            let name_len = read_u32(&mut cursor)? as usize;
110            let mut name_buf = vec![0u8; name_len];
111            cursor.read_exact(&mut name_buf)?;
112            let name = String::from_utf8(name_buf).map_err(|_| BundleError::BadUtf8)?;
113            let offset = read_u64(&mut cursor)?;
114            let size = read_u64(&mut cursor)?;
115            if offset.saturating_add(size) > blob.len() as u64 {
116                return Err(BundleError::OutOfRange);
117            }
118            index.insert(name, Entry { offset, size });
119        }
120        Ok(Self {
121            blob: Arc::from(blob),
122            index,
123        })
124    }
125
126    /// Number of files in the archive.
127    pub fn len(&self) -> usize {
128        self.index.len()
129    }
130
131    /// Is the archive empty?
132    pub fn is_empty(&self) -> bool {
133        self.index.is_empty()
134    }
135
136    /// List logical entry names in unspecified order. Useful for
137    /// font-discovery passes that filter on extension.
138    pub fn entries(&self) -> impl Iterator<Item = &str> {
139        self.index.keys().map(String::as_str)
140    }
141
142    /// Look up the raw bytes for `name`. Returns `None` when the
143    /// entry isn't in the archive. The returned `Vec<u8>` is a fresh
144    /// owned copy - bundles are immutable so the slice could in
145    /// theory be loaned, but mirroring `Vec<u8>` keeps the call site
146    /// uniform with disk-loaded paths.
147    pub fn read(&self, name: &str) -> Option<Vec<u8>> {
148        let entry = self.index.get(name)?;
149        let start = entry.offset as usize;
150        let end = start + entry.size as usize;
151        self.blob.get(start..end).map(|s| s.to_vec())
152    }
153
154    /// Whether `name` exists in the archive.
155    pub fn contains(&self, name: &str) -> bool {
156        self.index.contains_key(name)
157    }
158
159    /// Borrow the entry bytes without copying. Used by the font-db
160    /// registration path which feeds the slice straight into
161    /// `cosmic_text::fontdb::Database::load_font_data` (which copies
162    /// internally anyway).
163    pub fn slice(&self, name: &str) -> Option<&[u8]> {
164        let entry = self.index.get(name)?;
165        let start = entry.offset as usize;
166        let end = start + entry.size as usize;
167        self.blob.get(start..end)
168    }
169
170    /// Package a directory tree into a `.lpak` written to `out`.
171    /// `root` is walked recursively; each visited file becomes an
172    /// entry keyed by its path RELATIVE to `root`, with forward
173    /// slashes. Symlinks are followed.
174    ///
175    /// A `src/` directory at `root` is left out: an app's code is compiled,
176    /// never looked up by name at run time, so a bundle holds assets only.
177    ///
178    /// Returns the number of entries written.
179    pub fn pack_dir(root: impl AsRef<Path>, out: impl AsRef<Path>) -> Result<usize, BundleError> {
180        let root = root.as_ref();
181        let mut entries: Vec<(String, std::path::PathBuf)> = Vec::new();
182        walk_dir(root, root, &mut entries)?;
183        // Stable order so two runs produce the same bytes.
184        entries.sort_by(|a, b| a.0.cmp(&b.0));
185        let mut file = File::create(out.as_ref())?;
186        write_bundle(&mut file, &entries)?;
187        Ok(entries.len())
188    }
189}
190
191impl std::fmt::Debug for LumenBundle {
192    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
193        f.debug_struct("LumenBundle")
194            .field("entries", &self.index.len())
195            .field("bytes", &self.blob.len())
196            .finish()
197    }
198}
199
200fn read_u32(cur: &mut std::io::Cursor<&Vec<u8>>) -> Result<u32, BundleError> {
201    let mut buf = [0u8; 4];
202    cur.read_exact(&mut buf)?;
203    Ok(u32::from_le_bytes(buf))
204}
205
206fn read_u64(cur: &mut std::io::Cursor<&Vec<u8>>) -> Result<u64, BundleError> {
207    let mut buf = [0u8; 8];
208    cur.read_exact(&mut buf)?;
209    Ok(u64::from_le_bytes(buf))
210}
211
212/// Recursively collect `(virtual_path, real_path)` pairs under `root`.
213/// Skips dotfiles + the `target/` directory at any depth.
214fn walk_dir(
215    root: &Path,
216    dir: &Path,
217    out: &mut Vec<(String, std::path::PathBuf)>,
218) -> Result<(), BundleError> {
219    for entry in std::fs::read_dir(dir)? {
220        let entry = entry?;
221        let path = entry.path();
222        let name = entry.file_name();
223        let name_str = name.to_string_lossy();
224        // Skip dotfiles, `target/`, and known editor noise. The app's `src/`
225        // goes too, but only at the root: deeper down the name is an asset
226        // folder's, not the app's code.
227        if name_str.starts_with('.') || name_str == "target" || (dir == root && name_str == "src") {
228            continue;
229        }
230        let metadata = std::fs::metadata(&path)?;
231        if metadata.is_dir() {
232            walk_dir(root, &path, out)?;
233        } else if metadata.is_file() {
234            let rel = path
235                .strip_prefix(root)
236                .map_err(|_| std::io::Error::other("strip_prefix"))?;
237            // Forward-slash logical paths - Lumen URIs are URL-shaped.
238            let logical: String = rel
239                .components()
240                .filter_map(|c| match c {
241                    std::path::Component::Normal(s) => Some(s.to_string_lossy().to_string()),
242                    _ => None,
243                })
244                .collect::<Vec<_>>()
245                .join("/");
246            out.push((logical, path));
247        }
248    }
249    Ok(())
250}
251
252/// Encode `entries` into the bundle wire format and stream into `w`.
253fn write_bundle<W: Write + Seek>(
254    w: &mut W,
255    entries: &[(String, std::path::PathBuf)],
256) -> Result<(), BundleError> {
257    // Header.
258    w.write_all(&MAGIC)?;
259    w.write_all(&VERSION.to_le_bytes())?;
260    w.write_all(&(entries.len() as u32).to_le_bytes())?;
261    // First pass: write the entry table with placeholder offsets so
262    // we can come back and overwrite once we know where each file
263    // payload landed. Capture the byte position of each `offset`
264    // field so the second pass can seek + rewrite.
265    let mut offset_positions: Vec<u64> = Vec::with_capacity(entries.len());
266    for (name, _) in entries {
267        let bytes = name.as_bytes();
268        w.write_all(&(bytes.len() as u32).to_le_bytes())?;
269        w.write_all(bytes)?;
270        let pos = w.stream_position()?;
271        offset_positions.push(pos);
272        // Placeholder offset + size.
273        w.write_all(&0u64.to_le_bytes())?;
274        w.write_all(&0u64.to_le_bytes())?;
275    }
276    // Second pass: append each file's payload, capture (offset, size),
277    // then seek back and patch the header entries.
278    let mut payloads: Vec<(u64, u64)> = Vec::with_capacity(entries.len());
279    for (_, real) in entries {
280        let mut data = Vec::new();
281        File::open(real)?.read_to_end(&mut data)?;
282        let offset = w.stream_position()?;
283        w.write_all(&data)?;
284        payloads.push((offset, data.len() as u64));
285    }
286    // Patch entries.
287    for (pos, (offset, size)) in offset_positions.iter().zip(payloads.iter()) {
288        w.seek(SeekFrom::Start(*pos))?;
289        w.write_all(&offset.to_le_bytes())?;
290        w.write_all(&size.to_le_bytes())?;
291    }
292    w.seek(SeekFrom::End(0))?;
293    Ok(())
294}
295
296/// Parse a `lumen://app/<path>` URI and return the `<path>` portion
297/// (suitable for `LumenBundle::read`). Returns `None` for any other
298/// scheme.
299pub fn parse_lumen_uri(uri: &str) -> Option<&str> {
300    let rest = uri.strip_prefix("lumen://app/")?;
301    Some(rest)
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use std::io::Cursor;
308
309    fn round_trip(entries: &[(&str, &[u8])]) -> LumenBundle {
310        let tmp = tempfile_path("bundle");
311        // Hand-build the bundle in memory using a Cursor so we don't
312        // depend on a tempdir crate.
313        let mut buf: Vec<u8> = Vec::new();
314        {
315            let mut cur = Cursor::new(&mut buf);
316            // Header.
317            cur.write_all(&MAGIC).unwrap();
318            cur.write_all(&VERSION.to_le_bytes()).unwrap();
319            cur.write_all(&(entries.len() as u32).to_le_bytes())
320                .unwrap();
321            // Reserve table.
322            let mut offset_positions: Vec<u64> = Vec::new();
323            for (name, _) in entries {
324                let bytes = name.as_bytes();
325                cur.write_all(&(bytes.len() as u32).to_le_bytes()).unwrap();
326                cur.write_all(bytes).unwrap();
327                offset_positions.push(cur.position());
328                cur.write_all(&0u64.to_le_bytes()).unwrap();
329                cur.write_all(&0u64.to_le_bytes()).unwrap();
330            }
331            // Payloads.
332            let mut payloads = Vec::new();
333            for (_, data) in entries {
334                let pos = cur.position();
335                cur.write_all(data).unwrap();
336                payloads.push((pos, data.len() as u64));
337            }
338            // Patch.
339            for (pos, (off, sz)) in offset_positions.iter().zip(payloads.iter()) {
340                cur.set_position(*pos);
341                cur.write_all(&off.to_le_bytes()).unwrap();
342                cur.write_all(&sz.to_le_bytes()).unwrap();
343            }
344        }
345        let _ = std::fs::write(&tmp, &buf);
346        let bundle = LumenBundle::from_bytes(buf).expect("parse bundle");
347        let _ = std::fs::remove_file(tmp);
348        bundle
349    }
350
351    fn tempfile_path(tag: &str) -> std::path::PathBuf {
352        let mut p = std::env::temp_dir();
353        // Cheap unique-ish suffix; we're in a unit test, collisions are
354        // OK as long as we clean up.
355        let nanos = std::time::SystemTime::now()
356            .duration_since(std::time::UNIX_EPOCH)
357            .unwrap()
358            .as_nanos();
359        p.push(format!("lumen-bundle-{tag}-{nanos}.lpak"));
360        p
361    }
362
363    #[test]
364    fn read_round_trip() {
365        let bundle = round_trip(&[("main.lmn", b"hello"), ("icons/x.png", &[1, 2, 3])]);
366        assert_eq!(bundle.read("main.lmn"), Some(b"hello".to_vec()));
367        assert_eq!(bundle.read("icons/x.png"), Some(vec![1u8, 2, 3]));
368        assert_eq!(bundle.read("missing"), None);
369        assert!(bundle.contains("main.lmn"));
370        assert_eq!(bundle.len(), 2);
371    }
372
373    #[test]
374    fn pack_dir_then_open() {
375        let dir = tempfile_path("pack-src");
376        let _ = std::fs::remove_dir_all(&dir);
377        std::fs::create_dir_all(dir.join("sub")).unwrap();
378        std::fs::write(dir.join("main.lmn"), b"root").unwrap();
379        std::fs::write(dir.join("sub").join("nested.css"), b"body{}").unwrap();
380        let out = tempfile_path("pack-out");
381        let count = LumenBundle::pack_dir(&dir, &out).expect("pack");
382        assert_eq!(count, 2);
383        let bundle = LumenBundle::open(&out).expect("open");
384        assert_eq!(bundle.read("main.lmn"), Some(b"root".to_vec()));
385        assert_eq!(bundle.read("sub/nested.css"), Some(b"body{}".to_vec()));
386        let _ = std::fs::remove_dir_all(&dir);
387        let _ = std::fs::remove_file(&out);
388    }
389
390    #[test]
391    fn parse_uri_yields_path() {
392        assert_eq!(
393            parse_lumen_uri("lumen://app/icons/sun.png"),
394            Some("icons/sun.png")
395        );
396        assert_eq!(parse_lumen_uri("lumen://app/main.lmn"), Some("main.lmn"));
397        assert_eq!(parse_lumen_uri("file:///abs/path"), None);
398        assert_eq!(parse_lumen_uri("icons/sun.png"), None);
399    }
400
401    #[test]
402    fn bad_magic_rejected() {
403        let mut blob = vec![0u8; 32];
404        blob[0..4].copy_from_slice(b"XXXX");
405        assert!(matches!(
406            LumenBundle::from_bytes(blob),
407            Err(BundleError::BadMagic)
408        ));
409    }
410}