From 9a7b9b89364c196b652546889bbc40e30ef44022 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:36:42 +0530 Subject: [PATCH] fix: upgrade zip to 9.5.0 and flag local/central header mismatches zip 2.4.2 has open advisories, including the critical "zip cloaking" parser differential, where local file headers and the central directory describe different files. - Upgrade zip to 9.5.0 with only pure-Rust deflate (zlib-rs), pinned exactly because the reader uses `zip::unstable::read::ZipIterable`. - Read entries with `ZipIterable`, which walks the central directory in order and keeps duplicate names like JSZip. zip 9's `ZipArchive` indexes entries by name and drops duplicates, which would hide the copy Acode installs. - New `archive.header_mismatch` (High): a separate pass validates every local header against the central directory, so a cloaked zip needs review while the installed files are still scanned. - Decode entry names as UTF-8 like JSZip instead of falling back to CP437. - Add raw-zip tests for exact duplicate names and cloaked headers. - Bump rules version to 2026.11.2. --- Cargo.lock | 211 +++++++++++++++++++++++++++++++++--------------- Cargo.toml | 7 +- README.md | 10 ++- src/archive.rs | 214 +++++++++++++++++++++++++++++++++++++++++++------ src/report.rs | 2 +- 5 files changed, 348 insertions(+), 96 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c944a0c..79797c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,15 +79,6 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] - [[package]] name = "ariadne" version = "0.6.0" @@ -146,6 +137,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "clap" version = "4.6.1" @@ -177,7 +174,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -206,6 +203,15 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "constant_time_eq" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb6feb175df65d0a3ccd3e73484e940d0ac664e8034f0519fd49a93db3071d9" +dependencies = [ + "rustversion", +] + [[package]] name = "cow-utils" version = "0.1.3" @@ -230,12 +236,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - [[package]] name = "crypto-common" version = "0.1.7" @@ -246,17 +246,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "derive_arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "digest" version = "0.10.7" @@ -267,17 +256,6 @@ dependencies = [ "crypto-common", ] -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "dragonbox_ecma" version = "0.1.12" @@ -318,8 +296,8 @@ version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ - "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -345,10 +323,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -414,6 +394,16 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -454,6 +444,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nonmax" version = "0.5.5" @@ -529,7 +531,7 @@ checksum = "b237422b014f8f8fff75bb9379e697d13f8d57551a22c88bebb39f073c1bf696" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -571,7 +573,7 @@ dependencies = [ "phf", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -780,7 +782,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -821,7 +823,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -953,7 +955,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1021,6 +1023,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae9ccbea3a7b0b09443036490c8f34a8b1b760fd345e5c0dae0aac718faea79" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -1062,9 +1075,15 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] +[[package]] +name = "typed-path" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" + [[package]] name = "typenum" version = "1.20.0" @@ -1137,6 +1156,50 @@ dependencies = [ "wit-bindgen 0.51.0", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +dependencies = [ + "cfg-if", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.10", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -1222,7 +1285,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -1238,7 +1301,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -1286,37 +1349,53 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zip" -version = "2.4.2" +version = "9.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +checksum = "0700e6c427aff64acdbf02e85fbfd946c2ee12319d1af013092f3a2178914ff8" dependencies = [ - "arbitrary", + "constant_time_eq", "crc32fast", - "crossbeam-utils", - "displaydoc", "flate2", + "getrandom", "indexmap", + "libc", "memchr", - "thiserror", - "zopfli", + "nix", + "typed-path", + "windows-sys", + "zeroize", ] [[package]] -name = "zmij" -version = "1.0.21" +name = "zlib-rs" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" [[package]] -name = "zopfli" -version = "0.8.3" +name = "zmij" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" -dependencies = [ - "bumpalo", - "crc32fast", - "log", - "simd-adler32", -] +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index f7f7665..fadbd27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,8 +18,11 @@ sha2 = "0.10" thiserror = "2.0.17" # Acode installs plugins with JSZip, which only understands stored and deflated # entries. Matching that keeps the scanner from reading content the installer -# can't, and avoids pulling native compression libraries into the server build. -zip = { version = "2.4.2", default-features = false, features = ["deflate"] } +# can't, and avoids pulling native compression libraries into the server build +# (zlib-rs is pure Rust). Pinned exactly because the archive reader uses +# `zip::unstable::read::ZipIterable`, the only reader that keeps duplicate +# entry names the way JSZip does. +zip = { version = "=9.5.0", default-features = false, features = ["deflate-flate2-zlib-rs"] } regex = "1" base64 = "0.22" diff --git a/README.md b/README.md index ac3f826..bbdefc6 100644 --- a/README.md +++ b/README.md @@ -132,8 +132,12 @@ plugin as critical (`e.read()`, `n.copy()`, `window.cordova`, webpack's - native binaries detected by magic bytes - code under `__MACOSX/`, `.DS_Store` or `Thumbs.db` names, which unzip tools hide but Acode installs (real metadata is recognised by its header) + - local file headers that disagree with the central directory ("zip + cloaking"): Acode and the scanner read the central directory, but tools + that read local headers would see different files - Overwritten entries are still analysed. + Overwritten entries are still analysed. Names are decoded as UTF-8, as + JSZip does. - **Entry resolution like `installPlugin.js` and `loadPlugin.js`.** `main` is looked up by exact key, so `"./dist/main.js"` falls back to `main.js`, just as on device. The loader then joins the raw `main` onto the plugin folder, @@ -160,7 +164,7 @@ plugin as critical (`e.read()`, `n.copy()`, `window.cordova`, webpack's | Area | Rule IDs (severity) | | --- | --- | -| Archive | `install_path_collision` (C), `path_traversal`, `duplicate_entry`, `encrypted_entry`, `unsupported_compression`, `oversized_entry`, `total_size_limit`, `too_many_entries`, `native_binary` (H), `absolute_path`, `nested_archive`, `metadata_path_content` (M) | +| Archive | `install_path_collision` (C), `path_traversal`, `duplicate_entry`, `encrypted_entry`, `unsupported_compression`, `oversized_entry`, `total_size_limit`, `too_many_entries`, `native_binary`, `header_mismatch` (H), `absolute_path`, `nested_archive`, `metadata_path_content` (M) | | Manifest | `missing`, `invalid_json`, `no_entrypoint`, `main_escapes_plugin_dir` (C), `missing_id/name/version`, `readme_password_form` (readme or changelog renders a password form; DOMPurify keeps forms) (H), `invalid_id`, `invalid_version`, `invalid_price`, `invalid_min_version_code`, `missing_icon`, `missing_readme`, `non_js_entry`, `main_loader_mismatch`, `readme_form` (M), `dependencies`, `icon_too_large` (L), `main_fallback` (I) | | Shell | `remote_command` (C), `decoded_command` (H), `dangerous_command` (C/H/M: download-and-run, decode-and-run, reverse shell, miner, wiping `/sdcard`/`$HOME`, other apps' data, `su`, download-then-chmod, SSH keys / stored credentials, curl file uploads, piping env or files to the network, untrusted package sources, cron/boot persistence, shell-profile persistence), `load_library` (C), `exec` (H/M/L by what the command runs; keys end in `:program`, `:inspect`, or `write:input`), `terminal_module`, `terminal_environment`, `process_control` (M/H), `script_file` (L; shipped `.sh` / `#!` scripts get the same command checks) | | Native | `plugin_context_bridge` (`cordova.exec` to `Tee`), `cordova_exec_dynamic` (C), `account_bridge` (`Authenticator`; C for `saveToken`, else H), `cordova_exec`, `manage_all_files`, `in_app_purchase` (H), `permission_request`, `launch_app`, `intent_access`, `purchase_read`, `cordova_require`, `cordova_plugin` (M), `file_intent`, `device_setting`, `os_integration` (L) | @@ -181,7 +185,7 @@ Prefixes are omitted in the table; full IDs look like `shell.exec`. { "schema_version": 2, "scanner_version": "0.4.0", - "rules_version": "2026.11.1", // rescan stored plugins when this changes + "rules_version": "2026.11.2", // rescan stored plugins when this changes "plugin": { "id", "name", "version", "main", "entry", "permissions", "dependencies", ... }, "verdict": { "risk": "high", "recommendation": "review", "complete": true, "reasons": ["..."] }, "summary": { "total_findings", "by_severity", "by_category" }, diff --git a/src/archive.rs b/src/archive.rs index b880f1b..5920288 100644 --- a/src/archive.rs +++ b/src/archive.rs @@ -23,7 +23,11 @@ use std::{ use sha2::{Digest, Sha256}; use thiserror::Error; -use zip::{CompressionMethod, ZipArchive}; +use zip::{ + CompressionMethod, ZipReadOptions, + read::Config, + unstable::read::{ZipIterable, ZipIterableFiles}, +}; use crate::{ report::{FileEntry, Finding, Report}, @@ -84,23 +88,30 @@ impl PluginArchive { } pub fn from_reader( - reader: R, + mut reader: R, limits: Limits, report: &mut Report, ) -> Result { - let mut zip = ZipArchive::new(reader)?; + check_header_consistency(&mut reader, limits, report)?; + + // `ZipIterable` walks the central directory in order and keeps + // duplicate names, like JSZip. (`ZipArchive` indexes entries by name + // and drops duplicates, which would hide the copy that gets installed.) + let mut zip = ZipIterable::try_new(&mut reader, Config::default())?; + let entry_count = zip.len(); + let entries = zip.files()?; let mut archive = Self::default(); let mut total_bytes = 0u64; - report.stats.archive_entries = zip.len(); + report.stats.archive_entries = entry_count; - if zip.len() > limits.max_entries { + if entry_count > limits.max_entries { report.push( Finding::new( "archive.too_many_entries", Severity::High, Category::Archive, "Archive has more entries than the scanner will read", - format!("{} entries, limit {}", zip.len(), limits.max_entries), + format!("{entry_count} entries, limit {}", limits.max_entries), ) .with_file("archive"), ); @@ -110,21 +121,25 @@ impl PluginArchive { // Paths written by more than one distinct raw key. let mut writers: BTreeMap> = BTreeMap::new(); - for index in 0..zip.len().min(limits.max_entries) { - let meta = match zip.by_index_raw(index) { - Ok(entry) => EntryMeta { - name: entry.name().to_string(), - is_dir: entry.is_dir(), - encrypted: entry.encrypted(), - compression: entry.compression(), - size: entry.size(), - compressed: entry.compressed_size(), - }, - Err(error) => { + for index in 0..entry_count.min(limits.max_entries) { + let entry = match entries.next() { + Some(Ok(entry)) => entry, + Some(Err(error)) => { + // The central directory can't be walked past a bad record. report.error(Some("archive"), format!("entry #{index}: {error}")); report.mark_incomplete(); - continue; + break; } + None => break, + }; + let meta = EntryMeta { + // JSZip decodes names as UTF-8 whatever the encoding flag says. + name: String::from_utf8_lossy(entry.name_raw()).into_owned(), + is_dir: entry.is_dir(), + encrypted: entry.encrypted(), + compression: entry.compression(), + size: entry.size(), + compressed: entry.compressed_size(), }; let raw = meta.name.clone(); @@ -234,12 +249,7 @@ impl PluginArchive { // The declared size can lie, so cap the read itself. let mut bytes = Vec::with_capacity(meta.size.min(limits.max_entry_bytes) as usize); - let read = zip.by_index(index).and_then(|entry| { - entry - .take(limits.max_entry_bytes + 1) - .read_to_end(&mut bytes) - .map_err(Into::into) - }); + let read = read_entry(entries, entry, limits.max_entry_bytes + 1, &mut bytes); if let Err(error) = read { report.error(Some(&path), format!("failed to read entry: {error}")); report.mark_incomplete(); @@ -402,6 +412,56 @@ impl PluginArchive { } } +/// Reads one entry's content, capped at `limit` bytes. +fn read_entry( + entries: &mut ZipIterableFiles, + entry: zip::read::ZipFileEntry<'static>, + limit: u64, + bytes: &mut Vec, +) -> zip::result::ZipResult<()> { + let file = entry.with_reader(entries.reader_mut(), ZipReadOptions::new())?; + file.take(limit).read_to_end(bytes)?; + Ok(()) +} + +/// Compares every local file header with its central-directory record. +/// +/// Acode (JSZip) and this scanner both trust the central directory, but +/// tools that stream local headers (and some unzip tools reviewers use) do +/// not. When the two disagree on a name, compression, flags, or sizes, the +/// zip shows different files to different readers ("zip cloaking"). This is a +/// separate pass because a failed check stops a pass, and the main read should +/// still see every entry. +fn check_header_consistency( + reader: &mut R, + limits: Limits, + report: &mut Report, +) -> Result<(), ArchiveError> { + let mut zip = + ZipIterable::try_new(&mut *reader, Config::default())?.enforce_header_consistency(true); + for (index, entry) in zip.files()?.take(limits.max_entries).enumerate() { + let Err(error) = entry else { + continue; + }; + let message = error.to_string(); + if message.contains("header inconsistency") { + report.push( + Finding::new( + "archive.header_mismatch", + Severity::High, + Category::Archive, + "Local file header disagrees with the central directory, so other zip tools can see different files than Acode installs", + format!("entry #{index}: {message}"), + ) + .with_file("archive"), + ); + } + // Other errors stop the main pass too, which reports them. + break; + } + Ok(()) +} + struct EntryMeta { name: String, is_dir: bool, @@ -544,6 +604,112 @@ mod tests { (archive, report) } + fn crc32(bytes: &[u8]) -> u32 { + let mut crc = !0u32; + for byte in bytes { + crc ^= u32::from(*byte); + for _ in 0..8 { + crc = if crc & 1 == 1 { + (crc >> 1) ^ 0xEDB8_8320 + } else { + crc >> 1 + }; + } + } + !crc + } + + /// A stored zip written by hand, so tests can build what ZipWriter + /// refuses to: duplicate names, and local headers that disagree with the + /// central directory. Entries are (central name, local name, content). + fn raw_zip(entries: &[(&str, &str, &[u8])]) -> Vec { + let mut out = Vec::new(); + let mut central = Vec::new(); + for (central_name, local_name, data) in entries { + let offset = out.len() as u32; + let (crc, size) = (crc32(data), data.len() as u32); + out.extend(0x0403_4b50u32.to_le_bytes()); + out.extend([20, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + out.extend(crc.to_le_bytes()); + out.extend(size.to_le_bytes()); + out.extend(size.to_le_bytes()); + out.extend((local_name.len() as u16).to_le_bytes()); + out.extend(0u16.to_le_bytes()); + out.extend(local_name.as_bytes()); + out.extend(*data); + + central.extend(0x0201_4b50u32.to_le_bytes()); + central.extend([20, 0, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + central.extend(crc.to_le_bytes()); + central.extend(size.to_le_bytes()); + central.extend(size.to_le_bytes()); + central.extend((central_name.len() as u16).to_le_bytes()); + central.extend([0u8; 12]); + central.extend(offset.to_le_bytes()); + central.extend(central_name.as_bytes()); + } + let (start, length) = (out.len() as u32, central.len() as u32); + out.extend(central); + out.extend(0x0605_4b50u32.to_le_bytes()); + out.extend([0u8; 4]); + out.extend((entries.len() as u16).to_le_bytes()); + out.extend((entries.len() as u16).to_le_bytes()); + out.extend(length.to_le_bytes()); + out.extend(start.to_le_bytes()); + out.extend(0u16.to_le_bytes()); + out + } + + fn read_raw(entries: &[(&str, &str, &[u8])]) -> (PluginArchive, Report) { + let mut report = Report::new(); + let archive = PluginArchive::from_reader( + Cursor::new(raw_zip(entries)), + Limits::default(), + &mut report, + ) + .unwrap(); + (archive, report) + } + + #[test] + fn keeps_every_copy_of_a_duplicate_name() { + let (archive, report) = read_raw(&[ + ("main.js", "main.js", b"safe()"), + ("main.js", "main.js", b"Executor.execute('id')"), + ]); + assert!(report.has("archive.duplicate_entry")); + assert!(!report.has("archive.header_mismatch")); + // JSZip keeps the last copy, for install and for lookups. + assert_eq!( + archive.installed("main.js").unwrap().bytes, + b"Executor.execute('id')" + ); + assert_eq!( + archive.by_key("main.js").unwrap().bytes, + b"Executor.execute('id')" + ); + // The overwritten copy is still analysed. + assert_eq!(archive.javascript_files().len(), 2); + } + + #[test] + fn flags_local_headers_that_disagree_with_the_central_directory() { + let (archive, report) = read_raw(&[ + ("plugin.json", "plugin.json", b"{}"), + ("main.js", "readme.txt", b"eval(atob(p))"), + ]); + assert!( + report.has("archive.header_mismatch"), + "{:?}", + report.findings + ); + // What Acode installs (central-directory names) is still scanned. + assert!(archive.installed("main.js").is_some()); + + let (_, report) = read_raw(&[("main.js", "main.js", b"x")]); + assert!(!report.has("archive.header_mismatch")); + } + #[test] fn sanitizes_like_acode_installer() { assert_eq!(sanitize_zip_path("a/../plugin.json"), "plugin.json"); diff --git a/src/report.rs b/src/report.rs index ecaee90..e1d5eb4 100644 --- a/src/report.rs +++ b/src/report.rs @@ -7,7 +7,7 @@ use crate::severity::{Category, Confidence, Severity}; /// Bumped whenever the JSON shape changes incompatibly. pub const SCHEMA_VERSION: u32 = 2; /// Bumped whenever rules or severities change, so stored reports can be rescanned. -pub const RULES_VERSION: &str = "2026.11.1"; +pub const RULES_VERSION: &str = "2026.11.2"; /// Upper bound on extra source locations kept per aggregated finding. const MAX_LOCATIONS: usize = 8;