asar-rust 0.1.0

Rust port of @electron/asar — create and extract Electron ASAR archives
Documentation
use crate::disk::AsarError;
use crate::integrity::FileIntegrity;
use indexmap::IndexMap;
use std::collections::HashSet;
use std::path::{Component, Path, PathBuf};

const SYMLINK_MAX_DEPTH: usize = 40;
const UINT32_MAX: u64 = 4_294_967_295;

/// Represents a file entry in an ASAR archive.
///
/// Files are stored sequentially in the archive body. The `offset`
/// field records the byte position within the data section.
#[derive(Debug, Clone)]
pub struct FileEntry {
    /// Byte offset of this file's content within the archive data section.
    pub offset: String,
    /// Size of the file in bytes.
    pub size: u64,
    /// Whether the file has the executable permission bit set.
    pub executable: bool,
    /// Whether this file is stored unpacked alongside the archive.
    pub unpacked: bool,
    /// SHA256 integrity hashes for this file.
    pub integrity: Option<FileIntegrity>,
}

/// Represents a directory entry in an ASAR archive.
#[derive(Debug, Clone)]
pub struct DirectoryEntry {
    /// Child entries in this directory, keyed by name.
    pub files: IndexMap<String, FilesystemEntry>,
    /// Whether this directory is stored unpacked.
    pub unpacked: bool,
}

/// Represents a symbolic link entry in an ASAR archive.
#[derive(Debug, Clone)]
pub struct LinkEntry {
    /// The target path of the symbolic link.
    pub link: String,
    /// Whether this link is stored unpacked.
    pub unpacked: bool,
}

/// An entry in the ASAR filesystem tree.
#[derive(Debug, Clone)]
pub enum FilesystemEntry {
    File(FileEntry),
    Directory(DirectoryEntry),
    Link(LinkEntry),
}

impl FilesystemEntry {
    pub fn is_directory(&self) -> bool {
        matches!(self, FilesystemEntry::Directory(_))
    }

    pub fn is_file(&self) -> bool {
        matches!(self, FilesystemEntry::File(_))
    }

    pub fn is_link(&self) -> bool {
        matches!(self, FilesystemEntry::Link(_))
    }
}

#[derive(Debug, Clone)]
pub struct Filesystem {
    src: PathBuf,
    header: FilesystemEntry,
    header_size: u32,
    offset: u64,
}

impl Filesystem {
    pub fn new(src: &Path) -> Self {
        Filesystem {
            src: src.to_path_buf(),
            header: FilesystemEntry::Directory(DirectoryEntry {
                files: IndexMap::new(),
                unpacked: false,
            }),
            header_size: 0,
            offset: 0,
        }
    }

    pub fn root_path(&self) -> &Path {
        &self.src
    }

    pub fn get_header(&self) -> &FilesystemEntry {
        &self.header
    }

    pub fn header_size(&self) -> u32 {
        self.header_size
    }

    pub fn set_header(&mut self, header: FilesystemEntry, header_size: u32) {
        self.header = header;
        self.header_size = header_size;
    }

    pub fn current_offset(&self) -> u64 {
        self.offset
    }

    pub fn advance_offset(&mut self, size: u64) {
        self.offset += size;
    }

    fn search_node_from_directory(
        &mut self,
        p: &Path,
    ) -> Result<&mut FilesystemEntry, AsarError> {
        let mut current = &mut self.header;
        for component in p.components() {
            if matches!(component, Component::RootDir | Component::CurDir) {
                continue;
            }
            let name = component.as_os_str().to_str().unwrap_or("");
            if name.is_empty() {
                continue;
            }
            if let FilesystemEntry::Directory(dir) = current {
                current = dir.files.entry(name.to_string()).or_insert_with(|| {
                    FilesystemEntry::Directory(DirectoryEntry {
                        files: IndexMap::new(),
                        unpacked: false,
                    })
                });
            } else {
                return Err(AsarError::Other(format!(
                    "Unexpected directory state while traversing: {}",
                    p.display()
                )));
            }
        }
        Ok(current)
    }

    fn search_node_from_path(&mut self, p: &Path) -> Result<&mut FilesystemEntry, AsarError> {
        let relative = p.strip_prefix(&self.src).unwrap_or(p);
        if relative.as_os_str().is_empty() {
            return Ok(&mut self.header);
        }
        let parent = relative.parent().unwrap_or(Path::new("."));
        let name = relative.file_name().unwrap().to_str().unwrap_or("");

        let node = self.search_node_from_directory(parent)?;
        if let FilesystemEntry::Directory(dir) = node {
            let entry = dir.files.entry(name.to_string()).or_insert_with(|| {
                FilesystemEntry::File(FileEntry {
                    offset: "0".to_string(),
                    size: 0,
                    executable: false,
                    unpacked: false,
                    integrity: None,
                })
            });
            Ok(entry)
        } else {
            Err(AsarError::Other(format!(
                "Unexpected state while searching path: {}",
                p.display()
            )))
        }
    }

    pub fn insert_directory(&mut self, p: &Path, should_unpack: bool) -> Result<(), AsarError> {
        let node = self.search_node_from_path(p)?;
        match node {
            FilesystemEntry::Directory(dir) => {
                if should_unpack {
                    dir.unpacked = true;
                }
            }
            _ => {
                *node = FilesystemEntry::Directory(DirectoryEntry {
                    files: IndexMap::new(),
                    unpacked: should_unpack,
                });
            }
        }
        Ok(())
    }

    pub fn insert_file(
        &mut self,
        p: &Path,
        size: u64,
        executable: bool,
        should_unpack: bool,
        integrity: Option<FileIntegrity>,
    ) -> Result<(), AsarError> {
        if !should_unpack && size > UINT32_MAX {
            return Err(AsarError::FileTooLarge {
                path: p.display().to_string(),
            });
        }

        let offset = self.offset;
        let parent = p.parent().unwrap_or(Path::new("."));
        let relative_parent = parent.strip_prefix(&self.src).unwrap_or(parent);
        let parent_node = self.search_node_from_directory(relative_parent)?;
        let parent_unpacked = match parent_node {
            FilesystemEntry::Directory(dir) => dir.unpacked,
            _ => false,
        };

        let node = self.search_node_from_path(p)?;
        match node {
            FilesystemEntry::File(file) => {
                if should_unpack || parent_unpacked {
                    file.size = size;
                    file.unpacked = true;
                    file.integrity = integrity;
                } else {
                    file.size = size;
                    file.offset = offset.to_string();
                    file.executable = executable;
                    file.integrity = integrity;
                    self.offset = offset + size;
                }
            }
            _ => {
                return Err(AsarError::Other(format!(
                    "Expected file entry for: {}",
                    p.display()
                )));
            }
        }
        Ok(())
    }

    pub fn insert_link(&mut self, p: &Path, link: String, should_unpack: bool) -> Result<(), AsarError> {
        let parent = p.parent().unwrap_or(Path::new("."));
        let relative_parent = parent.strip_prefix(&self.src).unwrap_or(parent);
        let parent_node = self.search_node_from_directory(relative_parent)?;
        let parent_unpacked = match parent_node {
            FilesystemEntry::Directory(dir) => dir.unpacked,
            _ => false,
        };

        let node = self.search_node_from_path(p)?;
        *node = FilesystemEntry::Link(LinkEntry {
            link,
            unpacked: should_unpack || parent_unpacked,
        });
        Ok(())
    }

    pub fn list_files(&self, options: Option<&ListOptions>) -> Vec<String> {
        let mut files = Vec::with_capacity(64);
        self.fill_files_from_metadata("/", &self.header, &mut files, options);
        files
    }

    fn fill_files_from_metadata(
        &self,
        base_path: &str,
        metadata: &FilesystemEntry,
        files: &mut Vec<String>,
        options: Option<&ListOptions>,
    ) {
        if let FilesystemEntry::Directory(dir) = metadata {
            let mut keys: Vec<&String> = dir.files.keys().collect();
            keys.sort_unstable();
            for name in keys {
                let child = &dir.files[name];
                let full_path = if base_path == "/" {
                    format!("/{}", name)
                } else {
                    format!("{}/{}", base_path, name)
                };

                let display = if let Some(opts) = options
                    && opts.is_pack
                {
                    let state = match child {
                        FilesystemEntry::File(f) if f.unpacked => "unpack",
                        FilesystemEntry::Link(l) if l.unpacked => "unpack",
                        FilesystemEntry::Directory(d) if d.unpacked => "unpack",
                        _ => "pack  ",
                    };
                    format!("{} : {}", state, full_path)
                } else {
                    full_path.clone()
                };
                files.push(display);

                self.fill_files_from_metadata(&full_path, child, files, options);
            }
        }
    }

    pub fn get_file(&self, p: &str, follow_links: bool) -> Result<&FilesystemEntry, AsarError> {
        self.get_file_internal(p, follow_links, 0, &mut HashSet::new())
    }

    fn check_symlink(&self, p: &str, link_target: &str, depth: usize, visited: &mut HashSet<String>) -> Result<(), AsarError> {
        if visited.contains(link_target) {
            return Err(AsarError::CircularSymlink(format!("\"{}\": circular symlink detected at \"{}\"", p, link_target)));
        }
        if depth >= SYMLINK_MAX_DEPTH {
            return Err(AsarError::SymlinkDepth);
        }
        visited.insert(link_target.to_string());
        Ok(())
    }

    fn get_file_internal(
        &self,
        p: &str,
        follow_links: bool,
        depth: usize,
        visited: &mut HashSet<String>,
    ) -> Result<&FilesystemEntry, AsarError> {
        let info = self.get_node_internal(p, follow_links, depth, visited)?;

        if let FilesystemEntry::Link(link_entry) = info
            && follow_links
        {
            let link = link_entry.link.clone();
            self.check_symlink(p, &link, depth, visited)?;
            return self.get_file_internal(&link, follow_links, depth + 1, visited);
        }

        Ok(info)
    }

    fn get_node_internal(
        &self,
        p: &str,
        follow_links: bool,
        depth: usize,
        visited: &mut HashSet<String>,
    ) -> Result<&FilesystemEntry, AsarError> {
        let path = Path::new(p);
        let parent = path.parent().unwrap_or(Path::new("."));
        let name = path.file_name().unwrap_or_default().to_str().unwrap_or("");

        let node = self.search_node_from_directory_readonly(parent);

        if let FilesystemEntry::Link(link_entry) = node
            && follow_links
        {
            let resolved = Path::new(&link_entry.link).join(name);
            let resolved_str = resolved.to_str().unwrap_or("").to_string();
            self.check_symlink(p, &resolved_str, depth, visited)?;
            return self.get_node_internal(&resolved_str, follow_links, depth + 1, visited);
        }

        if name.is_empty() {
            Ok(node)
        } else if let FilesystemEntry::Directory(dir) = node {
            dir.files
                .get(name)
                .ok_or_else(|| AsarError::NotFound(format!("\"{}\" was not found in this archive", p)))
        } else {
            Err(AsarError::NotFound(format!("\"{}\" was not found in this archive", p)))
        }
    }

    fn search_node_from_directory_readonly(&self, p: &Path) -> &FilesystemEntry {
        let mut current = &self.header;
        for component in p.components() {
            if matches!(component, Component::RootDir | Component::CurDir) {
                continue;
            }
            let name = component.as_os_str().to_str().unwrap_or("");
            if name.is_empty() {
                continue;
            }
            if let FilesystemEntry::Directory(dir) = current {
                match dir.files.get(name) {
                    Some(node) => current = node,
                    None => return current,
                }
            } else {
                return current;
            }
        }
        current
    }
}

/// Options for listing files in an ASAR archive.
///
/// When `is_pack` is true, each entry includes a prefix
/// indicating whether the file is packed or unpacked.
pub struct ListOptions {
    pub is_pack: bool,
}