diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..83a35fe3 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,31 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/rust +{ + "name": "Rust", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "image": "mcr.microsoft.com/devcontainers/rust:1-1-bookworm" + + // Use 'mounts' to make the cargo cache persistent in a Docker Volume. + // "mounts": [ + // { + // "source": "devcontainer-cargo-cache-${devcontainerId}", + // "target": "/usr/local/cargo", + // "type": "volume" + // } + // ] + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "rustc --version", + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..751528a4 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "vendor/inno"] + path = vendor/inno + url = https://github.com/russellbanks/inno diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..f5303b8b --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,48 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "lldb", + "request": "launch", + "name": "Debug executable 'komac'", + "cargo": { + "args": [ + "build", + "--bin=komac", + "--package=komac" + ], + "filter": { + "name": "komac", + "kind": "bin" + } + }, + "args": [ + "analyse", + ".idea/installshield/AllShare_2.1.0.12031_10.exe" + ], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug unit tests in executable 'komac'", + "cargo": { + "args": [ + "test", + "--no-run", + "--bin=komac", + "--package=komac" + ], + "filter": { + "name": "komac", + "kind": "bin" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..c3d33727 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "files.autoSave": "off" +} \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1868cee9..64a96d10 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,11 +17,11 @@ Contributions are what make the open source community such an amazing place to l ### Testing your changes -Using Docker is the easiest way to to test your code before submitting a pull request. +Using Docker is the easiest way to to test your code before submitting a pull request. > [!NOTE] > When using the Docker container on Windows, the WSL engine does not support the default collection for keys or tokens. This means that when testing inside the container GitHub tokens will not be stored, even when `komac token update` is used. -> +> > This is a [known issue](https://github.com/hwchen/keyring-rs/blob/47c8daf3e6178a2282ae3e8670d1ea7fa736b8cb/src/secret_service.rs#L73-L77) which is documented in the keyring crate. > > As a workaround, you can set the `GITHUB_TOKEN` environment variable from within the container, in the `docker run` command, or in the Dockerfile itself @@ -30,4 +30,31 @@ Using Docker is the easiest way to to test your code before submitting a pull re 2. Run `docker build ./ --tag komac_dev:latest`. 3. Wait for the build to complete. 4. Start the container using `docker run -it komac_dev bash`. -5. Test out any commands. Use the `exit` command to quit the container \ No newline at end of file +5. Test out any commands. Use the `exit` command to quit the container + +### Known Bugs + +#### Burn + +| Error | Examples | Comment | +|---------|---------------------|---------| +| `Expected RParen, got Some(Ident("or"))` | python-3.12.9-amd64.exe | Can't parse InstallCondition with implied evaluation eg A or B or C | + +#### Inno + +The error message is typically `failed to fill whole buffer` with no further details. + +| Version | Examples | Comment | +|---------|---------------------|---------| +| 5570 | BHPIO CAD Build v9.1 for Microstation Connect 060522.exe | | +| 5500u | ClassicStickyNotes-2.0-setup.exe | | +| 3061 | e-bility5.00.64g.exe | | +| 4260 | MoffFreeCalcSetup.exe | | +| 5570 | Ultranalysis Suite 3 Base Setup.exe | | + +#### NSIS + +| Error | Examples | Comment | +|---------|---------------------|---------| +| `The conversion failed because the source bytes are not a valid value of the destination type. Destination type: [komac::installers::nsis::entry::Entry]` | iTwinStudioSetup.exe | | +| Leading unicode in `DefaultInstallLocation` | mbbServiceSetup.exe
scopephoto.exe | Failing to parse app root? | diff --git a/Cargo.lock b/Cargo.lock index aa31c7f8..b9ade02c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2497,8 +2497,6 @@ dependencies = [ [[package]] name = "inno" version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9160c92116c0e94f433e116cee3e1a56522f615c5a7cf6b84ef8a76d111dd193" dependencies = [ "bitflags", "codepage", @@ -3051,6 +3049,15 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "md-5" version = "0.10.6" @@ -5361,10 +5368,14 @@ version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" dependencies = [ + "matchers", "nu-ansi-term", + "once_cell", + "regex-automata", "sharded-slab", "smallvec", "thread_local", + "tracing", "tracing-core", "tracing-log", ] diff --git a/Cargo.toml b/Cargo.toml index 221eb073..d8588725 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,9 +79,9 @@ supports-hyperlinks = "3.1.0" tempfile = "3.23.0" thiserror = "2.0.17" tokio = { version = "1.48.0", features = ["rt-multi-thread", "macros", "fs", "parking_lot"] } -tracing = { version = "0.1.41", features = ["release_max_level_warn"] } +tracing = "0.1.41" tracing-indicatif = "0.3.13" -tracing-subscriber = "0.3.20" +tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } tree-sitter-highlight = "0.25.10" tree-sitter-yaml = "0.7.2" tui-textarea = { version = "0.7.0", features = ["search"] } @@ -110,3 +110,6 @@ rstest = "0.26.1" assets = [ { source = "target/release/komac", dest = "/usr/bin/komac", mode = "755" }, ] + +[patch.crates-io] +inno = { path = "vendor/inno/core" } diff --git a/src/analysis/extensions.rs b/src/analysis/extensions.rs index 1770c97c..8bb58dd3 100644 --- a/src/analysis/extensions.rs +++ b/src/analysis/extensions.rs @@ -5,3 +5,4 @@ pub const APPX: &str = "appx"; pub const MSIX_BUNDLE: &str = "msixbundle"; pub const APPX_BUNDLE: &str = "appxbundle"; pub const ZIP: &str = "zip"; +pub const APPINSTALLER: &str = "appinstaller"; diff --git a/src/analysis/installers/burn/mod.rs b/src/analysis/installers/burn/mod.rs index 6b6f3ef0..1f252b67 100644 --- a/src/analysis/installers/burn/mod.rs +++ b/src/analysis/installers/burn/mod.rs @@ -18,7 +18,7 @@ use thiserror::Error; use tracing::debug; use winget_types::installer::{ AppsAndFeaturesEntries, AppsAndFeaturesEntry, Architecture, InstallationMetadata, Installer, - InstallerType, Scope, + InstallerSwitches, InstallerType, Scope, }; use wix_burn_stub::WixBurnStub; use yara_x::mods::{ @@ -213,6 +213,29 @@ impl Installers for Burn { ..InstallationMetadata::default() }) .unwrap_or_default(), + switches: InstallerSwitches::builder() + .maybe_custom({ + let mut switches = manifest + .variables + .iter() + .map(|variable| { + format!( + "{}=\"{}\"", + variable.id(), + variable + .resolved_value() + .unwrap_or_default() + .replace("NOT_SET", "") + ) + }) + // Exclude some built-in variables + // https://docs.firegiant.com/wix3/bundle/bundle_built_in_variables/ + .filter(|switch| !switch.starts_with("WixBundle")) + .collect::>(); + switches.sort(); + switches.join(" ").parse().ok() + }) + .build(), ..Installer::default() }] } diff --git a/src/analysis/installers/exe.rs b/src/analysis/installers/exe.rs index f8935d4a..2092041b 100644 --- a/src/analysis/installers/exe.rs +++ b/src/analysis/installers/exe.rs @@ -2,22 +2,24 @@ use std::io::{Read, Seek}; use color_eyre::Result; use inno::{Inno, error::InnoError}; -use winget_types::installer::{Architecture, Installer, InstallerType}; +use winget_types::installer::{Architecture, Installer, InstallerSwitches, InstallerType}; use yara_x::mods::PE; -use super::{super::Installers, Burn, Nsis}; +use super::{super::Installers, Burn, InstallShield, Nsis}; use crate::{ - analysis::installers::{burn::BurnError, nsis::NsisError}, + analysis::installers::{burn::BurnError, installshield::InstallShieldError, nsis::NsisError}, traits::FromMachine, }; const ORIGINAL_FILENAME: &str = "OriginalFilename"; +const INTERNAL_NAME: &str = "InternalName"; const FILE_DESCRIPTION: &str = "FileDescription"; -const BASIC_INSTALLER_KEYWORDS: [&str; 4] = ["installer", "setup", "7zs.sfx", "7zsd.sfx"]; +const BASIC_INSTALLER_KEYWORDS: [&str; 2] = ["installer", "setup"]; pub enum Exe { Burn(Box), Inno(Box), + InstallShield(Box), Nsis(Nsis), Generic(Box), } @@ -36,15 +38,46 @@ impl Exe { Err(error) => return Err(error.into()), } + match InstallShield::new(&mut reader, pe) { + Ok(installshield) => return Ok(Self::InstallShield(Box::new(installshield))), + Err(InstallShieldError::NotInstallShieldFile) => {} + Err(error) => return Err(error.into()), + } + match Nsis::new(&mut reader, pe) { Ok(nsis) => return Ok(Self::Nsis(nsis)), Err(NsisError::NotNsisFile) => {} Err(error) => return Err(error.into()), } - Ok(Self::Generic(Box::new(Installer { - architecture: Architecture::from_machine(pe.machine()), - r#type: if pe + let internal_name = pe + .version_info_list + .iter() + .find(|key_value| key_value.key() == INTERNAL_NAME) + .and_then(|key_value| key_value.value.as_deref()) + .map(str::to_ascii_lowercase) + .unwrap_or_default(); + + let switches = match internal_name.as_str() { + "sfxcab.exe" => InstallerSwitches::builder() + .silent("/quiet".parse().unwrap()) + .build(), + "7zs.sfx" | "7z.sfx" | "7zsd.sfx" => InstallerSwitches::builder() + .silent("/s".parse().unwrap()) + .build(), + "setup launcher" => InstallerSwitches::builder() + .silent("/s".parse().unwrap()) + .build(), + "wextract" => InstallerSwitches::builder() + .silent("/Q".parse().unwrap()) + .build(), + _ => InstallerSwitches::default(), + }; + + let installer_type = if switches.silent().is_some() { + InstallerType::Exe + } else { + let is_installer = pe .version_info_list .iter() .filter(|key_value| matches!(key_value.key(), FILE_DESCRIPTION | ORIGINAL_FILENAME)) @@ -53,11 +86,19 @@ impl Exe { BASIC_INSTALLER_KEYWORDS .iter() .any(|keyword| value.contains(keyword)) - }) { - Some(InstallerType::Exe) + }); + + if is_installer { + InstallerType::Exe } else { - Some(InstallerType::Portable) - }, + InstallerType::Portable + } + }; + + Ok(Self::Generic(Box::new(Installer { + architecture: Architecture::from_machine(pe.machine()), + r#type: Some(installer_type), + switches, ..Installer::default() }))) } @@ -68,6 +109,7 @@ impl Installers for Exe { match self { Self::Burn(burn) => burn.installers(), Self::Inno(inno) => inno.installers(), + Self::InstallShield(installshield) => installshield.installers(), Self::Nsis(nsis) => nsis.installers(), Self::Generic(installer) => vec![*installer.clone()], } diff --git a/src/analysis/installers/inno/mod.rs b/src/analysis/installers/inno/mod.rs index e90b33f9..93fed046 100644 --- a/src/analysis/installers/inno/mod.rs +++ b/src/analysis/installers/inno/mod.rs @@ -5,6 +5,7 @@ use inno::{ header::{Architecture as InnoArchitecture, PrivilegesRequiredOverrides}, }; use msi::Language as CodePageLanguage; +use regex::Regex; use winget_types::{ LanguageTag, Sha256String, installer::{ @@ -72,6 +73,24 @@ impl Installers for Inno { default_install_location: install_dir.map(Utf8PathBuf::from), ..InstallationMetadata::default() }, + switches: InstallerSwitches::builder() + .maybe_custom({ + let param_regex = Regex::new(r"\{param:([^|]+)\(|([^}]+)\)?}").unwrap(); + let header_str = format!("{:?}", self.header); + let mut switches = param_regex + .captures_iter(&header_str) + .filter_map(|caps| { + Some(format!( + "/{}=\"{}\"", + caps.get(1)?.as_str(), + caps.get(2)?.as_str() + )) + }) + .collect::>(); + switches.sort(); + switches.join(" ").parse().ok() + }) + .build(), ..Default::default() }; diff --git a/src/analysis/installers/installshield/README.md b/src/analysis/installers/installshield/README.md new file mode 100644 index 00000000..85f187cd --- /dev/null +++ b/src/analysis/installers/installshield/README.md @@ -0,0 +1,221 @@ +# InstallShield Setup Format + +## Overview + +InstallShield installers are self-extracting PE executables containing compressed installation files. The installer data is appended after the PE sections. + +## File Structure + +```text +┌─────────────────────────────────────┐ +│ PE Header + Sections │ +│ (Standard Windows Executable) │ +├─────────────────────────────────────┤ +│ Optional: Version Signature │ +│ (e.g., "NB10") │ +├─────────────────────────────────────┤ +│ InstallShield Header (46 bytes) │ +│ - Signature (14 bytes) │ +│ - Number of files (2 bytes) │ +│ - Type field (4 bytes) │ +│ - Padding (26 bytes) │ +├─────────────────────────────────────┤ +│ File Table (variable) │ +│ [IS 12.x or IS 30.x structure] │ +├─────────────────────────────────────┤ +│ File Data (inline, encrypted/ │ +│ compressed) │ +└─────────────────────────────────────┘ +``` + +## Header Formats + +### Main Header (46 bytes) + +```text +Offset Size Field +────────────────────────────────────── +0x00 14 Signature ("InstallShield" or "ISSetupStream") +0x0E 2 Number of files (uint16) +0x10 4 Type field (uint32) +0x14 8 x4 (unknown) +0x1C 2 x5 (unknown) +0x1E 16 x6 (unknown) +``` + +### Legacy Format ("InstallShield") + +Uses fixed 260-byte paths and older encryption. Rare in modern installers. + +### ISSetupStream Format + +Modern format with two variants: + +#### InstallShield 12.x File Attributes (24 bytes) + +```text +Offset Size Field +────────────────────────────────────── +0x00 4 Filename length in bytes (UTF-16) +0x04 4 Encoded flags +0x08 2 x3 (unknown) +0x0A 4 File length +0x0E 8 x5 (unknown) +0x16 2 is_unicode_launcher +``` + +#### InstallShield 30.x+ File Attributes (48 bytes) + +```text +Offset Size Field +────────────────────────────────────── +0x00 4 Filename length in bytes (UTF-16) +0x04 4 x2 (typically 6, unknown purpose) +0x08 4 Encoded flags +0x0C 4 File length +0x10 8 x3 (unknown/padding) +0x18 8 Timestamp 1 (FILETIME) +0x20 8 Timestamp 2 (FILETIME) +0x28 8 Timestamp 3 (FILETIME) +``` + +**Note**: IS 30.x has TWO `ISSetupStream` headers: + +1. First header at data section start +2. Second header marking the file table location + +## File Storage + +Files are stored **inline** with no offset table: + +```text +┌─────────────────────────────────────┐ +│ File Attributes (24 or 48 bytes) │ +├─────────────────────────────────────┤ +│ Filename (UTF-16, variable length) │ +├─────────────────────────────────────┤ +│ File Data (encrypted/compressed) │ +│ [Variable length, no delimiter] │ +├─────────────────────────────────────┤ +│ File Attributes (next file) │ +├─────────────────────────────────────┤ +│ Filename (UTF-16) │ +├─────────────────────────────────────┤ +│ File Data │ +└─────────────────────────────────────┘ +``` + +To find files, scan forward looking for valid file attribute structures (no offset table exists). + +## Encryption & Compression + +### XOR Encryption + +```rust +const MAGIC_DEC: [u8; 4] = [0x13, 0x35, 0x86, 0x07]; + +// Key derived from filename +key = filename.bytes().collect() + +// Decrypt in 1024-byte blocks +for block in data.chunks(1024) { + for (i, byte) in block.iter_mut().enumerate() { + *byte ^= MAGIC_DEC[i % 4]; + *byte ^= key[i % key.len()]; + } +} +``` + +### Flag Encoding + +Flags control encryption/compression behavior: + +**IS 12.x**: Flags in lower byte of `encoded_flags` (0x000000XX) +**IS 30.x**: Flags in upper byte of `encoded_flags` (0xXX000000) + +```text +Flag Bits: + 0x01 - has_type_1 + 0x02 - has_type_2 (block-based decoding) + 0x04 - has_type_4 (full-file decoding) + 0x08 - has_type_8 +``` + +### Compression + +After decryption, data is zlib-compressed (magic: 0x78). + +```rust +// After XOR decryption +if decrypted_data[0] == 0x78 { + // Decompress with zlib + let mut decoder = ZlibDecoder::new(&decrypted_data[..]); + decoder.read_to_end(&mut output)?; +} +``` + +## Version Detection + +1. **PE Version Info**: Check `ISInternalVersion` resource (e.g., "30.0.157") +2. **MSI Package**: Read `creating_application` property +3. **Fallback**: Assume IS 12.x if no version info + +```text +Major Version ≥ 30 → Use IS 30.x structures +Major Version < 30 → Use IS 12.x structures +``` + +## Key Files + +### Setup.ini + +Primary installer configuration (UTF-16 LE): + +```ini +[Startup] +ProductName=Product Name +ProductVersion=1.0.0 +ProductCode={GUID} +UpgradeCode={GUID} +Default=0x0409 ; Default language LCID + +[0x0409] ; English (US) +COMPANY_NAME=Company Name +PRODUCT_NAME=Product Name +; ... localized strings +``` + +### Language Files + +Format: `0x{LCID:04x}.ini` (e.g., `0x0409.ini` for en-US, `0x0804.ini` for zh-CN) + +Contains localized UI strings for the installer. + +### MSI Packages + +Windows Installer packages, often with language-specific transforms (`.mst` files). + +## Scanning Heuristics + +To find file attributes when parsing IS 30.x: + +```rust +// Valid attributes must have: +- filename_len: 10-200 bytes, even (UTF-16) +- x2: 1-10 (strongly prefer 6) +- encoded_flags upper byte: not 0x00 or 0xFF +- File attributes can start at ANY byte offset (1-byte stepping required) +``` + +## Common Issues + +1. **Header reports wrong file count**: May be off by one; stop gracefully when no more files found +2. **file_len = 0**: Common in IS 30.x; read until valid data extracted (up to 100KB) +3. **Odd byte offsets**: Files can start at non-aligned offsets (e.g., 5215); must use 1-byte stepping +4. **Language INI decoding**: May use non-UTF encodings; handle gracefully + +## References + +- Original C implementation: ISx.c (unshield project) +- InstallShield versions: 2010 (12.x), 30.x (modern) +- Tested with: Sonos 90.0, Trimble Connect 1.26, FedEx Ship Manager 25.01 diff --git a/src/analysis/installers/installshield/analyzer.rs b/src/analysis/installers/installshield/analyzer.rs new file mode 100644 index 00000000..8a8b0a48 --- /dev/null +++ b/src/analysis/installers/installshield/analyzer.rs @@ -0,0 +1,1181 @@ +use camino::Utf8PathBuf; +use flate2::read::ZlibDecoder; +use msi::Language; +use std::io::{Cursor, Read, Seek, SeekFrom}; +use tracing::debug; +use winget_types::{ + LanguageTag, + installer::{Architecture, InstallationMetadata, Installer, InstallerType, Scope}, +}; +use yara_x::mods::PE; + +use super::error::InstallShieldError; +use crate::analysis::installers::msi::Msi; +use crate::{analysis::Installers, traits::FromMachine}; + +// Constants +const ISSIG: &[u8; 13] = b"InstallShield"; +const ISSIG_STRM: &[u8; 13] = b"ISSetupStream"; +const MAGIC_DEC: [u8; 4] = [0x13, 0x35, 0x86, 0x07]; + +// Data structures matching C structs +#[repr(C, packed)] +#[derive(Debug, Clone, Copy)] +struct IsHeader { + sig: [u8; 14], + num_files: u16, + type_field: u32, + x4: [u8; 8], + x5: u16, + x6: [u8; 16], +} + +#[repr(C, packed)] +#[derive(Debug, Clone)] +struct IsFileAttributes { + file_name: [u8; 260], // _MAX_PATH + encoded_flags: u32, + x3: u32, + file_len: u32, + x5: [u8; 8], + is_unicode_launcher: u16, + x7: [u8; 30], +} + +#[repr(C, packed)] +#[derive(Debug, Clone, Copy)] +struct IsFileAttributesX { + filename_len: u32, + encoded_flags: u32, + x3: [u8; 2], + file_len: u32, + x5: [u8; 8], + is_unicode_launcher: u16, +} + +// Newer format used in InstallShield 30.x+ +#[repr(C, packed)] +#[derive(Debug, Clone, Copy)] +struct IsFileAttributesX30 { + filename_len: u32, // 0x00: length of filename in bytes + x2: u32, // 0x04: unknown (count/flag?) + encoded_flags: u32, // 0x08: file flags + file_len: u32, // 0x0C: file length + x3: [u8; 8], // 0x10: padding/unknown + timestamp1: u64, // 0x18: first timestamp + timestamp2: u64, // 0x20: second timestamp + timestamp3: u64, // 0x28: third timestamp +} // Total: 48 bytes (0x30) + +pub struct InstallShield { + pub file_count: u16, + pub file_names: Vec, + pub primary_language: Option, + pub install_location: Option, + pub architecture: Architecture, + pub setup_ini_content: Option, + pub product_name: Option, + pub product_version: Option, + pub product_code: Option, + pub upgrade_code: Option, + pub msi: Option, + pub installshield_version: Option, +} + +impl InstallShield { + pub fn new(reader: &mut R, pe: &PE) -> Result { + // Get data offset (after last PE section) + let mut data_offset = Self::get_data_offset(reader, pe)?; + debug!("Data section starts at: {:#X}", data_offset); + + // Try to skip version signature like "NB10" that may appear before InstallShield header + reader.seek(SeekFrom::Start(data_offset))?; + + // Read a small buffer to check for version signature + let mut prefix = [0u8; 16]; + if reader.read(&mut prefix).is_ok() { + // Check for "NB10" or similar version signatures + if &prefix[..4] == b"NB10" { + // Skip past the version signature block + // The C code uses fscanf with patterns to skip, we'll look for the signature + reader.seek(SeekFrom::Start(data_offset))?; + let mut search_buf = vec![0u8; 512]; + if let Ok(_n) = reader.read(&mut search_buf) { + // Look for "InstallShield" or "ISSetupStream" in the buffer + if let Some(pos) = search_buf + .windows(13) + .position(|w| w == ISSIG || w == ISSIG_STRM) + { + data_offset += pos as u64; + } + } + } + } + + // Try to read InstallShield header + reader.seek(SeekFrom::Start(data_offset))?; + + let header = Self::read_header(reader)?; + + // Verify signature + if &header.sig[..13] != ISSIG && &header.sig[..13] != ISSIG_STRM { + return Err(InstallShieldError::NotInstallShieldFile); + } + + let num_files = header.num_files; + let _type_field = header.type_field; + let is_stream = &header.sig[..13] == ISSIG_STRM; + + // Detect version early to know which structure to use + let is_version_30_plus = pe + .version_info + .get("ISInternalVersion") + .and_then(|v| v.split('.').next()) + .and_then(|major| major.parse::().ok()) + .map(|major| major >= 30) + .unwrap_or(false); + + debug!( + "Found InstallShield installer with {} files (IS 30+: {})", + num_files, is_version_30_plus + ); + + // For IS 30.x+, the file table is located after a second ISSetupStream signature + // Search for it if this is IS 30.x + let file_table_offset = if is_version_30_plus && is_stream { + // Search for second ISSetupStream signature + let current_pos = reader.stream_position()?; + let mut found_offset = None; + let file_size = reader.seek(SeekFrom::End(0))?; + reader.seek(SeekFrom::Start(current_pos))?; + + // Search from current position onwards + let mut search_offset = current_pos; + let chunk_size = 1024 * 1024; // 1 MB chunks + + while search_offset < file_size { + reader.seek(SeekFrom::Start(search_offset))?; + let mut search_buf = vec![0u8; chunk_size]; + let n = reader.read(&mut search_buf)?; + + if let Some(pos) = search_buf[..n].windows(13).position(|w| w == ISSIG_STRM) { + found_offset = Some(search_offset + pos as u64); + break; + } + + search_offset += (n - 13) as u64; // Overlap to catch signatures at boundaries + if n < chunk_size { + break; + } + } + + found_offset.unwrap_or(data_offset + std::mem::size_of::() as u64) + } else { + data_offset + std::mem::size_of::() as u64 + }; + + debug!("File table starts at offset: 0x{:X}", file_table_offset); + + // Parse file names and extract file data + let mut file_names = Vec::new(); + let mut file_data_map: Vec<(String, u64, u32, u32)> = Vec::new(); // (name, offset, size, flags) + let mut current_offset = file_table_offset; + + // If we found a second header, skip it + if is_version_30_plus + && is_stream + && file_table_offset != data_offset + std::mem::size_of::() as u64 + { + current_offset += std::mem::size_of::() as u64; + } + + for _i in 0..num_files { + reader.seek(SeekFrom::Start(current_offset))?; + + if is_stream { + // ISSetupStream format - structure differs between IS 12.x and IS 30.x+ + if is_version_30_plus { + // Use IS 30.x structure + match Self::read_file_attributes_x30(reader) { + Ok(attrs) => { + let filename_len = attrs.filename_len; + let file_len = attrs.file_len; + let encoded_flags = attrs.encoded_flags; + + debug!( + "File {}: filename_len={}, file_len={}, flags=0x{:X}", + _i, filename_len, file_len, encoded_flags + ); + + // Read UTF-16 filename + let mut filename_bytes = vec![0u8; filename_len as usize]; + reader.read_exact(&mut filename_bytes)?; + + // Convert UTF-16 to String + let u16_chars: Vec = filename_bytes + .chunks_exact(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect(); + + if let Ok(filename) = String::from_utf16(&u16_chars) { + let filename = filename.trim_end_matches('\0').to_string(); + debug!(" Filename: {}", filename); + let data_offset_for_file = reader.stream_position()?; + file_data_map.push(( + filename.clone(), + data_offset_for_file, + file_len, + encoded_flags, + )); + file_names.push(filename); + } + + // For IS 30.x, file data is inline but file_len is unreliable + // Scan forward to find the next file attributes structure + current_offset = reader.stream_position()?; + + // Try to find next attributes by looking for reasonable filename_len + let scan_start = current_offset; + let scan_limit = 150_000_000; // Max 150MB to scan (files stored inline, no offset table) + let mut found_next = false; + + // Scan with 1-byte steps (file attributes can be at any byte offset) + for offset in 0..scan_limit { + if reader.seek(SeekFrom::Start(scan_start + offset)).is_ok() { + let mut test_bytes = [0u8; 12]; + if reader.read_exact(&mut test_bytes).is_ok() { + let test_len = u32::from_le_bytes([ + test_bytes[0], + test_bytes[1], + test_bytes[2], + test_bytes[3], + ]); + let test_x2 = u32::from_le_bytes([ + test_bytes[4], + test_bytes[5], + test_bytes[6], + test_bytes[7], + ]); + let test_flags = u32::from_le_bytes([ + test_bytes[8], + test_bytes[9], + test_bytes[10], + test_bytes[11], + ]); + + // Check if this looks like valid attributes: + // - filename_len between 10 and 200 (reasonable for UTF-16 filenames) + // - x2 is typically 6 for IS 30.x (prefer 6, require >= 1) + // - flags should have upper byte set (not 0x00 or 0xFF which indicate garbage) + if test_len >= 10 && test_len <= 200 && test_len % 2 == 0 && // UTF-16 filenames are even length + test_x2 >= 1 && test_x2 <= 10 && // Require x2 >= 1 to avoid false positives + (test_flags & 0xFF000000) != 0 && (test_flags & 0xFF000000) != 0xFF000000 && + (test_x2 == 6 || offset > 10000) + // Strongly prefer x2=6; only accept others after 10KB + { + current_offset = scan_start + offset; + found_next = true; + if _i < 5 || offset > 1_000_000 { + debug!( + " Found next file at offset +{}KB", + offset / 1024 + ); + } + break; + } + } + } + } + + if !found_next && _i + 1 < num_files { + debug!( + "Could not find next file attributes for IS 30.x file {} within {}MB scan limit, stopping parse at offset {:#X}", + _i + 1, + scan_limit / (1024 * 1024), + current_offset + ); + break; + } + } + Err(e) => { + debug!("Failed to read file attributes at index {}: {}", _i, e); + break; + } + } + } else { + // Use IS 12.x structure + match Self::read_file_attributes_x(reader) { + Ok(attrs) => { + let filename_len = attrs.filename_len; + let file_len = attrs.file_len; + let encoded_flags = attrs.encoded_flags; + + debug!( + "File {}: filename_len={}, file_len={}, flags=0x{:X}", + _i, filename_len, file_len, encoded_flags + ); + + // Read UTF-16 filename + let mut filename_bytes = vec![0u8; filename_len as usize]; + reader.read_exact(&mut filename_bytes)?; + + // Convert UTF-16 to String + let u16_chars: Vec = filename_bytes + .chunks_exact(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect(); + + if let Ok(filename) = String::from_utf16(&u16_chars) { + let filename = filename.trim_end_matches('\0').to_string(); + let data_offset_for_file = reader.stream_position()?; + file_data_map.push(( + filename.clone(), + data_offset_for_file, + file_len, + encoded_flags, + )); + file_names.push(filename); + } + + current_offset = reader.stream_position()?; + current_offset += file_len as u64; + } + Err(e) => { + debug!("Failed to read file attributes at index {}: {}", _i, e); + break; + } + } + } + } else { + // Standard format uses IS_FILE_ATTRIBUTES + match Self::read_file_attributes(reader) { + Ok(attrs) => { + // Convert null-terminated filename to String + let filename_end = attrs + .file_name + .iter() + .position(|&c| c == 0) + .unwrap_or(attrs.file_name.len()); + if let Ok(filename) = + String::from_utf8(attrs.file_name[..filename_end].to_vec()) + { + let data_offset_for_file = reader.stream_position()?; + file_data_map.push(( + filename.clone(), + data_offset_for_file, + attrs.file_len, + attrs.encoded_flags, + )); + file_names.push(filename); + } + + current_offset = reader.stream_position()?; + current_offset += attrs.file_len as u64; + } + Err(_) => break, + } + } + } + + debug!("Parsed {} file names", file_names.len()); + if !file_names.is_empty() { + debug!( + "First few files: {:?}", + &file_names[..std::cmp::min(100, file_names.len())] + ); + } + + // Try to extract and parse Setup.ini + let mut setup_ini_content = None; + if let Some((filename, offset, size, flags)) = file_data_map + .iter() + .find(|(name, _, _, _)| name.eq_ignore_ascii_case("Setup.ini")) + { + debug!("Found Setup.ini at offset 0x{:X}, size {}", offset, size); + + setup_ini_content = Self::extract_and_decrypt_file( + reader, filename, *offset, *size, *flags, is_stream, + )?; + + if let Some(ref content) = setup_ini_content { + debug!("Setup.ini contents:\n{}", content); + } + } + + // Parse Setup.ini for metadata first to get the default language + let ( + product_name, + product_version, + product_code, + upgrade_code, + setup_ini_language, + msi_package_name, + ) = if let Some(ref ini) = setup_ini_content { + Self::parse_setup_ini_metadata(ini) + } else { + (None, None, None, None, None, None) + }; + + // Determine primary language - prefer Setup.ini Default language + let temp_primary_language = setup_ini_language.or_else(|| { + file_names.iter().find_map(|name| { + if name.ends_with(".ini") && name.starts_with("0x") { + let hex_str = name.strip_prefix("0x")?.strip_suffix(".ini")?; + u16::from_str_radix(hex_str, 16).ok() + } else { + None + } + }) + }); + + // Try to extract and parse language INI file matching the primary language + if let Some(lang_id) = temp_primary_language { + let target_lang_file = format!("0x{:04x}.ini", lang_id); + + if let Some((filename, offset, size, flags)) = file_data_map + .iter() + .find(|(name, _, _, _)| name.eq_ignore_ascii_case(&target_lang_file)) + { + debug!( + "Found language file {} (primary language 0x{:04X}) at offset 0x{:X}, size {}", + filename, lang_id, offset, size + ); + + if let Ok(Some(content)) = Self::extract_and_decrypt_file( + reader, filename, *offset, *size, *flags, is_stream, + ) { + debug!("Language INI ({}) contents:\n{}", filename, content); + } + } else { + debug!( + "Primary language file {} not found, trying first available", + target_lang_file + ); + + // Fallback to first language file if primary not found + if let Some(lang_ini_name) = file_names + .iter() + .find(|name| name.ends_with(".ini") && name.starts_with("0x")) + { + if let Some((filename, offset, size, flags)) = file_data_map + .iter() + .find(|(name, _, _, _)| name == lang_ini_name) + { + debug!( + "Found fallback language file {} at offset 0x{:X}, size {}", + filename, offset, size + ); + + if let Ok(Some(content)) = Self::extract_and_decrypt_file( + reader, filename, *offset, *size, *flags, is_stream, + ) { + debug!("Language INI ({}) contents:\n{}", filename, content); + } + } + } + } + } + + // Try to extract and analyze the MSI if referenced in Setup.ini + let msi = if let Some(ref package_name) = msi_package_name { + if let Some((filename, offset, size, flags)) = file_data_map + .iter() + .find(|(name, _, _, _)| name.eq_ignore_ascii_case(package_name)) + { + debug!( + "Found MSI package {} at offset 0x{:X}, size {}", + filename, offset, size + ); + + // Extract the MSI file data + reader.seek(SeekFrom::Start(*offset))?; + let mut msi_data = vec![0u8; *size as usize]; + reader.read_exact(&mut msi_data)?; + + // Check if encrypted and decrypt if needed + let needs_decryption = (flags & 0x6) != 0; + if needs_decryption { + debug!("MSI is encrypted, decrypting..."); + let seed = filename.as_bytes(); + let key = Self::gen_key(seed); + let has_type_4 = (flags & 0x4) != 0; + let has_type_2 = (flags & 0x2) != 0; + + if has_type_4 && has_type_2 { + let mut decoded_pos = 0; + while decoded_pos < msi_data.len() { + let block_size = std::cmp::min(1024, msi_data.len() - decoded_pos); + Self::decode_data( + &mut msi_data[decoded_pos..decoded_pos + block_size], + 0, + &key, + ); + decoded_pos += block_size; + } + } else if !has_type_4 && has_type_2 { + Self::decode_data(&mut msi_data, 0, &key); + } + + // Check if the decrypted data is zlib compressed + if msi_data.len() >= 2 + && (msi_data[0] == 0x78 + && (msi_data[1] == 0x9C || msi_data[1] == 0x01 || msi_data[1] == 0xDA)) + { + debug!("MSI is zlib compressed, decompressing..."); + let mut decoder = ZlibDecoder::new(&msi_data[..]); + let mut decompressed = Vec::new(); + if decoder.read_to_end(&mut decompressed).is_ok() { + debug!("Decompressed MSI to {} bytes", decompressed.len()); + msi_data = decompressed; + } else { + debug!("Failed to decompress MSI, using encrypted version"); + } + } + } + + // Try to parse the MSI + let cursor = Cursor::new(msi_data); + match Msi::new(cursor) { + Ok(msi) => { + debug!("Successfully parsed MSI package"); + if let Some(ref creating_app) = msi.creating_application { + debug!("MSI created by: {}", creating_app); + } + Some(msi) + } + Err(e) => { + debug!("Failed to parse MSI: {}", e); + None + } + } + } else { + debug!("MSI package {} not found in file list", package_name); + None + } + } else { + None + }; + + // Primary language was already determined above + let primary_language = temp_primary_language; + + if let Some(lang_id) = primary_language { + debug!("Detected primary language: 0x{:04X}", lang_id); + } + + // Look for common install location indicators in filenames + let install_location = + Self::detect_install_location(&file_names, setup_ini_content.as_deref()); + + let architecture = Architecture::from_machine(pe.machine()); + + // Detect InstallShield version - prioritize PE version info, then MSI, then header + let installshield_version = Self::detect_version_from_pe(pe) + .or_else(|| { + msi.as_ref() + .and_then(|m| m.creating_application.as_deref()) + .and_then(|app| Self::parse_installshield_version(app)) + }) + .or_else(|| Self::detect_version(&header)); + + if let Some(ref version) = installshield_version { + debug!("Detected InstallShield version: {}", version); + } + + Ok(Self { + file_count: num_files, + file_names, + primary_language, + install_location, + architecture, + setup_ini_content, + product_name, + product_version, + product_code, + upgrade_code, + msi, + installshield_version, + }) + } + + fn parse_installshield_version(creating_app: &str) -> Option { + // MSI creating_application typically contains "InstallShield" and version + // Examples: + // - "InstallShield 2020" + // - "InstallShield Premier - 11.5.0.123" + // - "InstallShield 12.0" + + if creating_app.contains("InstallShield") { + // Try to extract version number + if let Some(version_part) = creating_app + .split_whitespace() + .find(|s| s.chars().next().map_or(false, |c| c.is_ascii_digit()) && s.contains('.')) + { + return Some(version_part.to_string()); + } + + // Try to find year-based version (2018, 2019, 2020, etc.) + if let Some(year) = creating_app.split_whitespace().find(|s| { + s.len() == 4 && s.chars().all(|c| c.is_ascii_digit()) && s.starts_with("20") + }) { + return Some(year.to_string()); + } + + // Return the whole string if we can't parse it + return Some(creating_app.to_string()); + } + + None + } + + fn detect_version_from_pe(pe: &PE) -> Option { + // Check for ISInternalVersion in PE version info + if let Some(is_version) = pe.version_info.get("ISInternalVersion") { + return Some(is_version.to_string()); + } + None + } + + fn detect_version(header: &IsHeader) -> Option { + // The 14th byte (index 13) of the signature often indicates version + // type_field also contains version information + let version_byte = header.sig[13]; + + // Based on reverse engineering of InstallShield installers: + // - Early versions (5.x): sig[13] = 0x01 + // - Version 6.x: sig[13] = 0x02 + // - Version 7.x-9.x: sig[13] = 0x03 + // - Version 10.x-11.x: sig[13] = 0x04 + // - Version 12.x+: sig[13] = 0x05 or higher + // ISSetupStream format introduced in IS 12 + + let is_stream = &header.sig[..13] == ISSIG_STRM; + + if is_stream { + // ISSetupStream format was introduced in InstallShield 12 + // type_field can give more granular version info + match header.type_field { + 0..=0x01000000 => Some("12.x".to_string()), + 0x01000001..=0x01ffffff => Some("2008-2009".to_string()), + 0x02000000..=0x02ffffff => Some("2010-2011".to_string()), + 0x03000000..=0x03ffffff => Some("2012-2013".to_string()), + 0x04000000..=0x04ffffff => Some("2014-2015".to_string()), + _ => Some("12.x or later".to_string()), + } + } else { + // Legacy InstallShield format + match version_byte { + 0x01 => Some("5.x".to_string()), + 0x02 => Some("6.x".to_string()), + 0x03 => Some("7.x-9.x".to_string()), + 0x04 => Some("10.x-11.x".to_string()), + 0x05 => Some("11.x-12.x".to_string()), + _ => Some(format!("Unknown (sig[13]=0x{:02X})", version_byte)), + } + } + } + + fn get_data_offset( + _reader: &mut R, + pe: &PE, + ) -> Result { + // Find the last section and calculate offset after it + if let Some(last_section) = pe.sections.last() { + let offset = + last_section.raw_data_offset() as u64 + last_section.raw_data_size() as u64; + Ok(offset) + } else { + Err(InstallShieldError::InvalidHeader) + } + } + + fn read_header(reader: &mut R) -> Result { + let mut header = IsHeader { + sig: [0; 14], + num_files: 0, + type_field: 0, + x4: [0; 8], + x5: 0, + x6: [0; 16], + }; + + // Read header fields + reader.read_exact(&mut header.sig)?; + + let mut buf = [0u8; 2]; + reader.read_exact(&mut buf)?; + header.num_files = u16::from_le_bytes(buf); + + let mut buf = [0u8; 4]; + reader.read_exact(&mut buf)?; + header.type_field = u32::from_le_bytes(buf); + + reader.read_exact(&mut header.x4)?; + + let mut buf = [0u8; 2]; + reader.read_exact(&mut buf)?; + header.x5 = u16::from_le_bytes(buf); + + reader.read_exact(&mut header.x6)?; + + Ok(header) + } + + fn read_file_attributes( + reader: &mut R, + ) -> Result { + let mut attrs = IsFileAttributes { + file_name: [0; 260], + encoded_flags: 0, + x3: 0, + file_len: 0, + x5: [0; 8], + is_unicode_launcher: 0, + x7: [0; 30], + }; + + reader.read_exact(&mut attrs.file_name)?; + + let mut buf = [0u8; 4]; + reader.read_exact(&mut buf)?; + attrs.encoded_flags = u32::from_le_bytes(buf); + + reader.read_exact(&mut buf)?; + attrs.x3 = u32::from_le_bytes(buf); + + reader.read_exact(&mut buf)?; + attrs.file_len = u32::from_le_bytes(buf); + + reader.read_exact(&mut attrs.x5)?; + + let mut buf = [0u8; 2]; + reader.read_exact(&mut buf)?; + attrs.is_unicode_launcher = u16::from_le_bytes(buf); + + reader.read_exact(&mut attrs.x7)?; + + Ok(attrs) + } + + fn read_file_attributes_x( + reader: &mut R, + ) -> Result { + let mut all_bytes = [0u8; 24]; + reader.read_exact(&mut all_bytes)?; + + let attrs = IsFileAttributesX { + filename_len: u32::from_le_bytes([ + all_bytes[0], + all_bytes[1], + all_bytes[2], + all_bytes[3], + ]), + encoded_flags: u32::from_le_bytes([ + all_bytes[4], + all_bytes[5], + all_bytes[6], + all_bytes[7], + ]), + x3: [all_bytes[8], all_bytes[9]], + file_len: u32::from_le_bytes([ + all_bytes[10], + all_bytes[11], + all_bytes[12], + all_bytes[13], + ]), + x5: [ + all_bytes[14], + all_bytes[15], + all_bytes[16], + all_bytes[17], + all_bytes[18], + all_bytes[19], + all_bytes[20], + all_bytes[21], + ], + is_unicode_launcher: u16::from_le_bytes([all_bytes[22], all_bytes[23]]), + }; + + Ok(attrs) + } + + fn read_file_attributes_x30( + reader: &mut R, + ) -> Result { + let mut all_bytes = [0u8; 48]; + reader.read_exact(&mut all_bytes)?; + + let attrs = IsFileAttributesX30 { + filename_len: u32::from_le_bytes([ + all_bytes[0], + all_bytes[1], + all_bytes[2], + all_bytes[3], + ]), + x2: u32::from_le_bytes([all_bytes[4], all_bytes[5], all_bytes[6], all_bytes[7]]), + encoded_flags: u32::from_le_bytes([ + all_bytes[8], + all_bytes[9], + all_bytes[10], + all_bytes[11], + ]), + file_len: u32::from_le_bytes([ + all_bytes[12], + all_bytes[13], + all_bytes[14], + all_bytes[15], + ]), + x3: all_bytes[16..24].try_into().unwrap(), + timestamp1: u64::from_le_bytes([ + all_bytes[24], + all_bytes[25], + all_bytes[26], + all_bytes[27], + all_bytes[28], + all_bytes[29], + all_bytes[30], + all_bytes[31], + ]), + timestamp2: u64::from_le_bytes([ + all_bytes[32], + all_bytes[33], + all_bytes[34], + all_bytes[35], + all_bytes[36], + all_bytes[37], + all_bytes[38], + all_bytes[39], + ]), + timestamp3: u64::from_le_bytes([ + all_bytes[40], + all_bytes[41], + all_bytes[42], + all_bytes[43], + all_bytes[44], + all_bytes[45], + all_bytes[46], + all_bytes[47], + ]), + }; + + Ok(attrs) + } + + // Generate decryption key from seed + fn gen_key(seeds: &[u8]) -> Vec { + seeds + .iter() + .enumerate() + .map(|(i, &seed)| seed ^ MAGIC_DEC[i % MAGIC_DEC.len()]) + .collect() + } + + // Helper function to extract and decrypt a file from the archive + fn extract_and_decrypt_file( + reader: &mut R, + filename: &str, + offset: u64, + size: u32, + flags: u32, + _is_stream: bool, + ) -> Result, InstallShieldError> { + reader.seek(SeekFrom::Start(offset))?; + + // For IS 30.x, size may be 0 or unreliable. Read up to a reasonable limit. + let actual_size = if size == 0 { 100_000 } else { size as usize }; + let mut file_data = vec![0u8; actual_size]; + + // Try to read the data - may read less than requested if we hit EOF + let bytes_read = reader.read(&mut file_data)?; + file_data.truncate(bytes_read); + + debug!( + "{} raw first bytes: {:02X?}", + filename, + &file_data[..std::cmp::min(20, file_data.len())] + ); + + // Check if file needs decryption based on flags + // For IS 30.x, flags are in the upper byte (0xXX000000) + // For IS 12.x and older, flags are in lower byte + let flag_byte = if (flags & 0xFF000000) != 0 { + // IS 30.x - use upper byte + (flags >> 24) & 0xFF + } else { + // IS 12.x - use lower byte + flags & 0xFF + }; + + let needs_decryption = (flag_byte & 0x6) != 0; + if needs_decryption { + debug!( + "{} is encrypted (flags: 0x{:X}, flag_byte: 0x{:02X}), attempting to decrypt", + filename, flags, flag_byte + ); + + // Generate decryption key from filename + let seed = filename.as_bytes(); + let key = Self::gen_key(seed); + + // Determine decoding method based on flags + let has_type_4 = (flag_byte & 0x4) != 0; + let has_type_2 = (flag_byte & 0x2) != 0; + + debug!( + "Decryption flags - has_type_4: {}, has_type_2: {}, key_len: {}", + has_type_4, + has_type_2, + key.len() + ); + + if has_type_4 && has_type_2 { + // Block-based decoding (1024 bytes) per C code + debug!("Using block-based decoding (1024 bytes)"); + let mut decoded_pos = 0; + while decoded_pos < file_data.len() { + let block_size = std::cmp::min(1024, file_data.len() - decoded_pos); + Self::decode_data( + &mut file_data[decoded_pos..decoded_pos + block_size], + 0, + &key, + ); + decoded_pos += block_size; + } + } else if !has_type_4 && has_type_2 { + // Full file decoding + debug!("Using full file decoding"); + Self::decode_data(&mut file_data, 0, &key); + } + } + + // Try to convert to string + if let Ok(content) = String::from_utf8(file_data.clone()) { + return Ok(Some(content)); + } else if file_data.len() >= 2 + && file_data[0] == 0x78 + && (file_data[1] == 0x9C || file_data[1] == 0x01 || file_data[1] == 0xDA) + { + // Try to decompress with zlib (starts with 78 9C, 78 01, or 78 DA) + debug!("Data appears to be zlib compressed, attempting to decompress"); + let mut decoder = ZlibDecoder::new(&file_data[..]); + let mut decompressed = Vec::new(); + if decoder.read_to_end(&mut decompressed).is_ok() { + debug!("Decompressed {} bytes", decompressed.len()); + debug!( + "Decompressed first bytes: {:02X?}", + &decompressed[..std::cmp::min(100, decompressed.len())] + ); + + // Try UTF-8 first + if let Ok(content) = String::from_utf8(decompressed.clone()) { + return Ok(Some(content)); + } else { + // Try UTF-16 LE (common in Windows) + if decompressed.len() >= 2 && decompressed.len() % 2 == 0 { + let u16_data: Vec = decompressed + .chunks_exact(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect(); + if let Ok(content) = String::from_utf16(&u16_data) { + return Ok(Some(content)); + } + } + } + } + } + + debug!("{} could not be decoded as text", filename); + Ok(None) + } + + // Decode a single byte + fn decode_byte(byte: u8, key: u8) -> u8 { + !(key ^ (byte.wrapping_shl(4) | byte.wrapping_shr(4))) + } + + // Decode data with key + fn decode_data(data: &mut [u8], offset: usize, key: &[u8]) { + if key.is_empty() { + return; + } + + for (i, byte) in data.iter_mut().enumerate() { + *byte = Self::decode_byte(*byte, key[(i + offset) % key.len()]); + } + } + + // Decode data for unicode stream (1024 byte blocks) + fn decode_data_ustrm(data: &mut [u8], offset: usize, key: &[u8]) { + if key.is_empty() { + return; + } + + let mut decoded_len = 0; + while decoded_len < data.len() { + let decode_start = (decoded_len + offset) % 1024; + let task_len = std::cmp::min(1024 - decode_start, data.len() - decoded_len); + + // Decode this chunk + Self::decode_data( + &mut data[decoded_len..decoded_len + task_len], + decode_start % key.len(), + key, + ); + + decoded_len += task_len; + } + } + + fn detect_install_location(file_names: &[String], setup_ini: Option<&str>) -> Option { + // First try to parse Setup.ini if available + if let Some(ini_content) = setup_ini { + // Look for install directory in Setup.ini + // Common keys: "InstallDir=", "TargetDir=", "DefaultDir=" + for line in ini_content.lines() { + let line = line.trim(); + if let Some(value) = line + .strip_prefix("InstallDir=") + .or_else(|| line.strip_prefix("TargetDir=")) + .or_else(|| line.strip_prefix("DefaultDir=")) + .or_else(|| line.strip_prefix("INSTALLDIR=")) + { + return Some(value.trim().to_string()); + } + } + } + + // Check for common data files + let _has_data_cab = file_names + .iter() + .any(|f| f.to_lowercase().starts_with("data") && f.ends_with(".cab")); + let _has_setup_ini = file_names + .iter() + .any(|f| f.eq_ignore_ascii_case("setup.ini")); + + // InstallShield installers typically install to Program Files + // Return None as we cannot determine the exact path without more info + None + } + + fn parse_setup_ini_metadata( + ini_content: &str, + ) -> ( + Option, + Option, + Option, + Option, + Option, + Option, + ) { + let mut product_name = None; + let mut product_version = None; + let mut product_code = None; + let mut upgrade_code = None; + let mut default_language = None; + let mut package_name = None; + + for line in ini_content.lines() { + let line = line.trim(); + + if let Some(value) = line.strip_prefix("Product=") { + product_name = Some(value.trim().to_string()); + } else if let Some(value) = line.strip_prefix("ProductVersion=") { + product_version = Some(value.trim().to_string()); + } else if let Some(value) = line.strip_prefix("ProductCode=") { + product_code = Some(value.trim().to_string()); + } else if let Some(value) = line.strip_prefix("UpgradeCode=") { + upgrade_code = Some(value.trim().to_string()); + } else if let Some(value) = line.strip_prefix("Default=") { + // Parse hex language code like "0x0409" + if let Some(hex_str) = value.trim().strip_prefix("0x") { + default_language = u16::from_str_radix(hex_str, 16).ok(); + } + } else if let Some(value) = line.strip_prefix("PackageName=") { + package_name = Some(value.trim().to_string()); + } + } + + ( + product_name, + product_version, + product_code, + upgrade_code, + default_language, + package_name, + ) + } +} + +impl Installers for InstallShield { + fn installers(&self) -> Vec { + use winget_types::installer::{AppsAndFeaturesEntries, AppsAndFeaturesEntry}; + + // If we have an MSI, use its installers data and merge with InstallShield data + if let Some(ref msi) = self.msi { + let mut msi_installers = msi.installers(); + + // Merge InstallShield metadata into MSI installers + for installer in &mut msi_installers { + // Prefer InstallShield's locale if available + if installer.locale.is_none() { + installer.locale = self.primary_language.and_then(|lang_id| { + Language::from_code(lang_id) + .tag() + .parse::() + .ok() + }); + } + + // Override installer type to Exe since it's wrapped + installer.r#type = Some(InstallerType::Exe); + } + + return msi_installers; + } + + // Otherwise, use InstallShield metadata + // Determine scope based on architecture and typical InstallShield behavior + // InstallShield installers typically require admin privileges and install to Program Files + let scope = Some(Scope::Machine); + + let locale = self.primary_language.and_then(|lang_id| { + Language::from_code(lang_id) + .tag() + .parse::() + .ok() + }); + + let installer = Installer { + locale, + architecture: self.architecture, + r#type: Some(InstallerType::Exe), + scope, + product_code: self.product_code.clone(), + apps_and_features_entries: if self.product_name.is_some() + || self.product_version.is_some() + || self.product_code.is_some() + { + AppsAndFeaturesEntry::builder() + .maybe_display_name(self.product_name.as_deref()) + .maybe_display_version(self.product_version.as_deref()) + .maybe_product_code(self.product_code.as_deref()) + .maybe_upgrade_code(self.upgrade_code.as_deref()) + .build() + .into() + } else { + AppsAndFeaturesEntries::new() + }, + installation_metadata: InstallationMetadata { + default_install_location: self + .install_location + .as_ref() + .map(|s| Utf8PathBuf::from(s)), + ..InstallationMetadata::default() + }, + + ..Installer::default() + }; + + vec![installer] + } +} diff --git a/src/analysis/installers/installshield/error.rs b/src/analysis/installers/installshield/error.rs new file mode 100644 index 00000000..a7f01973 --- /dev/null +++ b/src/analysis/installers/installshield/error.rs @@ -0,0 +1,34 @@ +use std::fmt; + +#[derive(Debug)] +pub enum InstallShieldError { + NotInstallShieldFile, + InvalidHeader, + IoError(std::io::Error), + Utf8Error(std::string::FromUtf8Error), +} + +impl fmt::Display for InstallShieldError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NotInstallShieldFile => write!(f, "Not an InstallShield file"), + Self::InvalidHeader => write!(f, "Invalid InstallShield header"), + Self::IoError(e) => write!(f, "IO error: {}", e), + Self::Utf8Error(e) => write!(f, "UTF-8 error: {}", e), + } + } +} + +impl std::error::Error for InstallShieldError {} + +impl From for InstallShieldError { + fn from(err: std::io::Error) -> Self { + Self::IoError(err) + } +} + +impl From for InstallShieldError { + fn from(err: std::string::FromUtf8Error) -> Self { + Self::Utf8Error(err) + } +} diff --git a/src/analysis/installers/installshield/mod.rs b/src/analysis/installers/installshield/mod.rs new file mode 100644 index 00000000..d1f60586 --- /dev/null +++ b/src/analysis/installers/installshield/mod.rs @@ -0,0 +1,5 @@ +mod analyzer; +mod error; + +pub use analyzer::InstallShield; +pub use error::InstallShieldError; diff --git a/src/analysis/installers/mod.rs b/src/analysis/installers/mod.rs index 3619598e..6c69f97d 100644 --- a/src/analysis/installers/mod.rs +++ b/src/analysis/installers/mod.rs @@ -1,6 +1,7 @@ pub mod burn; mod exe; pub mod inno; +pub mod installshield; mod msi; pub mod msix_family; pub mod nsis; @@ -9,6 +10,7 @@ mod zip; pub use burn::Burn; pub use exe::Exe; +pub use installshield::InstallShield; pub use msi::Msi; pub use nsis::Nsis; pub use zip::Zip; diff --git a/src/analysis/installers/msi/mod.rs b/src/analysis/installers/msi/mod.rs index 0b66ffaa..907417c3 100644 --- a/src/analysis/installers/msi/mod.rs +++ b/src/analysis/installers/msi/mod.rs @@ -13,7 +13,7 @@ use winget_types::{ LanguageTag, installer::{ AppsAndFeaturesEntries, AppsAndFeaturesEntry, Architecture, InstallationMetadata, - Installer, InstallerType, Scope, + Installer, InstallerSwitches, InstallerType, Scope, }, }; @@ -273,6 +273,24 @@ impl Installers for Msi { default_install_location: self.find_install_directory(), ..InstallationMetadata::default() }, + switches: InstallerSwitches::builder() + .maybe_custom({ + let mut switches = self + .property_table + .iter() + // Public properties are uppercase + // https://learn.microsoft.com/en-us/windows/win32/msi/property-reference + // TODO Indicate standard vs custom properties? + .filter(|(key, _)| { + key.chars() + .all(|char| char.is_uppercase() || !char.is_alphabetic()) + }) + .map(|(key, value)| format!("{}=\"{}\"", key, value)) + .collect::>(); + switches.sort(); + switches.join(" ").parse().ok() + }) + .build(), ..Installer::default() }; diff --git a/src/analysis/installers/msix_family/appinstaller.rs b/src/analysis/installers/msix_family/appinstaller.rs new file mode 100644 index 00000000..7a71f4c6 --- /dev/null +++ b/src/analysis/installers/msix_family/appinstaller.rs @@ -0,0 +1,161 @@ +use color_eyre::eyre::Result; +use quick_xml::de::from_str; +use reqwest::Client; +use serde::Deserialize; +use tracing::warn; + +use crate::analysis::extensions::APPINSTALLER; + +/// Resolves `.appinstaller` URLs to the actual installer URL +/// +/// If the URL ends with `.appinstaller`, this function will: +/// 1. Download the .appinstaller file +/// 2. Parse it as XML to extract the MainBundle or MainPackage Uri +/// 3. Return the extracted installer URL +pub async fn resolve_appinstaller_url( + client: &Client, + url: &url::Url, +) -> Result, reqwest::Error> { + // Check if this is an .appinstaller URL + if !url.path().ends_with(&format!(".{APPINSTALLER}")) { + return Ok(None); + } + + // Download the .appinstaller file + let response = client.get(url.clone()).send().await?; + + if let Err(err) = response.error_for_status_ref() { + return Err(err.into()); + } + + let content = response.text().await?; + + // Parse the XML and extract the installer URL + match from_str::(&content) { + Ok(app_installer) => { + if let Some(installer_url) = app_installer.get_installer_url() { + match installer_url.parse() { + Ok(new_url) => Ok(Some(new_url)), + Err(e) => { + warn!( + "Failed to parse extracted installer URL from .appinstaller: {}", + e + ); + Ok(None) + } + } + } else { + warn!("No MainBundle or MainPackage Uri found in .appinstaller file"); + Ok(None) + } + } + Err(e) => { + warn!("Failed to parse .appinstaller file: {}", e); + Ok(None) + } + } +} + +/// +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +struct AppInstaller { + #[serde(rename = "MainBundle", default)] + main_bundle: Option, + #[serde(rename = "MainPackage", default)] + main_package: Option, +} + +impl AppInstaller { + fn get_installer_url(self) -> Option { + self.main_bundle + .map(|bundle| bundle.uri) + .or_else(|| self.main_package.map(|package| package.uri)) + } +} + +/// +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +struct MainBundle { + #[serde(rename = "@Uri")] + uri: String, +} + +/// +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +struct MainPackage { + #[serde(rename = "@Uri")] + uri: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use indoc::indoc; + + #[tokio::test] + async fn test_resolve_real_appinstaller_url() { + // Use the real URL from the issue + let url = "https://github.com/MicaForEveryone/MicaForEveryone/releases/download/2.0.5.0/MicaForEveryone.appinstaller" + .parse() + .unwrap(); + + let client = Client::new(); + let result = resolve_appinstaller_url(&client, &url).await; + + // The function should succeed and return a URL + assert!(result.is_ok()); + let resolved_url = result.unwrap(); + assert!(resolved_url.is_some()); + + // The resolved URL should be a .msixbundle file + let final_url = resolved_url.unwrap(); + assert!(final_url.path().ends_with(".msixbundle")); + } + + #[test] + fn test_parse_appinstaller_with_main_bundle() { + let xml = indoc! {r#" + + + + + "#}; + + let app_installer: AppInstaller = from_str(xml).unwrap(); + let url = app_installer.get_installer_url().unwrap(); + assert_eq!( + url, + "https://github.com/MicaForEveryone/MicaForEveryone/releases/download/2.0.5.0/MicaForEveryone_2.0.5.0_x64.msixbundle" + ); + } + + #[test] + fn test_parse_appinstaller_with_main_package() { + let xml = indoc! {r#" + + + + + "#}; + + let app_installer: AppInstaller = from_str(xml).unwrap(); + let url = app_installer.get_installer_url().unwrap(); + assert_eq!(url, "https://example.com/TestApp_1.0.0.0_x64.msix"); + } + + #[test] + fn test_parse_appinstaller_no_uri() { + let xml = indoc! {r#" + + + + "#}; + + let app_installer: AppInstaller = from_str(xml).unwrap(); + let url = app_installer.get_installer_url(); + assert!(url.is_none()); + } +} diff --git a/src/analysis/installers/msix_family/mod.rs b/src/analysis/installers/msix_family/mod.rs index 26c09d78..bf49df97 100644 --- a/src/analysis/installers/msix_family/mod.rs +++ b/src/analysis/installers/msix_family/mod.rs @@ -1,3 +1,4 @@ +pub mod appinstaller; pub mod bundle; mod utils; diff --git a/src/analysis/mod.rs b/src/analysis/mod.rs index b85e010f..558cec1a 100644 --- a/src/analysis/mod.rs +++ b/src/analysis/mod.rs @@ -1,5 +1,5 @@ mod analyzer; -mod extensions; +pub mod extensions; pub mod installers; mod r#trait; diff --git a/src/download/downloader.rs b/src/download/downloader.rs index d80c6f98..0b450903 100644 --- a/src/download/downloader.rs +++ b/src/download/downloader.rs @@ -129,10 +129,19 @@ impl Downloader { mut download: Download, multi_progress: &MultiProgress, ) -> Result { + use crate::analysis::installers::msix_family::appinstaller; + download.convert_to_github_versioned().await?; download.upgrade_to_https(client).await; + // Resolve .appinstaller files to actual installer URLs + if let Some(resolved_url) = + appinstaller::resolve_appinstaller_url(client, download.url()).await? + { + **download.0 = resolved_url; + } + let res = client.get((***download.url()).clone()).send().await?; if let Err(err) = res.error_for_status_ref() { diff --git a/src/github/mod.rs b/src/github/mod.rs index bb557da3..1cf760b2 100644 --- a/src/github/mod.rs +++ b/src/github/mod.rs @@ -9,5 +9,5 @@ pub use error::GitHubError; pub const MICROSOFT: &str = "microsoft"; pub const WINGET_PKGS: &str = "winget-pkgs"; -pub const WINGET_PKGS_FULL_NAME: &str = formatcp!("{MICROSOFT}/{WINGET_PKGS}"); +pub const WINGET_PKGS_FULL_NAME: &str = "pl4nty/winget-pkgs-selfhost"; pub const GITHUB_HOST: &str = "github.com"; diff --git a/src/main.rs b/src/main.rs index f5952424..3c65d68e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,7 @@ use clap::{Parser, Subcommand, crate_name}; use color_eyre::eyre::Result; use tracing::{Level, metadata::LevelFilter}; use tracing_indicatif::IndicatifLayer; -use tracing_subscriber::{filter, layer::SubscriberExt, util::SubscriberInitExt}; +use tracing_subscriber::{EnvFilter, filter, layer::SubscriberExt, util::SubscriberInitExt}; use crate::commands::{ analyse::Analyse, @@ -75,9 +75,10 @@ fn setup_logging() { ) .with(indicatif_layer) .with( - filter::Targets::new() - .with_default(LevelFilter::INFO) - .with_target(crate_name!(), Level::TRACE), + EnvFilter::builder() + .with_default_directive(LevelFilter::INFO.into()) + .from_env_lossy() + .add_directive(format!("{}=trace", crate_name!()).parse().unwrap()), ) .init(); } diff --git a/vendor/inno b/vendor/inno new file mode 160000 index 00000000..db848c11 --- /dev/null +++ b/vendor/inno @@ -0,0 +1 @@ +Subproject commit db848c1151dc2cb70fb166d2d85fa37aa5b4627d