1use std::collections::HashMap;
33use std::fs::File;
34use std::io::{Read, Seek, SeekFrom, Write};
35use std::path::Path;
36use std::sync::Arc;
37
38pub const MAGIC: [u8; 4] = *b"LMNB";
40
41pub const VERSION: u32 = 1;
43
44#[derive(Debug, thiserror::Error)]
46pub enum BundleError {
47 #[error("io: {0}")]
49 Io(#[from] std::io::Error),
50 #[error("bad magic (expected LMNB)")]
52 BadMagic,
53 #[error("unsupported bundle version {0}")]
55 UnsupportedVersion(u32),
56 #[error("entry name not utf-8")]
58 BadUtf8,
59 #[error("entry offset/size out of range")]
61 OutOfRange,
62}
63
64#[derive(Debug, Clone)]
66struct Entry {
67 offset: u64,
68 size: u64,
69}
70
71#[derive(Clone)]
74pub struct LumenBundle {
75 blob: Arc<[u8]>,
78 index: HashMap<String, Entry>,
80}
81
82impl LumenBundle {
83 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 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 pub fn len(&self) -> usize {
128 self.index.len()
129 }
130
131 pub fn is_empty(&self) -> bool {
133 self.index.is_empty()
134 }
135
136 pub fn entries(&self) -> impl Iterator<Item = &str> {
139 self.index.keys().map(String::as_str)
140 }
141
142 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 pub fn contains(&self, name: &str) -> bool {
156 self.index.contains_key(name)
157 }
158
159 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 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 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
212fn 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 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 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
252fn write_bundle<W: Write + Seek>(
254 w: &mut W,
255 entries: &[(String, std::path::PathBuf)],
256) -> Result<(), BundleError> {
257 w.write_all(&MAGIC)?;
259 w.write_all(&VERSION.to_le_bytes())?;
260 w.write_all(&(entries.len() as u32).to_le_bytes())?;
261 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 w.write_all(&0u64.to_le_bytes())?;
274 w.write_all(&0u64.to_le_bytes())?;
275 }
276 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 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
296pub 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 let mut buf: Vec<u8> = Vec::new();
314 {
315 let mut cur = Cursor::new(&mut buf);
316 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 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 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 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 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}