From 6cedc883e9b7aa2a726f951bae84441bfd2797af Mon Sep 17 00:00:00 2001 From: Afloat <132809511+Afloat16@users.noreply.github.com> Date: Wed, 4 Nov 2026 01:13:52 +0700 Subject: [PATCH] Keep PyInstaller modules within their PYZ archive (from #707) --- crates/formats/src/pyinstaller.rs | 9 ++++- crates/formats/tests/pyz_module_bounds.rs | 46 +++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 crates/formats/tests/pyz_module_bounds.rs diff --git a/crates/formats/src/pyinstaller.rs b/crates/formats/src/pyinstaller.rs index 77f096d5..4e240b46 100644 --- a/crates/formats/src/pyinstaller.rs +++ b/crates/formats/src/pyinstaller.rs @@ -218,6 +218,7 @@ struct PyzRead { /// How many modules its table holds, which may be more than `entries`. total: usize, encrypted: bool, + outside: usize, } fn looks_zlib(b: &[u8]) -> bool { @@ -254,6 +255,7 @@ fn read_pyz(data: &[u8], member: &PackedEntry, room: usize, key_seen: bool) -> R let unzipped = !samples.is_empty() && samples.iter().all(|b| !looks_zlib(b)); let encrypted = key_seen || key_inside || unzipped; let kind = format!("{PYZ_KIND}{magic:04x}"); + let outside = modules.iter().filter(|m| !in_range(m)).count(); let entries = modules .iter() .map(|m| PackedEntry { @@ -261,11 +263,11 @@ fn read_pyz(data: &[u8], member: &PackedEntry, room: usize, key_seen: bool) -> R offset: member.offset.saturating_add(m.at), stored_size: m.len, size: None, - compression: if encrypted { Compression::Unreadable } else { Compression::Zlib }, + compression: if encrypted || !in_range(m) { Compression::Unreadable } else { Compression::Zlib }, kind: Some(kind.clone()), }) .collect(); - Ok(PyzRead { magic, entries, total, encrypted }) + Ok(PyzRead { magic, entries, total, encrypted, outside }) } impl PackedFormat for PyInstaller { @@ -312,6 +314,9 @@ impl PackedFormat for PyInstaller { "encrypted": read.encrypted, })); truncated |= read.total > read.entries.len(); + if read.outside > 0 { + notes.push(format!("{} module(s) in {} lie outside that PYZ archive and cannot be read.", read.outside, m.entry.path)); + } nested.extend(read.entries); } Err(why) => { diff --git a/crates/formats/tests/pyz_module_bounds.rs b/crates/formats/tests/pyz_module_bounds.rs new file mode 100644 index 00000000..09ef5f9a --- /dev/null +++ b/crates/formats/tests/pyz_module_bounds.rs @@ -0,0 +1,46 @@ +use craft_anything_formats::packed::{Compression, PackedFormat}; +use craft_anything_formats::pyinstaller::PyInstaller; +use craft_anything_testkit::packed::{code_311, marshal as m, pyinstaller}; + +fn sample() -> (Vec, Vec, Vec) { + let code = code_311("", "", "good.py", 1, &[], &[], &[]); + let compressed = miniz_oxide::deflate::compress_to_vec_zlib(&code, 6); + let mut pyz = b"PYZ\0".to_vec(); + pyz.extend_from_slice(&3531u16.to_le_bytes()); + pyz.extend_from_slice(&[0x0d, 0x0a]); + pyz.extend_from_slice(&[0; 8]); + pyz.extend_from_slice(&compressed); + let table_at = pyz.len(); + pyz[8..12].copy_from_slice(&(table_at as u32).to_be_bytes()); + let table = |outside: i32| { + m::list(vec![ + m::tuple(vec![m::string("good"), m::tuple(vec![m::int(0), m::int(16), m::int(compressed.len() as i32)])]), + m::tuple(vec![m::string("outside"), m::tuple(vec![m::int(0), m::int(outside), m::int(compressed.len() as i32)])]), + ]) + }; + let full_len = table_at + table(0).len(); + pyz.extend_from_slice(&table(full_len as i32)); + let container = pyinstaller(&[("PYZ.pyz", b'z', &pyz, false), ("neighbor", b'x', &compressed, false)], true, 312); + (container, compressed, code) +} + +#[test] +fn a_module_cannot_read_compressed_bytes_from_the_next_container_member() { + let (data, _, _) = sample(); + let listing = PyInstaller.list(&data).unwrap(); + let bad = listing.entries.iter().find(|e| e.path == "PYZ.pyz.modules/outside.pyc").unwrap(); + assert_eq!(bad.compression, Compression::Unreadable); + assert!(PyInstaller.read(&data, bad, 1000).is_err()); + assert!(listing.notes.iter().any(|n| n.contains("outside") || n.contains("bounds"))); +} + +#[test] +fn sound_modules_and_neighboring_members_still_read() { + let (data, compressed, code) = sample(); + let listing = PyInstaller.list(&data).unwrap(); + let good = listing.entries.iter().find(|e| e.path == "PYZ.pyz.modules/good.pyc").unwrap(); + let recovered = PyInstaller.read(&data, good, 1000).unwrap(); + assert_eq!(&recovered[16..], code); + let neighbor = listing.entries.iter().find(|e| e.path == "neighbor").unwrap(); + assert_eq!(PyInstaller.read(&data, neighbor, 1000).unwrap(), compressed); +}