Skip to main content

srcmap_ram_bundle/
lib.rs

1//! React Native RAM bundle parser for source map tooling.
2//!
3//! Supports two RAM bundle formats used by React Native / Metro:
4//!
5//! - **Indexed RAM bundles** (iOS): single binary file with magic number `0xFB0BD1E5`
6//! - **Unbundles** (Android): directory-based with `js-modules/` structure
7//!
8//! # Examples
9//!
10//! ```
11//! use srcmap_ram_bundle::{IndexedRamBundle, is_ram_bundle};
12//!
13//! // Build a minimal RAM bundle for demonstration
14//! let startup = b"var startup = true;";
15//! let module0 = b"__d(function(){},0);";
16//!
17//! let mut data = Vec::new();
18//! // Magic number
19//! data.extend_from_slice(&0xFB0BD1E5_u32.to_le_bytes());
20//! // Module count: 1
21//! data.extend_from_slice(&1_u32.to_le_bytes());
22//! // Startup code size
23//! data.extend_from_slice(&(startup.len() as u32).to_le_bytes());
24//! // Module table entry: offset 0, length of module0
25//! data.extend_from_slice(&0_u32.to_le_bytes());
26//! data.extend_from_slice(&(module0.len() as u32).to_le_bytes());
27//! // Startup code
28//! data.extend_from_slice(startup);
29//! // Module code
30//! data.extend_from_slice(module0);
31//!
32//! assert!(is_ram_bundle(&data));
33//!
34//! let bundle = IndexedRamBundle::from_bytes(&data).unwrap();
35//! assert_eq!(bundle.startup_code(), "var startup = true;");
36//! assert_eq!(bundle.module_count(), 1);
37//! assert_eq!(bundle.get_module(0).unwrap().source_code, "__d(function(){},0);");
38//! ```
39
40use std::fmt;
41use std::path::Path;
42
43/// Magic number for indexed RAM bundles (iOS format).
44const RAM_BUNDLE_MAGIC: u32 = 0xFB0B_D1E5;
45
46/// Size of the fixed header: magic (4) + module_count (4) + startup_code_size (4).
47const HEADER_SIZE: usize = 12;
48
49/// Size of each module table entry: offset (4) + length (4).
50const MODULE_ENTRY_SIZE: usize = 8;
51
52/// Error type for RAM bundle operations.
53#[derive(Debug)]
54pub enum RamBundleError {
55    /// Invalid magic number.
56    InvalidMagic,
57    /// Data too short to contain a valid header.
58    TooShort,
59    /// Invalid module entry.
60    InvalidEntry(String),
61    /// I/O error.
62    Io(std::io::Error),
63    /// Source map parse error.
64    SourceMap(srcmap_sourcemap::ParseError),
65}
66
67impl fmt::Display for RamBundleError {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        match self {
70            Self::InvalidMagic => write!(f, "invalid RAM bundle magic number"),
71            Self::TooShort => write!(f, "data too short for RAM bundle header"),
72            Self::InvalidEntry(msg) => write!(f, "invalid module entry: {msg}"),
73            Self::Io(e) => write!(f, "I/O error: {e}"),
74            Self::SourceMap(e) => write!(f, "source map error: {e}"),
75        }
76    }
77}
78
79impl std::error::Error for RamBundleError {
80    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
81        match self {
82            Self::Io(e) => Some(e),
83            Self::SourceMap(e) => Some(e),
84            _ => None,
85        }
86    }
87}
88
89impl From<std::io::Error> for RamBundleError {
90    fn from(e: std::io::Error) -> Self {
91        Self::Io(e)
92    }
93}
94
95impl From<srcmap_sourcemap::ParseError> for RamBundleError {
96    fn from(e: srcmap_sourcemap::ParseError) -> Self {
97        Self::SourceMap(e)
98    }
99}
100
101/// Type of RAM bundle.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum RamBundleType {
104    /// Indexed format (iOS) - single binary file.
105    Indexed,
106    /// Unbundle format (Android) - directory with `js-modules/`.
107    Unbundle,
108}
109
110/// A parsed RAM bundle module.
111#[derive(Debug, Clone)]
112pub struct RamBundleModule {
113    /// Module ID (0-based index).
114    pub id: u32,
115    /// Module source code.
116    pub source_code: String,
117}
118
119/// A parsed indexed RAM bundle.
120///
121/// The indexed format is a single binary file used primarily on iOS. It contains
122/// a header with a module table followed by startup code and module source code.
123#[derive(Debug)]
124pub struct IndexedRamBundle {
125    /// Number of modules in the bundle.
126    pub module_count: u32,
127    /// Startup (prelude) code that runs before modules.
128    pub startup_code: String,
129    /// Individual modules indexed by ID.
130    modules: Vec<Option<RamBundleModule>>,
131}
132
133impl IndexedRamBundle {
134    /// Parse an indexed RAM bundle from raw bytes.
135    ///
136    /// The binary layout is:
137    /// - Bytes 0..4: magic number (little-endian `u32`) = `0xFB0BD1E5`
138    /// - Bytes 4..8: module count (little-endian `u32`)
139    /// - Bytes 8..12: startup code size (little-endian `u32`)
140    /// - Next `module_count * 8` bytes: module table entries (offset + length, each `u32` LE)
141    /// - Startup code (UTF-8)
142    /// - Module source code at specified offsets (UTF-8)
143    pub fn from_bytes(data: &[u8]) -> Result<Self, RamBundleError> {
144        if data.len() < HEADER_SIZE {
145            return Err(RamBundleError::TooShort);
146        }
147
148        let magic = read_u32_le(data, 0).unwrap();
149        if magic != RAM_BUNDLE_MAGIC {
150            return Err(RamBundleError::InvalidMagic);
151        }
152
153        let module_count = read_u32_le(data, 4).unwrap();
154        let startup_code_size = read_u32_le(data, 8).unwrap() as usize;
155
156        let table_size = (module_count as usize)
157            .checked_mul(MODULE_ENTRY_SIZE)
158            .ok_or(RamBundleError::TooShort)?;
159        let table_end = HEADER_SIZE.checked_add(table_size).ok_or(RamBundleError::TooShort)?;
160
161        if data.len() < table_end {
162            return Err(RamBundleError::TooShort);
163        }
164
165        // Startup code comes right after the module table
166        let startup_start = table_end;
167        let startup_end =
168            startup_start.checked_add(startup_code_size).ok_or(RamBundleError::TooShort)?;
169
170        if data.len() < startup_end {
171            return Err(RamBundleError::TooShort);
172        }
173
174        let startup_code = std::str::from_utf8(&data[startup_start..startup_end])
175            .map_err(|e| {
176                RamBundleError::InvalidEntry(format!("startup code is not valid UTF-8: {e}"))
177            })?
178            .to_owned();
179
180        // The base offset for module data is right after startup code
181        let modules_base = startup_end;
182
183        let mut modules = Vec::with_capacity(module_count as usize);
184
185        for i in 0..module_count as usize {
186            let entry_offset = HEADER_SIZE + i * MODULE_ENTRY_SIZE;
187            let offset = read_u32_le(data, entry_offset).unwrap() as usize;
188            let length = read_u32_le(data, entry_offset + 4).unwrap() as usize;
189
190            if offset == 0 && length == 0 {
191                modules.push(None);
192                continue;
193            }
194
195            let abs_start = modules_base.checked_add(offset).ok_or_else(|| {
196                RamBundleError::InvalidEntry(format!("module {i} offset overflows"))
197            })?;
198            let abs_end = abs_start.checked_add(length).ok_or_else(|| {
199                RamBundleError::InvalidEntry(format!("module {i} length overflows"))
200            })?;
201
202            if abs_end > data.len() {
203                return Err(RamBundleError::InvalidEntry(format!(
204                    "module {i} extends beyond data (offset={offset}, length={length}, data_len={})",
205                    data.len()
206                )));
207            }
208
209            let source_code = std::str::from_utf8(&data[abs_start..abs_end])
210                .map_err(|e| {
211                    RamBundleError::InvalidEntry(format!(
212                        "module {i} source is not valid UTF-8: {e}"
213                    ))
214                })?
215                .to_owned();
216
217            modules.push(Some(RamBundleModule { id: i as u32, source_code }));
218        }
219
220        Ok(Self { module_count, startup_code, modules })
221    }
222
223    /// Returns the number of module slots in the bundle.
224    pub fn module_count(&self) -> u32 {
225        self.module_count
226    }
227
228    /// Returns a module by its ID, or `None` if the slot is empty.
229    pub fn get_module(&self, id: u32) -> Option<&RamBundleModule> {
230        self.modules.get(id as usize)?.as_ref()
231    }
232
233    /// Iterates over all non-empty modules in the bundle.
234    pub fn modules(&self) -> impl Iterator<Item = &RamBundleModule> {
235        self.modules.iter().filter_map(|m| m.as_ref())
236    }
237
238    /// Returns the startup (prelude) code.
239    pub fn startup_code(&self) -> &str {
240        &self.startup_code
241    }
242}
243
244/// Check if data starts with the RAM bundle magic number.
245///
246/// Requires at least 4 bytes.
247pub fn is_ram_bundle(data: &[u8]) -> bool {
248    read_u32_le(data, 0) == Some(RAM_BUNDLE_MAGIC)
249}
250
251/// Check if a path looks like an unbundle (file RAM bundle) directory.
252///
253/// Returns `true` if the path contains a `js-modules` subdirectory.
254pub fn is_unbundle_dir(path: &Path) -> bool {
255    path.join("js-modules").is_dir()
256}
257
258/// Read a little-endian `u32` from `data` at the given byte offset.
259fn read_u32_le(data: &[u8], offset: usize) -> Option<u32> {
260    if offset + 4 > data.len() {
261        return None;
262    }
263    Some(u32::from_le_bytes([data[offset], data[offset + 1], data[offset + 2], data[offset + 3]]))
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    /// Build a valid indexed RAM bundle from module source strings.
271    ///
272    /// `modules` is a slice of optional source code strings. `None` entries
273    /// produce empty module table slots (offset=0, length=0).
274    fn make_test_bundle(modules: &[Option<&str>], startup: &str) -> Vec<u8> {
275        let mut data = Vec::new();
276
277        // Header
278        data.extend_from_slice(&RAM_BUNDLE_MAGIC.to_le_bytes());
279        data.extend_from_slice(&(modules.len() as u32).to_le_bytes());
280        data.extend_from_slice(&(startup.len() as u32).to_le_bytes());
281
282        // Build module bodies and compute offsets.
283        // Offsets are relative to the start of the module data section
284        // (which comes after the header + table + startup code).
285        let mut module_bodies: Vec<(u32, u32)> = Vec::new();
286        let mut current_offset: u32 = 0;
287
288        for module in modules {
289            match module {
290                Some(src) => {
291                    let len = src.len() as u32;
292                    module_bodies.push((current_offset, len));
293                    current_offset += len;
294                }
295                None => {
296                    module_bodies.push((0, 0));
297                }
298            }
299        }
300
301        // Module table
302        for &(offset, length) in &module_bodies {
303            data.extend_from_slice(&offset.to_le_bytes());
304            data.extend_from_slice(&length.to_le_bytes());
305        }
306
307        // Startup code
308        data.extend_from_slice(startup.as_bytes());
309
310        // Module source code
311        for module in modules.iter().flatten() {
312            data.extend_from_slice(module.as_bytes());
313        }
314
315        data
316    }
317
318    #[test]
319    fn test_is_ram_bundle() {
320        let data = make_test_bundle(&[], "");
321        assert!(is_ram_bundle(&data));
322    }
323
324    #[test]
325    fn test_is_ram_bundle_wrong_magic() {
326        let data = [0x00, 0x00, 0x00, 0x00];
327        assert!(!is_ram_bundle(&data));
328    }
329
330    #[test]
331    fn test_is_ram_bundle_too_short() {
332        assert!(!is_ram_bundle(&[0xE5, 0xD1, 0x0B]));
333        assert!(!is_ram_bundle(&[]));
334    }
335
336    #[test]
337    fn test_parse_empty_bundle() {
338        let data = make_test_bundle(&[], "var x = 1;");
339        let bundle = IndexedRamBundle::from_bytes(&data).unwrap();
340        assert_eq!(bundle.module_count(), 0);
341        assert_eq!(bundle.startup_code(), "var x = 1;");
342        assert_eq!(bundle.modules().count(), 0);
343    }
344
345    #[test]
346    fn test_parse_single_module() {
347        let data = make_test_bundle(&[Some("__d(function(){},0);")], "startup();");
348        let bundle = IndexedRamBundle::from_bytes(&data).unwrap();
349
350        assert_eq!(bundle.module_count(), 1);
351        assert_eq!(bundle.startup_code(), "startup();");
352
353        let module = bundle.get_module(0).unwrap();
354        assert_eq!(module.id, 0);
355        assert_eq!(module.source_code, "__d(function(){},0);");
356    }
357
358    #[test]
359    fn test_parse_multiple_modules() {
360        let modules = vec![
361            Some("__d(function(){console.log('a')},0);"),
362            Some("__d(function(){console.log('b')},1);"),
363            Some("__d(function(){console.log('c')},2);"),
364        ];
365        let data = make_test_bundle(&modules, "require(0);");
366        let bundle = IndexedRamBundle::from_bytes(&data).unwrap();
367
368        assert_eq!(bundle.module_count(), 3);
369        assert_eq!(bundle.startup_code(), "require(0);");
370
371        for (i, module) in bundle.modules().enumerate() {
372            assert_eq!(module.id, i as u32);
373            assert!(module.source_code.contains(&format!("'{}'", (b'a' + i as u8) as char)));
374        }
375    }
376
377    #[test]
378    fn test_empty_module_slots() {
379        let modules = vec![Some("__d(function(){},0);"), None, Some("__d(function(){},2);")];
380        let data = make_test_bundle(&modules, "");
381        let bundle = IndexedRamBundle::from_bytes(&data).unwrap();
382
383        assert_eq!(bundle.module_count(), 3);
384        assert!(bundle.get_module(0).is_some());
385        assert!(bundle.get_module(1).is_none());
386        assert!(bundle.get_module(2).is_some());
387
388        // Only 2 non-empty modules
389        assert_eq!(bundle.modules().count(), 2);
390    }
391
392    #[test]
393    fn test_get_module_out_of_range() {
394        let data = make_test_bundle(&[Some("__d(function(){},0);")], "");
395        let bundle = IndexedRamBundle::from_bytes(&data).unwrap();
396
397        assert!(bundle.get_module(0).is_some());
398        assert!(bundle.get_module(1).is_none());
399        assert!(bundle.get_module(999).is_none());
400    }
401
402    #[test]
403    fn test_invalid_magic() {
404        let mut data = make_test_bundle(&[], "");
405        // Corrupt the magic number
406        data[0] = 0x00;
407        let err = IndexedRamBundle::from_bytes(&data).unwrap_err();
408        assert!(matches!(err, RamBundleError::InvalidMagic));
409    }
410
411    #[test]
412    fn test_too_short_header() {
413        let err = IndexedRamBundle::from_bytes(&[0xE5, 0xD1, 0x0B, 0xFB]).unwrap_err();
414        assert!(matches!(err, RamBundleError::TooShort));
415    }
416
417    #[test]
418    fn test_too_short_for_table() {
419        // Valid header claiming 1000 modules but no table data
420        let mut data = Vec::new();
421        data.extend_from_slice(&RAM_BUNDLE_MAGIC.to_le_bytes());
422        data.extend_from_slice(&1000_u32.to_le_bytes());
423        data.extend_from_slice(&0_u32.to_le_bytes());
424        let err = IndexedRamBundle::from_bytes(&data).unwrap_err();
425        assert!(matches!(err, RamBundleError::TooShort));
426    }
427
428    #[test]
429    fn test_module_extends_beyond_data() {
430        // Build a bundle but truncate it
431        let data = make_test_bundle(&[Some("hello world")], "");
432        let truncated = &data[..data.len() - 5];
433        let err = IndexedRamBundle::from_bytes(truncated).unwrap_err();
434        assert!(matches!(err, RamBundleError::InvalidEntry(_)));
435    }
436
437    #[test]
438    fn test_module_iteration_order() {
439        let modules = vec![Some("mod0"), None, Some("mod2"), None, Some("mod4")];
440        let data = make_test_bundle(&modules, "");
441        let bundle = IndexedRamBundle::from_bytes(&data).unwrap();
442
443        let ids: Vec<u32> = bundle.modules().map(|m| m.id).collect();
444        assert_eq!(ids, vec![0, 2, 4]);
445    }
446
447    #[test]
448    fn test_is_unbundle_dir_nonexistent() {
449        assert!(!is_unbundle_dir(Path::new("/nonexistent/path")));
450    }
451
452    #[test]
453    fn test_display_errors() {
454        assert_eq!(RamBundleError::InvalidMagic.to_string(), "invalid RAM bundle magic number");
455        assert_eq!(RamBundleError::TooShort.to_string(), "data too short for RAM bundle header");
456        assert_eq!(
457            RamBundleError::InvalidEntry("bad".to_string()).to_string(),
458            "invalid module entry: bad"
459        );
460    }
461
462    #[test]
463    fn test_ram_bundle_type_equality() {
464        assert_eq!(RamBundleType::Indexed, RamBundleType::Indexed);
465        assert_eq!(RamBundleType::Unbundle, RamBundleType::Unbundle);
466        assert_ne!(RamBundleType::Indexed, RamBundleType::Unbundle);
467    }
468}