Skip to main content

asar_rust/
asar.rs

1use crate::crawlfs::{crawl, FileMetadata, FileType};
2use crate::disk::{
3    read_archive_header_sync, read_file_sync, read_filesystem_sync, write_filesystem,
4    ArchiveHeader, AsarError,
5};
6use crate::filesystem::{Filesystem, FilesystemEntry, ListOptions};
7use crate::integrity::FileIntegrity;
8use crate::path_validation::ensure_within;
9use glob::Pattern;
10use std::collections::HashMap;
11use std::fs;
12use std::io::{Read, Seek};
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15
16/// Options for creating an ASAR archive.
17///
18/// Controls which files to include, file ordering,
19/// and which files should be stored unpacked.
20pub struct CreateOptions {
21    /// Include dotfiles (files and directories starting with `.`).
22    pub dot: bool,
23    /// Path to a file specifying the ordering of files in the archive.
24    pub ordering: Option<PathBuf>,
25    /// Glob pattern for files to store unpacked alongside the archive.
26    pub unpack: Option<String>,
27    /// Glob pattern for directories whose contents should be stored unpacked.
28    pub unpack_dir: Option<String>,
29}
30
31impl Default for CreateOptions {
32    fn default() -> Self {
33        CreateOptions {
34            dot: true,
35            ordering: None,
36            unpack: None,
37            unpack_dir: None,
38        }
39    }
40}
41
42/// An ASAR archive that can be read from or written to disk.
43///
44/// # Creating archives
45///
46/// ```no_run
47/// use asar_rust::AsarArchive;
48/// use std::path::Path;
49///
50/// let archive = AsarArchive::pack(Path::new("src"), Path::new("app.asar")).unwrap();
51/// ```
52///
53/// # Reading archives
54///
55/// ```no_run
56/// use asar_rust::AsarArchive;
57/// use std::path::Path;
58///
59/// let archive = AsarArchive::open(Path::new("app.asar")).unwrap();
60/// let files = archive.list().unwrap();
61/// let data = archive.extract_file("main.js").unwrap();
62/// ```
63pub struct AsarArchive {
64    filesystem: Arc<Filesystem>,
65}
66
67impl AsarArchive {
68    /// Create a new ASAR archive from a directory.
69    pub fn pack(src: &Path, dest: &Path) -> Result<Self, AsarError> {
70        create_package(src, dest)?;
71        Self::open(dest)
72    }
73
74    /// Create an ASAR archive with custom options.
75    pub fn pack_with_options(src: &Path, dest: &Path, options: CreateOptions) -> Result<Self, AsarError> {
76        create_package_with_options(src, dest, options)?;
77        Self::open(dest)
78    }
79
80    /// Open an existing ASAR archive for reading.
81    pub fn open(archive_path: &Path) -> Result<Self, AsarError> {
82        let filesystem = read_filesystem_sync(archive_path)?;
83        Ok(AsarArchive { filesystem })
84    }
85
86    /// Get the raw archive header.
87    pub fn raw_header(&self) -> Result<ArchiveHeader, AsarError> {
88        get_raw_header(self.filesystem.root_path())
89    }
90
91    /// List all files in the archive.
92    pub fn list(&self) -> Result<Vec<String>, AsarError> {
93        Ok(self.filesystem.list_files(None))
94    }
95
96    /// List files with pack/unpack state.
97    pub fn list_with_flags(&self, opts: ListOptions) -> Result<Vec<String>, AsarError> {
98        Ok(self.filesystem.list_files(Some(&opts)))
99    }
100
101    /// Get metadata for a file in the archive.
102    pub fn stat(&self, filename: &str, follow_links: bool) -> Result<FilesystemEntry, AsarError> {
103        self.filesystem.get_file(filename, follow_links).cloned()
104    }
105
106    /// Extract a single file's contents.
107    pub fn extract_file(&self, filename: &str) -> Result<Vec<u8>, AsarError> {
108        let info = self.filesystem.get_file(filename, true)?;
109        match info {
110            FilesystemEntry::File(file_entry) => read_file_sync(&self.filesystem, filename, file_entry),
111            FilesystemEntry::Directory(_) => Err(AsarError::Other(format!("Expected file but found directory: {}", filename))),
112            FilesystemEntry::Link(_) => Err(AsarError::Other(format!("Expected file but found link: {}", filename))),
113        }
114    }
115
116    /// Extract all files from the archive to a destination directory.
117    pub fn extract_all(&self, dest: &Path) -> Result<(), AsarError> {
118        extract_all_from_fs(&self.filesystem, dest)
119    }
120}
121
122/// Create a new ASAR archive from a directory using default options.
123pub fn create_package(src: &Path, dest: &Path) -> Result<(), AsarError> {
124    create_package_with_options(src, dest, CreateOptions::default())
125}
126
127/// Create a new ASAR archive from a directory with custom options.
128pub fn create_package_with_options(
129    src: &Path,
130    dest: &Path,
131    options: CreateOptions,
132) -> Result<(), AsarError> {
133    let pattern = format!("{}/**/*", src.display());
134    let (filenames, metadata) = crawl(&pattern)?;
135    let (filenames, metadata) = if options.dot {
136        (filenames, metadata)
137    } else {
138        let filtered: Vec<_> = filenames.into_iter().filter(|p| {
139            let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
140            !name.starts_with('.')
141        }).collect();
142        let filtered_set: std::collections::HashSet<_> = filtered.iter().cloned().collect();
143        let filtered_meta: HashMap<_, _> = metadata.into_iter()
144            .filter(|(p, _)| filtered_set.contains(p))
145            .collect();
146        (filtered, filtered_meta)
147    };
148    create_package_from_files(src, dest, &filenames, metadata, options)
149}
150
151fn canonicalize_stripped(path: &Path) -> PathBuf {
152    let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
153    let s = canonical.to_string_lossy();
154    if cfg!(windows) && s.starts_with(r"\\?\") {
155        PathBuf::from(&s[4..])
156    } else {
157        canonical
158    }
159}
160
161/// Create an ASAR archive from a pre-crawled list of files.
162///
163/// This is the lower-level entry point used by `create_package` and
164/// `create_package_with_options`. It takes a pre-computed list of
165/// filenames and their metadata.
166pub fn create_package_from_files(
167    src: &Path,
168    dest: &Path,
169    filenames: &[PathBuf],
170    metadata: HashMap<PathBuf, FileMetadata>,
171    options: CreateOptions,
172) -> Result<(), AsarError> {
173    let canonical_src = canonicalize_stripped(src);
174    let dest = dest.to_path_buf();
175
176    let mut filesystem = Filesystem::new(&canonical_src);
177    let mut file_entries: Vec<(PathBuf, bool)> = Vec::new();
178
179    let mut filenames_sorted: Vec<&PathBuf> = filenames.iter().collect();
180    filenames_sorted.sort_unstable();
181
182    if let Some(ref ordering_path) = options.ordering
183        && let Ok(content) = fs::read_to_string(ordering_path)
184    {
185        let ordering_files: Vec<String> = content
186            .lines()
187            .map(|line| {
188                let line = if line.contains(':') {
189                    line.split(':').next_back().unwrap_or(line)
190                } else {
191                    line
192                };
193                line.trim().trim_start_matches('/').to_string()
194            })
195            .collect();
196
197        let mut path_index: std::collections::HashMap<&str, Vec<&PathBuf>> = std::collections::HashMap::new();
198        for filename in filenames {
199            let fname = filename.to_str().unwrap_or("");
200            path_index.entry(fname).or_default().push(filename);
201        }
202
203        let mut sorted = Vec::new();
204        let mut seen: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
205
206        for ordering_file in &ordering_files {
207            if let Some(matches) = path_index.get(ordering_file.as_str()) {
208                for filename in matches {
209                    if seen.insert((*filename).clone()) {
210                        sorted.push(*filename);
211                    }
212                }
213            }
214        }
215
216        for filename in filenames {
217            if seen.insert(filename.clone()) {
218                sorted.push(filename);
219            }
220        }
221
222        filenames_sorted = sorted;
223    }
224
225    for filename in &filenames_sorted {
226        let file_meta = metadata.get(*filename);
227        let file_type = file_meta.map(|m| m.file_type.clone());
228        let abs_filename = canonicalize_stripped(filename);
229        let archive_path = abs_filename.strip_prefix(&canonical_src).unwrap_or(&abs_filename);
230
231        let should_unpack = {
232            let fname = archive_path.to_str().unwrap_or("");
233            let mut unpack = false;
234            if let Some(ref unpack_pattern) = options.unpack {
235                unpack = Pattern::new(unpack_pattern)
236                    .map(|p| p.matches(fname))
237                    .unwrap_or(false);
238            }
239            if !unpack
240                && let Some(ref unpack_dir_pattern) = options.unpack_dir
241            {
242                unpack = Pattern::new(unpack_dir_pattern)
243                    .map(|p| p.matches(fname))
244                    .unwrap_or(false);
245            }
246            unpack
247        };
248
249        match file_type {
250            Some(FileType::Directory) => {
251                filesystem.insert_directory(archive_path, should_unpack)?;
252            }
253            Some(FileType::File) => {
254                let size = file_meta.unwrap().size;
255                let executable = false;
256                let integrity = compute_file_integrity(&abs_filename);
257                filesystem.insert_file(archive_path, size, executable, should_unpack, integrity)?;
258                file_entries.push((abs_filename, should_unpack));
259            }
260            Some(FileType::Link) => {
261                let link = fs::read_link(filename)
262                    .map_err(AsarError::Io)?
263                    .to_str()
264                    .unwrap_or("")
265                    .to_string();
266                let resolved = resolve_link(&canonical_src, archive_path, &link);
267                if Path::new(&resolved).is_absolute() || resolved.starts_with("..") {
268                    return Err(AsarError::Other(format!(
269                        "{}: file \"{}\" links out of the package",
270                        filename.display(),
271                        resolved
272                    )));
273                }
274                filesystem.insert_link(archive_path, resolved, should_unpack)?;
275                file_entries.push((abs_filename, should_unpack));
276            }
277            None => {
278                return Err(AsarError::Other(format!(
279                    "Unknown file type: {}",
280                    filename.display()
281                )));
282            }
283        }
284    }
285
286    write_filesystem(&dest, &filesystem, &file_entries, &metadata)
287}
288
289fn compute_file_integrity(path: &Path) -> Option<FileIntegrity> {
290    if let Ok(mut file) = fs::File::open(path) {
291        FileIntegrity::from_reader(&mut file).ok()
292    } else {
293        None
294    }
295}
296
297fn resolve_link(src: &Path, parent_path: &Path, symlink: &str) -> String {
298    let parent = parent_path.parent().unwrap_or(Path::new("."));
299    let target = parent.join(symlink);
300    target
301        .strip_prefix(src)
302        .unwrap_or(&target)
303        .to_str()
304        .unwrap_or("")
305        .to_string()
306}
307
308/// Get metadata for a file in the archive.
309pub fn stat_file(
310    archive_path: &Path,
311    filename: &str,
312    follow_links: bool,
313) -> Result<FilesystemEntry, AsarError> {
314    let filesystem = read_filesystem_sync(archive_path)?;
315    filesystem
316        .get_file(filename, follow_links)
317        .cloned()
318}
319
320/// Read the raw archive header without caching the filesystem.
321pub fn get_raw_header(archive_path: &Path) -> Result<ArchiveHeader, AsarError> {
322    read_archive_header_sync(archive_path)
323}
324
325/// List all files in the archive.
326pub fn list_package(
327    archive_path: &Path,
328    options: Option<ListOptions>,
329) -> Result<Vec<String>, AsarError> {
330    let filesystem = read_filesystem_sync(archive_path)?;
331    Ok(filesystem.list_files(options.as_ref()))
332}
333
334/// Extract a single file's contents from the archive.
335pub fn extract_file(
336    archive_path: &Path,
337    filename: &str,
338    follow_links: bool,
339) -> Result<Vec<u8>, AsarError> {
340    let filesystem = read_filesystem_sync(archive_path)?;
341    let info = filesystem
342        .get_file(filename, follow_links)?;
343
344    match info {
345        FilesystemEntry::File(file_entry) => read_file_sync(&filesystem, filename, file_entry),
346        FilesystemEntry::Directory(_) => Err(AsarError::Other(format!(
347            "Expected file but found directory: {}",
348            filename
349        ))),
350        FilesystemEntry::Link(_) => Err(AsarError::Other(format!(
351            "Expected file but found link: {}",
352            filename
353        ))),
354    }
355}
356
357/// Extract all files from the archive to a destination directory.
358pub fn extract_all(archive_path: &Path, dest: &Path) -> Result<(), AsarError> {
359    let filesystem = read_filesystem_sync(archive_path)?;
360    extract_all_from_fs(&filesystem, dest)
361}
362
363fn extract_all_from_fs(filesystem: &Arc<Filesystem>, dest: &Path) -> Result<(), AsarError> {
364    let file_list = filesystem.list_files(None);
365
366    fs::create_dir_all(dest)?;
367
368    // Pre-read entire data section for efficient extraction
369    let archive_path = filesystem.root_path();
370    let header_size = filesystem.header_size() as u64;
371    let archive_size = fs::metadata(archive_path)?.len();
372    let data_start = 8 + header_size;
373    let data_size = archive_size.saturating_sub(data_start);
374    let mut data_buf = Vec::new();
375    if data_size > 0 {
376        let mut file = fs::File::open(archive_path)?;
377        file.seek(std::io::SeekFrom::Start(data_start))?;
378        let ds = usize::try_from(data_size).map_err(|_| AsarError::Other("data size overflow".into()))?;
379        data_buf.resize(ds, 0);
380        file.read_exact(&mut data_buf)?;
381    }
382
383    let mut extraction_errors: Vec<String> = Vec::new();
384
385    for full_path in &file_list {
386        let filename = &full_path[1..];
387        let dest_filename = ensure_within(dest, filename)?;
388
389        let file_entry = filesystem.get_file(filename, cfg!(windows))?;
390
391        match file_entry {
392            FilesystemEntry::Directory(_) => {
393                fs::create_dir_all(&dest_filename)?;
394            }
395            FilesystemEntry::Link(link_entry) => {
396                let link_path = dest.join(&link_entry.link);
397                let dest_dir = dest_filename.parent().unwrap_or(Path::new("."));
398                let _relative_path = pathdiff::diff_paths(&link_path, dest_dir)
399                    .unwrap_or_else(|| PathBuf::from(&link_entry.link));
400                let _ = fs::remove_file(&dest_filename);
401                #[cfg(unix)]
402                {
403                    std::os::unix::fs::symlink(&_relative_path, &dest_filename)?;
404                }
405                #[cfg(windows)]
406                {
407                    if let Some(parent) = dest_filename.parent() {
408                        fs::create_dir_all(parent)?;
409                    }
410                }
411            }
412            FilesystemEntry::File(file_info) => {
413                let result: Result<Vec<u8>, AsarError> = if file_info.unpacked {
414                    let unpacked_dir = format!("{}.unpacked", filesystem.root_path().display());
415                        fs::read(ensure_within(Path::new(&unpacked_dir), filename)?)
416                            .map_err(AsarError::Io)
417                } else if file_info.size == 0 {
418                    Ok(Vec::new())
419                } else {
420                    let offset: u64 = file_info.offset.parse().map_err(|_| AsarError::Other("Invalid offset".to_string()))?;
421                    let start = usize::try_from(offset).map_err(|_| AsarError::Other("offset overflow".into()))?;
422                    let end = start + usize::try_from(file_info.size).map_err(|_| AsarError::Other("size overflow".into()))?;
423                    if end <= data_buf.len() {
424                        Ok(data_buf[start..end].to_vec())
425                    } else {
426                        Err(AsarError::Other("Data out of bounds".to_string()))
427                    }
428                };
429
430                match result {
431                    Ok(content) => {
432                        if let Some(parent) = dest_filename.parent() {
433                            fs::create_dir_all(parent)?;
434                        }
435                        fs::write(&dest_filename, content)?;
436                        #[cfg(unix)]
437                        if file_info.executable {
438                            use std::os::unix::fs::PermissionsExt;
439                            if let Ok(meta) = fs::metadata(&dest_filename) {
440                                let mut perms = meta.permissions();
441                                perms.set_mode(0o755);
442                                let _ = fs::set_permissions(&dest_filename, perms);
443                            }
444                        }
445                    }
446                    Err(e) => {
447                        extraction_errors.push(format!("{}: {}", full_path, e));
448                    }
449                }
450            }
451        }
452    }
453
454    if !extraction_errors.is_empty() {
455        return Err(AsarError::Other(format!(
456            "Unable to extract some files:\n\n{}",
457            extraction_errors.join("\n\n")
458        )));
459    }
460
461    Ok(())
462}