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 = 0xFB0BD1E5;
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
160            .checked_add(table_size)
161            .ok_or(RamBundleError::TooShort)?;
162
163        if data.len() < table_end {
164            return Err(RamBundleError::TooShort);
165        }
166
167        // Startup code comes right after the module table
168        let startup_start = table_end;
169        let startup_end = startup_start
170            .checked_add(startup_code_size)
171            .ok_or(RamBundleError::TooShort)?;
172
173        if data.len() < startup_end {
174            return Err(RamBundleError::TooShort);
175        }
176
177        let startup_code = std::str::from_utf8(&data[startup_start..startup_end])
178            .map_err(|e| {
179                RamBundleError::InvalidEntry(format!("startup code is not valid UTF-8: {e}"))
180            })?
181            .to_owned();
182
183        // The base offset for module data is right after startup code
184        let modules_base = startup_end;
185
186        let mut modules = Vec::with_capacity(module_count as usize);
187
188        for i in 0..module_count as usize {
189            let entry_offset = HEADER_SIZE + i * MODULE_ENTRY_SIZE;
190            let offset = read_u32_le(data, entry_offset).unwrap() as usize;
191            let length = read_u32_le(data, entry_offset + 4).unwrap() as usize;
192
193            if offset == 0 && length == 0 {
194                modules.push(None);
195                continue;
196            }
197
198            let abs_start = modules_base.checked_add(offset).ok_or_else(|| {
199                RamBundleError::InvalidEntry(format!("module {i} offset overflows"))
200            })?;
201            let abs_end = abs_start.checked_add(length).ok_or_else(|| {
202                RamBundleError::InvalidEntry(format!("module {i} length overflows"))
203            })?;
204
205            if abs_end > data.len() {
206                return Err(RamBundleError::InvalidEntry(format!(
207                    "module {i} extends beyond data (offset={offset}, length={length}, data_len={})",
208                    data.len()
209                )));
210            }
211
212            let source_code = std::str::from_utf8(&data[abs_start..abs_end])
213                .map_err(|e| {
214                    RamBundleError::InvalidEntry(format!(
215                        "module {i} source is not valid UTF-8: {e}"
216                    ))
217                })?
218                .to_owned();
219
220            modules.push(Some(RamBundleModule {
221                id: i as u32,
222                source_code,
223            }));
224        }
225
226        Ok(Self {
227            module_count,
228            startup_code,
229            modules,
230        })
231    }
232
233    /// Returns the number of module slots in the bundle.
234    pub fn module_count(&self) -> u32 {
235        self.module_count
236    }
237
238    /// Returns a module by its ID, or `None` if the slot is empty.
239    pub fn get_module(&self, id: u32) -> Option<&RamBundleModule> {
240        self.modules.get(id as usize)?.as_ref()
241    }
242
243    /// Iterates over all non-empty modules in the bundle.
244    pub fn modules(&self) -> impl Iterator<Item = &RamBundleModule> {
245        self.modules.iter().filter_map(|m| m.as_ref())
246    }
247
248    /// Returns the startup (prelude) code.
249    pub fn startup_code(&self) -> &str {
250        &self.startup_code
251    }
252}
253
254/// Check if data starts with the RAM bundle magic number.
255///
256/// Requires at least 4 bytes.
257pub fn is_ram_bundle(data: &[u8]) -> bool {
258    read_u32_le(data, 0) == Some(RAM_BUNDLE_MAGIC)
259}
260
261/// Check if a path looks like an unbundle (file RAM bundle) directory.
262///
263/// Returns `true` if the path contains a `js-modules` subdirectory.
264pub fn is_unbundle_dir(path: &Path) -> bool {
265    path.join("js-modules").is_dir()
266}
267
268/// Read a little-endian `u32` from `data` at the given byte offset.
269fn read_u32_le(data: &[u8], offset: usize) -> Option<u32> {
270    if offset + 4 > data.len() {
271        return None;
272    }
273    Some(u32::from_le_bytes([
274        data[offset],
275        data[offset + 1],
276        data[offset + 2],
277        data[offset + 3],
278    ]))
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    /// Build a valid indexed RAM bundle from module source strings.
286    ///
287    /// `modules` is a slice of optional source code strings. `None` entries
288    /// produce empty module table slots (offset=0, length=0).
289    fn make_test_bundle(modules: &[Option<&str>], startup: &str) -> Vec<u8> {
290        let mut data = Vec::new();
291
292        // Header
293        data.extend_from_slice(&RAM_BUNDLE_MAGIC.to_le_bytes());
294        data.extend_from_slice(&(modules.len() as u32).to_le_bytes());
295        data.extend_from_slice(&(startup.len() as u32).to_le_bytes());
296
297        // Build module bodies and compute offsets.
298        // Offsets are relative to the start of the module data section
299        // (which comes after the header + table + startup code).
300        let mut module_bodies: Vec<(u32, u32)> = Vec::new();
301        let mut current_offset: u32 = 0;
302
303        for module in modules {
304            match module {
305                Some(src) => {
306                    let len = src.len() as u32;
307                    module_bodies.push((current_offset, len));
308                    current_offset += len;
309                }
310                None => {
311                    module_bodies.push((0, 0));
312                }
313            }
314        }
315
316        // Module table
317        for &(offset, length) in &module_bodies {
318            data.extend_from_slice(&offset.to_le_bytes());
319            data.extend_from_slice(&length.to_le_bytes());
320        }
321
322        // Startup code
323        data.extend_from_slice(startup.as_bytes());
324
325        // Module source code
326        for module in modules.iter().flatten() {
327            data.extend_from_slice(module.as_bytes());
328        }
329
330        data
331    }
332
333    #[test]
334    fn test_is_ram_bundle() {
335        let data = make_test_bundle(&[], "");
336        assert!(is_ram_bundle(&data));
337    }
338
339    #[test]
340    fn test_is_ram_bundle_wrong_magic() {
341        let data = [0x00, 0x00, 0x00, 0x00];
342        assert!(!is_ram_bundle(&data));
343    }
344
345    #[test]
346    fn test_is_ram_bundle_too_short() {
347        assert!(!is_ram_bundle(&[0xE5, 0xD1, 0x0B]));
348        assert!(!is_ram_bundle(&[]));
349    }
350
351    #[test]
352    fn test_parse_empty_bundle() {
353        let data = make_test_bundle(&[], "var x = 1;");
354        let bundle = IndexedRamBundle::from_bytes(&data).unwrap();
355        assert_eq!(bundle.module_count(), 0);
356        assert_eq!(bundle.startup_code(), "var x = 1;");
357        assert_eq!(bundle.modules().count(), 0);
358    }
359
360    #[test]
361    fn test_parse_single_module() {
362        let data = make_test_bundle(&[Some("__d(function(){},0);")], "startup();");
363        let bundle = IndexedRamBundle::from_bytes(&data).unwrap();
364
365        assert_eq!(bundle.module_count(), 1);
366        assert_eq!(bundle.startup_code(), "startup();");
367
368        let module = bundle.get_module(0).unwrap();
369        assert_eq!(module.id, 0);
370        assert_eq!(module.source_code, "__d(function(){},0);");
371    }
372
373    #[test]
374    fn test_parse_multiple_modules() {
375        let modules = vec![
376            Some("__d(function(){console.log('a')},0);"),
377            Some("__d(function(){console.log('b')},1);"),
378            Some("__d(function(){console.log('c')},2);"),
379        ];
380        let data = make_test_bundle(&modules, "require(0);");
381        let bundle = IndexedRamBundle::from_bytes(&data).unwrap();
382
383        assert_eq!(bundle.module_count(), 3);
384        assert_eq!(bundle.startup_code(), "require(0);");
385
386        for (i, module) in bundle.modules().enumerate() {
387            assert_eq!(module.id, i as u32);
388            assert!(
389                module
390                    .source_code
391                    .contains(&format!("'{}'", (b'a' + i as u8) as char))
392            );
393        }
394    }
395
396    #[test]
397    fn test_empty_module_slots() {
398        let modules = vec![
399            Some("__d(function(){},0);"),
400            None,
401            Some("__d(function(){},2);"),
402        ];
403        let data = make_test_bundle(&modules, "");
404        let bundle = IndexedRamBundle::from_bytes(&data).unwrap();
405
406        assert_eq!(bundle.module_count(), 3);
407        assert!(bundle.get_module(0).is_some());
408        assert!(bundle.get_module(1).is_none());
409        assert!(bundle.get_module(2).is_some());
410
411        // Only 2 non-empty modules
412        assert_eq!(bundle.modules().count(), 2);
413    }
414
415    #[test]
416    fn test_get_module_out_of_range() {
417        let data = make_test_bundle(&[Some("__d(function(){},0);")], "");
418        let bundle = IndexedRamBundle::from_bytes(&data).unwrap();
419
420        assert!(bundle.get_module(0).is_some());
421        assert!(bundle.get_module(1).is_none());
422        assert!(bundle.get_module(999).is_none());
423    }
424
425    #[test]
426    fn test_invalid_magic() {
427        let mut data = make_test_bundle(&[], "");
428        // Corrupt the magic number
429        data[0] = 0x00;
430        let err = IndexedRamBundle::from_bytes(&data).unwrap_err();
431        assert!(matches!(err, RamBundleError::InvalidMagic));
432    }
433
434    #[test]
435    fn test_too_short_header() {
436        let err = IndexedRamBundle::from_bytes(&[0xE5, 0xD1, 0x0B, 0xFB]).unwrap_err();
437        assert!(matches!(err, RamBundleError::TooShort));
438    }
439
440    #[test]
441    fn test_too_short_for_table() {
442        // Valid header claiming 1000 modules but no table data
443        let mut data = Vec::new();
444        data.extend_from_slice(&RAM_BUNDLE_MAGIC.to_le_bytes());
445        data.extend_from_slice(&1000_u32.to_le_bytes());
446        data.extend_from_slice(&0_u32.to_le_bytes());
447        let err = IndexedRamBundle::from_bytes(&data).unwrap_err();
448        assert!(matches!(err, RamBundleError::TooShort));
449    }
450
451    #[test]
452    fn test_module_extends_beyond_data() {
453        // Build a bundle but truncate it
454        let data = make_test_bundle(&[Some("hello world")], "");
455        let truncated = &data[..data.len() - 5];
456        let err = IndexedRamBundle::from_bytes(truncated).unwrap_err();
457        assert!(matches!(err, RamBundleError::InvalidEntry(_)));
458    }
459
460    #[test]
461    fn test_module_iteration_order() {
462        let modules = vec![Some("mod0"), None, Some("mod2"), None, Some("mod4")];
463        let data = make_test_bundle(&modules, "");
464        let bundle = IndexedRamBundle::from_bytes(&data).unwrap();
465
466        let ids: Vec<u32> = bundle.modules().map(|m| m.id).collect();
467        assert_eq!(ids, vec![0, 2, 4]);
468    }
469
470    #[test]
471    fn test_is_unbundle_dir_nonexistent() {
472        assert!(!is_unbundle_dir(Path::new("/nonexistent/path")));
473    }
474
475    #[test]
476    fn test_display_errors() {
477        assert_eq!(
478            RamBundleError::InvalidMagic.to_string(),
479            "invalid RAM bundle magic number"
480        );
481        assert_eq!(
482            RamBundleError::TooShort.to_string(),
483            "data too short for RAM bundle header"
484        );
485        assert_eq!(
486            RamBundleError::InvalidEntry("bad".to_string()).to_string(),
487            "invalid module entry: bad"
488        );
489    }
490
491    #[test]
492    fn test_ram_bundle_type_equality() {
493        assert_eq!(RamBundleType::Indexed, RamBundleType::Indexed);
494        assert_eq!(RamBundleType::Unbundle, RamBundleType::Unbundle);
495        assert_ne!(RamBundleType::Indexed, RamBundleType::Unbundle);
496    }
497}