use std::collections::BTreeSet;
use std::ffi::OsStr;
use std::fmt::{self, Display};
use std::fs::File;
use std::io::{self, Cursor, Read};
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use memmap2::{Mmap, MmapOptions};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[cfg(feature = "apk")]
use std::str::from_utf8;
use zip::ZipArchive;
use zip::read::ZipFile;
#[cfg(not(any(feature = "zip", feature = "apk", feature = "crx", feature = "ipa")))]
compile_error!("openpack needs at least one feature enabled");
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArchiveFormat {
Zip,
Jar,
Apk,
Ipa,
Crx,
}
impl Display for ArchiveFormat {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let value = match self {
Self::Zip => "zip",
Self::Jar => "jar",
Self::Apk => "apk",
Self::Ipa => "ipa",
Self::Crx => "crx",
};
write!(f, "{value}")
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Limits {
pub max_archive_size: u64,
pub max_entry_uncompressed_size: u64,
pub max_total_uncompressed_size: u64,
pub max_entries: usize,
pub max_compression_ratio: f64,
}
impl Default for Limits {
fn default() -> Self {
Self {
max_archive_size: 256 * 1024 * 1024,
max_entry_uncompressed_size: 50 * 1024 * 1024,
max_total_uncompressed_size: 128 * 1024 * 1024,
max_entries: 2048,
max_compression_ratio: 100.0,
}
}
}
impl Limits {
pub fn from_toml(raw: &str) -> Result<Self, OpenPackError> {
toml::from_str(raw).map_err(|err| OpenPackError::InvalidConfig(err.to_string()))
}
pub fn from_toml_file(path: &Path) -> Result<Self, OpenPackError> {
let mut file = File::open(path)?;
let mut raw = String::new();
file.read_to_string(&mut raw)?;
Self::from_toml(&raw)
}
pub fn builtin() -> Self {
Self::from_toml(include_str!("../config/limits.toml")).unwrap_or_else(|_| Self::default())
}
}
#[derive(Debug, Clone)]
pub struct ArchiveEntry {
pub name: String,
pub compressed_size: u64,
pub uncompressed_size: u64,
pub crc: u32,
pub is_dir: bool,
}
#[derive(Debug)]
pub struct OpenPack {
path: PathBuf,
_file: File,
mmap: Arc<Mmap>,
format: ArchiveFormat,
limits: Limits,
}
impl OpenPack {
pub fn open<P: AsRef<Path>>(path: P, limits: Limits) -> Result<Self, OpenPackError> {
let path = path.as_ref().to_path_buf();
let file = File::open(&path)?;
let metadata = file.metadata()?;
if metadata.len() > limits.max_archive_size {
return Err(OpenPackError::LimitExceeded("archive too large".into()));
}
let mmap = unsafe { MmapOptions::new().map(&file)? };
let mmap = Arc::new(mmap);
let format = detect_format(&path, &mmap)?;
Ok(Self {
path,
_file: file,
mmap,
format,
limits,
})
}
pub fn open_default<P: AsRef<Path>>(path: P) -> Result<Self, OpenPackError> {
Self::open(path, Limits::default())
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn format(&self) -> ArchiveFormat {
self.format
}
pub fn mmap(&self) -> &Mmap {
&self.mmap
}
fn zip_data(&self) -> Result<&[u8], OpenPackError> {
let range = match self.format {
ArchiveFormat::Zip | ArchiveFormat::Jar | ArchiveFormat::Apk | ArchiveFormat::Ipa => {
0..self.mmap.len()
}
ArchiveFormat::Crx => crx_zip_payload_range(&self.mmap)?,
};
Ok(&self.mmap[range])
}
fn open_zip_reader(&self) -> Result<ZipArchive<Cursor<&[u8]>>, OpenPackError> {
let data = self.zip_data()?;
Ok(ZipArchive::new(Cursor::new(data))?)
}
pub fn entries(&self) -> Result<Vec<ArchiveEntry>, OpenPackError> {
let mut archive = self.open_zip_reader()?;
let entry_count = archive.len();
if entry_count > self.limits.max_entries {
return Err(OpenPackError::LimitExceeded("entry count exceeds limit".into()));
}
let mut names = BTreeSet::new();
let mut entries = Vec::with_capacity(entry_count);
let mut total_uncompressed = 0u64;
for i in 0..entry_count {
let mut file = archive.by_index(i)?;
let entry = entry_meta(&mut file)?;
validate_entry_name(&entry.name)?;
if !names.insert(entry.name.clone()) {
return Err(OpenPackError::InvalidArchive("duplicate entry name".into()));
}
if !entry.is_dir {
if entry.uncompressed_size > self.limits.max_entry_uncompressed_size {
return Err(OpenPackError::LimitExceeded(format!(
"entry '{}' exceeds uncompressed size limit",
entry.name
)));
}
let ratio = if entry.compressed_size == 0 {
if entry.uncompressed_size == 0 {
0.0
} else {
f64::INFINITY
}
} else {
entry.uncompressed_size as f64 / entry.compressed_size as f64
};
if ratio > self.limits.max_compression_ratio {
return Err(OpenPackError::LimitExceeded(format!(
"entry '{}' exceeds compression ratio limit",
entry.name
)));
}
total_uncompressed = total_uncompressed.saturating_add(entry.uncompressed_size);
if total_uncompressed > self.limits.max_total_uncompressed_size {
return Err(OpenPackError::LimitExceeded(
"total uncompressed size exceeds limit".into(),
));
}
}
entries.push(entry);
}
Ok(entries)
}
pub fn contains(&self, name: &str) -> Result<bool, OpenPackError> {
validate_entry_name(name)?;
let mut archive = self.open_zip_reader()?;
let result = match archive.by_name(name) {
Ok(file) => {
let _ = file.size();
Ok(true)
}
Err(zip::result::ZipError::FileNotFound) => Ok(false),
Err(err) => Err(OpenPackError::from(err)),
};
result
}
pub fn read_entry(&self, name: &str) -> Result<Vec<u8>, OpenPackError> {
validate_entry_name(name)?;
let mut archive = self.open_zip_reader()?;
let mut file = archive.by_name(name)?;
let entry = entry_meta(&mut file)?;
if entry.uncompressed_size > self.limits.max_entry_uncompressed_size {
return Err(OpenPackError::LimitExceeded(format!(
"entry '{}' exceeds uncompressed size limit",
name
)));
}
let mut data = Vec::new();
file.read_to_end(&mut data)?;
if data.len() as u64 > self.limits.max_entry_uncompressed_size {
return Err(OpenPackError::LimitExceeded(format!(
"entry '{}' decompressed bytes exceed size limit",
name
)));
}
Ok(data)
}
#[cfg(feature = "apk")]
pub fn read_android_manifest(&self) -> Result<AndroidManifest, OpenPackError> {
let bytes = self.read_entry("AndroidManifest.xml")?;
parse_android_manifest(&bytes).ok_or(OpenPackError::InvalidArchive(
"failed parsing AndroidManifest.xml".into(),
))
}
#[cfg(feature = "ipa")]
pub fn read_info_plist(&self) -> Result<IpaInfoPlist, OpenPackError> {
let entry_name = self
.entries()?
.into_iter()
.find_map(|entry| {
entry
.name
.strip_prefix("Payload/")
.filter(|inner| inner.ends_with(".app/Info.plist"))
.map(|_| entry.name)
})
.ok_or_else(|| OpenPackError::MissingEntry("Info.plist".into()))?;
let bytes = self.read_entry(&entry_name)?;
let text = String::from_utf8_lossy(&bytes);
parse_info_plist(&text).ok_or_else(|| {
OpenPackError::InvalidArchive("failed parsing Info.plist".into())
})
}
}
fn detect_format(path: &Path, bytes: &[u8]) -> Result<ArchiveFormat, OpenPackError> {
let ext = path
.extension()
.and_then(OsStr::to_str)
.map(|value| value.to_ascii_lowercase());
let format = match ext.as_deref() {
Some("jar") => ArchiveFormat::Jar,
Some("apk") => ArchiveFormat::Apk,
Some("ipa") => ArchiveFormat::Ipa,
Some("crx") => ArchiveFormat::Crx,
Some("zip") => ArchiveFormat::Zip,
_ if bytes.starts_with(b"Cr24") => ArchiveFormat::Crx,
_ => ArchiveFormat::Zip,
};
#[cfg(not(feature = "crx"))]
if format == ArchiveFormat::Crx {
return Err(OpenPackError::Unsupported);
}
Ok(format)
}
fn crx_zip_payload_range(bytes: &[u8]) -> Result<std::ops::Range<usize>, OpenPackError> {
if bytes.len() < 16 {
return Err(OpenPackError::InvalidArchive("CRX header too short".into()));
}
if &bytes[0..4] != b"Cr24" {
return Err(OpenPackError::InvalidArchive("not a CRX file".into()));
}
let version = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
if version != 2 && version != 3 {
return Err(OpenPackError::InvalidArchive("unsupported CRX version".into()));
}
let pubkey_len = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]) as usize;
let sig_len = u32::from_le_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]) as usize;
let start = 16usize
.checked_add(pubkey_len)
.and_then(|value| value.checked_add(sig_len))
.ok_or_else(|| OpenPackError::InvalidArchive("CRX header overflows".into()))?;
if start >= bytes.len() {
return Err(OpenPackError::InvalidArchive("invalid CRX header lengths".into()));
}
Ok(start..bytes.len())
}
fn entry_meta(file: &mut ZipFile<'_>) -> Result<ArchiveEntry, OpenPackError> {
Ok(ArchiveEntry {
name: file.name().to_string(),
compressed_size: file.compressed_size(),
uncompressed_size: file.size(),
crc: file.crc32(),
is_dir: file.is_dir(),
})
}
fn validate_entry_name(name: &str) -> Result<(), OpenPackError> {
if name.is_empty() {
return Err(OpenPackError::InvalidArchive("empty entry name".into()));
}
if name.starts_with('/') {
return Err(OpenPackError::InvalidArchive("absolute path entry".into()));
}
if name.contains('\\') {
return Err(OpenPackError::InvalidArchive("backslash in entry name".into()));
}
if name.contains("../") || name.ends_with("/..") || name == ".." {
return Err(OpenPackError::ZipSlip(name.to_string()));
}
if Path::new(name)
.components()
.any(|component| matches!(component, Component::ParentDir))
{
return Err(OpenPackError::ZipSlip(name.to_string()));
}
Ok(())
}
#[cfg(feature = "apk")]
#[derive(Debug, Clone)]
pub struct AndroidManifest {
pub package: String,
pub version_name: Option<String>,
pub version_code: Option<String>,
pub min_sdk: Option<String>,
}
#[cfg(feature = "apk")]
fn parse_android_manifest(bytes: &[u8]) -> Option<AndroidManifest> {
let xml = from_utf8(bytes).ok()?;
let package = extract_xml_attr(xml, "package")?;
Some(AndroidManifest {
package,
version_name: extract_xml_attr(xml, "versionName"),
version_code: extract_xml_attr(xml, "versionCode"),
min_sdk: extract_block_attr(xml, "uses-sdk", "android:minSdkVersion")
.or_else(|| extract_block_attr(xml, "uses-sdk", "android:targetSdkVersion")),
})
}
#[cfg(feature = "apk")]
fn extract_xml_attr(xml: &str, attr: &str) -> Option<String> {
let token = format!(" {}=\"", attr);
let start = xml.find(&token)? + token.len();
let rest = &xml[start..];
let end = rest.find('"')?;
Some(rest[..end].to_string())
}
#[cfg(feature = "apk")]
fn extract_block_attr(xml: &str, block: &str, attr: &str) -> Option<String> {
let block_start = xml.find(&format!("<{}", block))?;
let after_block = &xml[block_start..];
let token = format!(" {}=\"", attr);
let start = after_block.find(&token)? + block_start + token.len();
let value_tail = &xml[start..];
let end = value_tail.find('"')?;
Some(value_tail[..end].to_string())
}
#[cfg(feature = "ipa")]
#[derive(Debug, Clone)]
pub struct IpaInfoPlist {
pub bundle_identifier: Option<String>,
pub bundle_version: Option<String>,
pub executable: Option<String>,
}
#[cfg(feature = "ipa")]
fn parse_info_plist(xml: &str) -> Option<IpaInfoPlist> {
Some(IpaInfoPlist {
bundle_identifier: parse_plist_key(xml, "CFBundleIdentifier"),
bundle_version: parse_plist_key(xml, "CFBundleShortVersionString"),
executable: parse_plist_key(xml, "CFBundleExecutable"),
})
}
#[cfg(feature = "ipa")]
fn parse_plist_key(xml: &str, key: &str) -> Option<String> {
let marker = format!("<key>{}</key>", key);
let key_pos = xml.find(&marker)?;
let start = xml[key_pos + marker.len()..].find("<string>")? + key_pos + marker.len() + "<string>".len();
let value_tail = &xml[start..];
let end = value_tail.find("</string>")?;
Some(value_tail[..end].trim().to_string())
}
#[derive(Error, Debug)]
pub enum OpenPackError {
#[error("invalid configuration: {0}")]
InvalidConfig(String),
#[error("I/O error: {0}")]
Io(#[from] io::Error),
#[error("zip error: {0}")]
Zip(#[from] zip::result::ZipError),
#[error("invalid archive: {0}")]
InvalidArchive(String),
#[error("entry blocked by zip-slip prevention: {0}")]
ZipSlip(String),
#[error("missing entry: {0}")]
MissingEntry(String),
#[error("limit exceeded: {0}")]
LimitExceeded(String),
#[error("unsupported archive format")]
Unsupported,
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use zip::CompressionMethod;
use zip::write::SimpleFileOptions;
#[allow(dead_code)]
struct Scratch {
_tmp: tempfile::TempDir,
path: PathBuf,
}
impl Scratch {
fn new(suffix: &str) -> Self {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join(format!("archive.{suffix}"));
Self { _tmp: tmp, path }
}
}
fn write_zip(path: &Path, entries: &[(&str, &[u8], CompressionMethod)]) {
let file = File::create(path).expect("create archive");
let mut zip = zip::ZipWriter::new(file);
for (name, data, method) in entries {
let options = SimpleFileOptions::default().compression_method(*method);
zip.start_file(name, options).expect("start file");
zip.write_all(data).expect("write entry");
}
zip.finish().expect("finish zip");
}
fn write_file(path: &Path, data: &[u8]) {
std::fs::write(path, data).expect("write file");
}
fn crx_payload(payload: &[u8]) -> Vec<u8> {
let mut bytes = Vec::new();
bytes.extend_from_slice(b"Cr24");
bytes.extend_from_slice(&2u32.to_le_bytes());
bytes.extend_from_slice(&0u32.to_le_bytes());
bytes.extend_from_slice(&0u32.to_le_bytes());
bytes.extend_from_slice(payload);
bytes
}
#[test]
fn loads_limits_from_embedded_toml() {
let cfg = include_str!("../config/limits.toml");
let parsed = Limits::from_toml(cfg).expect("valid config");
assert!(parsed.max_entries > 0);
assert!(parsed.max_archive_size > 0);
}
#[test]
fn limits_roundtrip_from_file() {
let fixture = Scratch::new("toml");
write_file(
fixture.path.as_path(),
include_bytes!("../config/limits.toml"),
);
let parsed = Limits::from_toml_file(&fixture.path).expect("loaded from file");
let defaults = Limits::builtin();
assert_eq!(parsed.max_entries, defaults.max_entries);
assert_eq!(parsed.max_compression_ratio, defaults.max_compression_ratio);
}
#[test]
fn invalid_limits_are_rejected() {
let raw = "max_archive_size = \"big\"";
assert!(Limits::from_toml(raw).is_err());
}
#[test]
fn detects_zip_and_other_extensions() {
for (name, expected) in [
("archive.zip", ArchiveFormat::Zip),
("archive.jar", ArchiveFormat::Jar),
("archive.apk", ArchiveFormat::Apk),
("archive.ipa", ArchiveFormat::Ipa),
] {
let fixture = Scratch::new("detect");
let path = fixture.path.with_file_name(name);
write_file(&path, b"PK\x03\x04");
let pack = OpenPack::open_default(&path).expect("open with extension");
assert_eq!(pack.format(), expected);
}
}
#[test]
fn detects_crx_signature_when_enabled() {
let payload = Scratch::new("format");
let zip_payload = payload.path.with_extension("zip");
write_zip(&zip_payload, &[("a.txt", b"hello", CompressionMethod::Stored)]);
let bytes = std::fs::read(&zip_payload).unwrap();
let crx_path = payload.path.with_extension("crx");
#[cfg(feature = "crx")]
{
write_file(&crx_path, &crx_payload(&bytes));
let pack = OpenPack::open_default(&crx_path).expect("open crx");
assert_eq!(pack.format(), ArchiveFormat::Crx);
assert!(pack.entries().is_ok());
}
#[cfg(not(feature = "crx"))]
{
write_file(&crx_path, &crx_payload(&bytes));
assert!(matches!(OpenPack::open_default(&crx_path), Err(OpenPackError::Unsupported)));
}
}
#[test]
fn unknown_extensions_default_to_zip_format() {
let archive = Scratch::new("mystery.dat");
write_file(archive.path.as_path(), b"PK\x03\x04");
let pack = OpenPack::open_default(&archive.path).expect("open");
assert_eq!(pack.format(), ArchiveFormat::Zip);
}
#[test]
fn opening_missing_file_fails() {
let scratch = Scratch::new("missing.zip");
assert!(OpenPack::open_default(scratch.path).is_err());
}
#[test]
fn open_enforces_archive_size_limit() {
let path = Scratch::new("big.zip");
write_file(path.path.as_path(), &vec![0u8; 256]);
let limits = Limits {
max_archive_size: 1,
..Limits::default()
};
assert!(matches!(
OpenPack::open(path.path, limits),
Err(OpenPackError::LimitExceeded(_))
));
}
#[test]
fn lists_entries_and_sizes() {
let archive = Scratch::new("list.zip");
write_zip(
&archive.path,
&[
("a", b"one", CompressionMethod::Stored),
("b", b"two", CompressionMethod::Stored),
],
);
let pack = OpenPack::open_default(&archive.path).expect("open");
let entries = pack.entries().expect("entries");
assert_eq!(entries.len(), 2);
assert!(entries.iter().any(|entry| entry.name == "a"));
assert!(entries.iter().any(|entry| entry.name == "b"));
assert!(entries.iter().all(|entry| !entry.is_dir));
}
#[test]
fn reads_entry_bytes() {
let archive = Scratch::new("read.zip");
write_zip(&archive.path, &[("read.txt", b"hello-world", CompressionMethod::Stored)]);
let pack = OpenPack::open_default(&archive.path).expect("open");
let data = pack.read_entry("read.txt").expect("read");
assert_eq!(data, b"hello-world");
}
#[test]
fn contains_true_and_false() {
let archive = Scratch::new("contains.zip");
write_zip(&archive.path, &[("x", b"1", CompressionMethod::Stored)]);
let pack = OpenPack::open_default(&archive.path).expect("open");
assert!(pack.contains("x").expect("contains x"));
assert!(!pack.contains("missing").expect("contains missing"));
}
#[test]
fn contains_blocks_traversal() {
let archive = Scratch::new("contains.zip");
write_zip(&archive.path, &[("x", b"1", CompressionMethod::Stored)]);
let pack = OpenPack::open_default(&archive.path).expect("open");
assert!(matches!(pack.contains("../x"), Err(OpenPackError::ZipSlip(_))));
}
#[test]
fn read_entry_blocks_traversal() {
let archive = Scratch::new("readbad.zip");
write_zip(&archive.path, &[("x", b"1", CompressionMethod::Stored)]);
let pack = OpenPack::open_default(&archive.path).expect("open");
assert!(matches!(pack.read_entry("../../x"), Err(OpenPackError::ZipSlip(_))));
}
#[test]
fn rejects_zip_slip_entry_names() {
let archive = Scratch::new("zip-slip.zip");
write_zip(
&archive.path,
&[
("good.txt", b"ok", CompressionMethod::Stored),
("../bad.txt", b"bad", CompressionMethod::Stored),
],
);
let pack = OpenPack::open_default(&archive.path).expect("open");
assert!(pack.entries().is_err());
}
#[test]
fn memory_map_is_exposed() {
let archive = Scratch::new("mmap.zip");
write_zip(&archive.path, &[("x", b"1", CompressionMethod::Stored)]);
let pack = OpenPack::open_default(&archive.path).expect("open");
assert_eq!(pack.mmap().len(), std::fs::metadata(&archive.path).unwrap().len() as usize);
}
#[test]
fn read_entry_size_limit_is_enforced() {
let archive = Scratch::new("limit.zip");
let payload = vec![b'a'; 64];
write_zip(&archive.path, &[("big", payload.as_slice(), CompressionMethod::Stored)]);
let mut strict = Limits::default();
strict.max_entry_uncompressed_size = 4;
let pack = OpenPack::open(&archive.path, strict).expect("open");
assert!(matches!(
pack.read_entry("big"),
Err(OpenPackError::LimitExceeded(_))
));
}
#[test]
fn total_uncompressed_size_limit_is_enforced() {
let archive = Scratch::new("total.zip");
let mut entries = vec![];
for i in 0..10 {
entries.push((
format!("file{i}"),
vec![b'a'; 1024].into_boxed_slice(),
CompressionMethod::Stored,
));
}
let file = File::create(&archive.path).expect("create");
let mut zip = zip::ZipWriter::new(file);
for (name, payload, method) in &entries {
let options = SimpleFileOptions::default().compression_method(*method);
zip.start_file(name.as_str(), options).expect("start file");
zip.write_all(payload.as_ref()).expect("write payload");
}
zip.finish().expect("finish");
let mut strict = Limits::default();
strict.max_total_uncompressed_size = 1024;
let pack = OpenPack::open(&archive.path, strict).expect("open");
assert!(matches!(
pack.entries(),
Err(OpenPackError::LimitExceeded(_))
));
}
#[test]
fn compression_ratio_limit_is_enforced() {
let archive = Scratch::new("ratio.zip");
let payload = vec![b'a'; 4 * 1024];
write_zip(
&archive.path,
&[("payload", payload.as_slice(), CompressionMethod::Deflated)],
);
let mut strict = Limits::default();
strict.max_compression_ratio = 0.5;
let pack = OpenPack::open(&archive.path, strict).expect("open");
assert!(pack.entries().is_err());
}
#[test]
fn entry_limit_is_enforced() {
let archive = Scratch::new("entries.zip");
let file = File::create(&archive.path).expect("create");
let mut zip = zip::ZipWriter::new(file);
for i in 0..50 {
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
zip.start_file(format!("item{i}"), options).expect("start");
zip.write_all(b"x").expect("write");
}
zip.finish().expect("finish");
let mut strict = Limits::default();
strict.max_entries = 10;
let pack = OpenPack::open(&archive.path, strict).expect("open");
assert!(matches!(
pack.entries(),
Err(OpenPackError::LimitExceeded(_))
));
}
#[test]
fn list_is_stable_over_multiple_calls() {
let archive = Scratch::new("stable.zip");
write_zip(&archive.path, &[("a", b"1", CompressionMethod::Stored)]);
let pack = OpenPack::open_default(&archive.path).expect("open");
assert_eq!(pack.entries().unwrap().len(), 1);
assert_eq!(pack.entries().unwrap().len(), 1);
}
#[test]
fn reads_entries_multiple_times() {
let archive = Scratch::new("twice.zip");
write_zip(&archive.path, &[("a", b"v", CompressionMethod::Stored)]);
let pack = OpenPack::open_default(&archive.path).expect("open");
let first = pack.read_entry("a").expect("first");
let second = pack.read_entry("a").expect("second");
assert_eq!(first, second);
}
#[test]
fn supports_junit_entry_names() {
let archive = Scratch::new("names.zip");
write_zip(
&archive.path,
&[
("dir/file.txt", b"a", CompressionMethod::Stored),
("dir2/file2.txt", b"b", CompressionMethod::Stored),
],
);
let pack = OpenPack::open_default(&archive.path).expect("open");
let entries = pack.entries().expect("entries");
assert_eq!(entries.len(), 2);
}
#[test]
fn detects_path_component_parents_with_dotdot() {
let archive = Scratch::new("dotzip.zip");
write_zip(
&archive.path,
&[("a/../b.txt", b"x", CompressionMethod::Stored)],
);
let pack = OpenPack::open_default(&archive.path).expect("open");
assert!(pack.entries().is_err());
}
#[test]
fn path_api_returns_original_path() {
let archive = Scratch::new("path.zip");
write_zip(&archive.path, &[("a", b"1", CompressionMethod::Stored)]);
let pack = OpenPack::open_default(&archive.path).expect("open");
assert_eq!(pack.path(), archive.path);
}
#[test]
fn reads_entry_after_listing() {
let archive = Scratch::new("after-list.zip");
write_zip(&archive.path, &[("readme", b"abc", CompressionMethod::Stored)]);
let pack = OpenPack::open_default(&archive.path).expect("open");
assert_eq!(pack.entries().unwrap().len(), 1);
assert_eq!(pack.read_entry("readme").unwrap(), b"abc");
}
#[cfg(feature = "apk")]
#[test]
fn parse_android_manifest() {
let archive = Scratch::new("app.apk");
let manifest = r#"<manifest package="com.example.app" versionName="1.2.3" versionCode="5"><uses-sdk android:minSdkVersion="21"/></manifest>"#;
write_zip(
&archive.path,
&[("AndroidManifest.xml", manifest.as_bytes(), CompressionMethod::Stored)],
);
let pack = OpenPack::open_default(&archive.path).expect("open");
let parsed = pack.read_android_manifest().expect("manifest");
assert_eq!(parsed.package, "com.example.app");
assert_eq!(parsed.version_name.as_deref(), Some("1.2.3"));
}
#[cfg(feature = "ipa")]
#[test]
fn parse_ipa_info_plist() {
let archive = Scratch::new("app.ipa");
let plist = r#"
<plist>
<dict>
<key>CFBundleIdentifier</key><string>com.example.bundle</string>
<key>CFBundleExecutable</key><string>Binary</string>
<key>CFBundleShortVersionString</key><string>4.2.1</string>
</dict>
</plist>
"#;
write_zip(
&archive.path,
&[("Payload/App.app/Info.plist", plist.as_bytes(), CompressionMethod::Stored)],
);
let pack = OpenPack::open_default(&archive.path).expect("open");
let parsed = pack.read_info_plist().expect("plist");
assert_eq!(parsed.bundle_identifier.as_deref(), Some("com.example.bundle"));
}
#[cfg(feature = "crx")]
#[test]
fn handles_crx_with_nested_zip() {
let archive = Scratch::new("crx.zip");
write_zip(&archive.path, &[("x", b"hello", CompressionMethod::Stored)]);
let payload = std::fs::read(&archive.path).expect("read payload");
let crx = Scratch::new("crx" );
let crx_path = crx.path;
write_file(&crx_path, &crx_payload(&payload));
let pack = OpenPack::open_default(&crx_path).expect("open crx");
assert!(pack.entries().is_ok());
}
}