From 8c07ba6fbe60beadde30001e59e880d670ad30c4 Mon Sep 17 00:00:00 2001 From: Tom Plant Date: Wed, 16 Apr 2025 14:01:57 +0000 Subject: [PATCH 01/14] Use custom winget-pkgs Signed-off-by: GitHub --- src/github/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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"; From 8f1fd5733b8fad5faa54bf83acf2187971dfbb32 Mon Sep 17 00:00:00 2001 From: Tom Plant Date: Thu, 18 Sep 2025 13:19:06 +0000 Subject: [PATCH 02/14] Vendor inno, track parser bugs, add vscode settings Signed-off-by: Tom Plant --- .devcontainer/devcontainer.json | 31 +++++++++++++++++++++ .gitmodules | 3 +++ .vscode/launch.json | 48 +++++++++++++++++++++++++++++++++ .vscode/settings.json | 3 +++ CONTRIBUTING.md | 33 ++++++++++++++++++++--- Cargo.lock | 2 -- Cargo.toml | 3 +++ vendor/inno | 1 + 8 files changed, 119 insertions(+), 5 deletions(-) create mode 100644 .devcontainer/devcontainer.json create mode 100644 .gitmodules create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 160000 vendor/inno 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..5d638dd5 --- /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", + "data/iTwinStudioSetup.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..9f2c6b96 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", diff --git a/Cargo.toml b/Cargo.toml index 221eb073..0e236c4c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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/vendor/inno b/vendor/inno new file mode 160000 index 00000000..db848c11 --- /dev/null +++ b/vendor/inno @@ -0,0 +1 @@ +Subproject commit db848c1151dc2cb70fb166d2d85fa37aa5b4627d From bd1c0f78a316a3df839c0fbddbbdaeef940db40f Mon Sep 17 00:00:00 2001 From: Tom Plant Date: Sat, 27 Sep 2025 10:36:53 +0000 Subject: [PATCH 03/14] Add switches for well-known custom installers 7zip, SFXCab, InstallShield, WExtract Signed-off-by: Tom Plant --- src/analysis/installers/exe.rs | 52 ++++++++++++++++++++++++++++------ 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/src/analysis/installers/exe.rs b/src/analysis/installers/exe.rs index f8935d4a..c29d3536 100644 --- a/src/analysis/installers/exe.rs +++ b/src/analysis/installers/exe.rs @@ -2,7 +2,7 @@ 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}; @@ -12,8 +12,9 @@ use crate::{ }; 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), @@ -42,9 +43,34 @@ impl Exe { 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 +79,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() }))) } From 5731e79e1adac5c064218d70d1509e29057056b5 Mon Sep 17 00:00:00 2001 From: Tom Plant Date: Mon, 27 Oct 2025 05:52:54 +0000 Subject: [PATCH 04/14] Write custom switches to manifest for burn, inno, and MSI Signed-off-by: Tom Plant --- src/analysis/installers/burn/mod.rs | 25 ++++++++++++++++++++++++- src/analysis/installers/inno/mod.rs | 19 +++++++++++++++++++ src/analysis/installers/msi/mod.rs | 20 +++++++++++++++++++- 3 files changed, 62 insertions(+), 2 deletions(-) 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/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/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() }; From ff4bdb8777d275243fd535016ec2acecd7d3e2d7 Mon Sep 17 00:00:00 2001 From: Tom Plant Date: Thu, 30 Oct 2025 09:08:00 +0000 Subject: [PATCH 05/14] `RUST_LOG` envvar for configurable logging Signed-off-by: Tom Plant --- Cargo.lock | 13 +++++++++++++ Cargo.toml | 4 ++-- src/main.rs | 9 +++++---- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9f2c6b96..b9ade02c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3049,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" @@ -5359,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 0e236c4c..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"] } 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(); } From a8e0a68a45492df8efa71768b8401defcf740bd8 Mon Sep 17 00:00:00 2001 From: Tom Plant Date: Fri, 31 Oct 2025 03:29:32 +0000 Subject: [PATCH 06/14] First pass at InstallShield analyzer based on ISx Tested with Sonos.Controller Signed-off-by: Tom Plant --- src/analysis/installers/exe.rs | 12 +- .../installers/installshield/analyzer.rs | 846 ++++++++++++++++++ .../installers/installshield/error.rs | 36 + src/analysis/installers/installshield/mod.rs | 5 + src/analysis/installers/mod.rs | 2 + 5 files changed, 899 insertions(+), 2 deletions(-) create mode 100644 src/analysis/installers/installshield/analyzer.rs create mode 100644 src/analysis/installers/installshield/error.rs create mode 100644 src/analysis/installers/installshield/mod.rs diff --git a/src/analysis/installers/exe.rs b/src/analysis/installers/exe.rs index c29d3536..2092041b 100644 --- a/src/analysis/installers/exe.rs +++ b/src/analysis/installers/exe.rs @@ -5,9 +5,9 @@ use inno::{Inno, error::InnoError}; 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, }; @@ -19,6 +19,7 @@ const BASIC_INSTALLER_KEYWORDS: [&str; 2] = ["installer", "setup"]; pub enum Exe { Burn(Box), Inno(Box), + InstallShield(Box), Nsis(Nsis), Generic(Box), } @@ -37,6 +38,12 @@ 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) => {} @@ -102,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/installshield/analyzer.rs b/src/analysis/installers/installshield/analyzer.rs new file mode 100644 index 00000000..3d0fef97 --- /dev/null +++ b/src/analysis/installers/installshield/analyzer.rs @@ -0,0 +1,846 @@ +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, +} + +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 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)?; + + // 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; + + // We'll detect version later after parsing MSI (if available) + + debug!("Found InstallShield installer with {} files", num_files); + + // 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 = data_offset + std::mem::size_of::() as u64; + + for _i in 0..num_files { + reader.seek(SeekFrom::Start(current_offset))?; + + if is_stream { + // ISSetupStream format uses IS_FILE_ATTRIBUTES_X + match Self::read_file_attributes_x(reader) { + Ok(attrs) => { + // Read UTF-16 filename + let mut filename_bytes = vec![0u8; attrs.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, + attrs.file_len, + attrs.encoded_flags, + )); + file_names.push(filename); + } + + current_offset = reader.stream_position()?; + current_offset += attrs.file_len as u64; + } + Err(_) => 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); + } + } + + // Try to extract and parse language INI file + 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 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); + } + } + } + + // Parse Setup.ini for metadata + let (product_name, product_version, product_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) + }; + + // 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 + }; + + // Extract primary language - prefer Setup.ini Default language over language files + let 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 + } + }) + }); + + 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 from MSI if available, otherwise from header + let installshield_version = 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, + 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(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 attrs = IsFileAttributesX { + filename_len: 0, + encoded_flags: 0, + x3: [0; 2], + file_len: 0, + x5: [0; 8], + is_unicode_launcher: 0, + }; + + let mut buf = [0u8; 4]; + reader.read_exact(&mut buf)?; + attrs.filename_len = u32::from_le_bytes(buf); + + reader.read_exact(&mut buf)?; + attrs.encoded_flags = u32::from_le_bytes(buf); + + reader.read_exact(&mut attrs.x3)?; + + 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); + + 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))?; + let mut file_data = vec![0u8; size as usize]; + reader.read_exact(&mut file_data)?; + + debug!( + "{} raw first bytes: {:02X?}", + filename, + &file_data[..std::cmp::min(20, file_data.len())] + ); + + // Check if file needs decryption based on flags + let needs_decryption = (flags & 0x6) != 0; + if needs_decryption { + debug!( + "{} is encrypted (flags: 0x{:X}), attempting to decrypt", + filename, flags + ); + + // 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 = (flags & 0x4) != 0; + let has_type_2 = (flags & 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, + ) { + let mut product_name = None; + let mut product_version = None; + let mut product_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("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, + 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()) + .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..1a973584 --- /dev/null +++ b/src/analysis/installers/installshield/error.rs @@ -0,0 +1,36 @@ +use std::fmt; + +#[derive(Debug)] +pub enum InstallShieldError { + NotInstallShieldFile, + InvalidHeader, + InvalidFileAttributes, + 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::InvalidFileAttributes => write!(f, "Invalid file attributes"), + 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; From 458247d7421130a403d7ffd6dab16815a1f11dd1 Mon Sep 17 00:00:00 2001 From: Tom Plant Date: Wed, 5 Nov 2025 03:25:50 +0000 Subject: [PATCH 07/14] InstallShield support multiple ISSetupStreams in v30+ Nasty hack, needs more reversing. Appears to have no file index, instead storing name+contents inline. There must be a size too but I can't find it Signed-off-by: Tom Plant --- .../installers/installshield/analyzer.rs | 521 ++++++++++++++---- .../installers/installshield/error.rs | 2 - 2 files changed, 428 insertions(+), 95 deletions(-) diff --git a/src/analysis/installers/installshield/analyzer.rs b/src/analysis/installers/installshield/analyzer.rs index 3d0fef97..8a8b0a48 100644 --- a/src/analysis/installers/installshield/analyzer.rs +++ b/src/analysis/installers/installshield/analyzer.rs @@ -53,6 +53,20 @@ struct IsFileAttributesX { 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, @@ -63,6 +77,7 @@ pub struct InstallShield { pub product_name: Option, pub product_version: Option, pub product_code: Option, + pub upgrade_code: Option, pub msi: Option, pub installshield_version: Option, } @@ -71,6 +86,7 @@ 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))?; @@ -84,7 +100,7 @@ impl InstallShield { // 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) { + 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) @@ -110,48 +126,225 @@ impl InstallShield { let _type_field = header.type_field; let is_stream = &header.sig[..13] == ISSIG_STRM; - // We'll detect version later after parsing MSI (if available) + // 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 + ); - debug!("Found InstallShield installer with {} files", num_files); + // 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 = data_offset + std::mem::size_of::() as u64; + 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 uses IS_FILE_ATTRIBUTES_X - match Self::read_file_attributes_x(reader) { - Ok(attrs) => { - // Read UTF-16 filename - let mut filename_bytes = vec![0u8; attrs.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(); + // 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 + ); - 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, - attrs.file_len, - attrs.encoded_flags, - )); - file_names.push(filename); + // 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 + ); - current_offset = reader.stream_position()?; - current_offset += attrs.file_len as u64; + // 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; + } } - Err(_) => break, } } else { // Standard format uses IS_FILE_ATTRIBUTES @@ -209,18 +402,43 @@ impl InstallShield { } } - // Try to extract and parse language INI file - if let Some(lang_ini_name) = file_names - .iter() - .find(|name| name.ends_with(".ini") && name.starts_with("0x")) - { + // 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 == lang_ini_name) + .find(|(name, _, _, _)| name.eq_ignore_ascii_case(&target_lang_file)) { debug!( - "Found language file {} at offset 0x{:X}, size {}", - filename, offset, size + "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( @@ -228,17 +446,36 @@ impl InstallShield { ) { 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); + } + } + } } } - // Parse Setup.ini for metadata - let (product_name, product_version, product_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) - }; - // 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 @@ -319,17 +556,8 @@ impl InstallShield { None }; - // Extract primary language - prefer Setup.ini Default language over language files - let 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 - } - }) - }); + // 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); @@ -341,11 +569,13 @@ impl InstallShield { let architecture = Architecture::from_machine(pe.machine()); - // Detect InstallShield version from MSI if available, otherwise from header - let installshield_version = msi - .as_ref() - .and_then(|m| m.creating_application.as_deref()) - .and_then(|app| Self::parse_installshield_version(app)) + // 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 { @@ -362,6 +592,7 @@ impl InstallShield { product_name, product_version, product_code, + upgrade_code, msi, installshield_version, }) @@ -397,6 +628,14 @@ impl InstallShield { 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 @@ -436,7 +675,10 @@ impl InstallShield { } } - fn get_data_offset(reader: &mut R, pe: &PE) -> Result { + 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 = @@ -518,32 +760,103 @@ impl InstallShield { fn read_file_attributes_x( reader: &mut R, ) -> Result { - let mut attrs = IsFileAttributesX { - filename_len: 0, - encoded_flags: 0, - x3: [0; 2], - file_len: 0, - x5: [0; 8], - is_unicode_launcher: 0, + 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]]), }; - let mut buf = [0u8; 4]; - reader.read_exact(&mut buf)?; - attrs.filename_len = u32::from_le_bytes(buf); - - reader.read_exact(&mut buf)?; - attrs.encoded_flags = u32::from_le_bytes(buf); - - reader.read_exact(&mut attrs.x3)?; - - reader.read_exact(&mut buf)?; - attrs.file_len = u32::from_le_bytes(buf); - - reader.read_exact(&mut attrs.x5)?; + Ok(attrs) + } - let mut buf = [0u8; 2]; - reader.read_exact(&mut buf)?; - attrs.is_unicode_launcher = u16::from_le_bytes(buf); + 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) } @@ -564,11 +877,17 @@ impl InstallShield { offset: u64, size: u32, flags: u32, - is_stream: bool, + _is_stream: bool, ) -> Result, InstallShieldError> { reader.seek(SeekFrom::Start(offset))?; - let mut file_data = vec![0u8; size as usize]; - reader.read_exact(&mut file_data)?; + + // 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?}", @@ -577,11 +896,21 @@ impl InstallShield { ); // Check if file needs decryption based on flags - let needs_decryption = (flags & 0x6) != 0; + // 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}), attempting to decrypt", - filename, flags + "{} is encrypted (flags: 0x{:X}, flag_byte: 0x{:02X}), attempting to decrypt", + filename, flags, flag_byte ); // Generate decryption key from filename @@ -589,8 +918,8 @@ impl InstallShield { let key = Self::gen_key(seed); // Determine decoding method based on flags - let has_type_4 = (flags & 0x4) != 0; - let has_type_2 = (flags & 0x2) != 0; + 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: {}", @@ -734,12 +1063,14 @@ impl InstallShield { 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; @@ -752,6 +1083,8 @@ impl InstallShield { 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") { @@ -766,6 +1099,7 @@ impl InstallShield { product_name, product_version, product_code, + upgrade_code, default_language, package_name, ) @@ -825,6 +1159,7 @@ impl Installers for InstallShield { .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 { diff --git a/src/analysis/installers/installshield/error.rs b/src/analysis/installers/installshield/error.rs index 1a973584..a7f01973 100644 --- a/src/analysis/installers/installshield/error.rs +++ b/src/analysis/installers/installshield/error.rs @@ -4,7 +4,6 @@ use std::fmt; pub enum InstallShieldError { NotInstallShieldFile, InvalidHeader, - InvalidFileAttributes, IoError(std::io::Error), Utf8Error(std::string::FromUtf8Error), } @@ -14,7 +13,6 @@ impl fmt::Display for InstallShieldError { match self { Self::NotInstallShieldFile => write!(f, "Not an InstallShield file"), Self::InvalidHeader => write!(f, "Invalid InstallShield header"), - Self::InvalidFileAttributes => write!(f, "Invalid file attributes"), Self::IoError(e) => write!(f, "IO error: {}", e), Self::Utf8Error(e) => write!(f, "UTF-8 error: {}", e), } From 5b66a7888c59c8e15d8cc7c2c8ccd39e72b7a011 Mon Sep 17 00:00:00 2001 From: Tom Plant Date: Wed, 26 Nov 2025 02:46:19 +0000 Subject: [PATCH 08/14] More InstallShield reversing Signed-off-by: Tom Plant --- .vscode/launch.json | 2 +- .../installers/installshield/README.md | 221 ++++++++++++++++++ 2 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 src/analysis/installers/installshield/README.md diff --git a/.vscode/launch.json b/.vscode/launch.json index 5d638dd5..f5303b8b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -21,7 +21,7 @@ }, "args": [ "analyse", - "data/iTwinStudioSetup.exe" + ".idea/installshield/AllShare_2.1.0.12031_10.exe" ], "cwd": "${workspaceFolder}" }, 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 From 019387ca2a475db6bb58d5b76b509e0f0a507a21 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 06:12:25 +0000 Subject: [PATCH 09/14] Initial plan From ceed95871439db30a6277ddf7e72ab7988746863 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 06:27:08 +0000 Subject: [PATCH 10/14] Add .appinstaller file support with XML parsing and URL resolution Co-authored-by: pl4nty <21111317+pl4nty@users.noreply.github.com> --- src/analysis/extensions.rs | 1 + .../installers/msix_family/appinstaller.rs | 92 +++++++++++++++++++ src/analysis/installers/msix_family/mod.rs | 1 + src/analysis/mod.rs | 2 +- src/download/downloader.rs | 3 + src/download/mod.rs | 35 +++++++ 6 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 src/analysis/installers/msix_family/appinstaller.rs 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/msix_family/appinstaller.rs b/src/analysis/installers/msix_family/appinstaller.rs new file mode 100644 index 00000000..7132e265 --- /dev/null +++ b/src/analysis/installers/msix_family/appinstaller.rs @@ -0,0 +1,92 @@ +use color_eyre::eyre::{Result, bail}; +use quick_xml::{Reader, events::Event}; + +/// Parses an `.appinstaller` file and extracts the installer URL +/// +/// According to the App Installer file schema: +/// https://learn.microsoft.com/en-us/uwp/schemas/appinstallerschema/schema-root +/// +/// The file contains either: +/// - MainBundle with a Uri attribute (for .msixbundle/.appxbundle) +/// - MainPackage with a Uri attribute (for .msix/.appx) +pub fn parse_appinstaller(xml_content: &str) -> Result { + let mut reader = Reader::from_str(xml_content); + let config = reader.config_mut(); + config.expand_empty_elements = true; + config.trim_text(true); + + loop { + match reader.read_event()? { + Event::Start(event) | Event::Empty(event) => { + match event.local_name().as_ref() { + b"MainBundle" | b"MainPackage" => { + for attribute in event.attributes().flatten() { + if attribute.key.as_ref() == b"Uri" { + let uri = String::from_utf8_lossy(&attribute.value).into_owned(); + if !uri.is_empty() { + return Ok(uri); + } + } + } + } + _ => {} + } + } + Event::Eof => break, + _ => {} + } + } + + bail!("No MainBundle or MainPackage Uri found in .appinstaller file") +} + +#[cfg(test)] +mod tests { + use super::*; + use indoc::indoc; + + #[test] + fn test_parse_appinstaller_with_main_bundle() { + let xml = indoc! {r#" + + + + + "#}; + + let result = parse_appinstaller(xml).unwrap(); + assert_eq!( + result, + "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 result = parse_appinstaller(xml).unwrap(); + assert_eq!(result, "https://example.com/TestApp_1.0.0.0_x64.msix"); + } + + #[test] + fn test_parse_appinstaller_no_uri() { + let xml = indoc! {r#" + + + + "#}; + + let result = parse_appinstaller(xml); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("No MainBundle or MainPackage Uri found")); + } +} 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..96c2afc7 100644 --- a/src/download/downloader.rs +++ b/src/download/downloader.rs @@ -133,6 +133,9 @@ impl Downloader { download.upgrade_to_https(client).await; + // Resolve .appinstaller files to actual installer URLs + download.resolve_appinstaller(client).await?; + let res = client.get((***download.url()).clone()).send().await?; if let Err(err) = res.error_for_status_ref() { diff --git a/src/download/mod.rs b/src/download/mod.rs index 39c91be9..676431b6 100644 --- a/src/download/mod.rs +++ b/src/download/mod.rs @@ -144,6 +144,41 @@ impl Download { Ok(()) } + /// Resolves `.appinstaller` URLs to the actual installer URL + /// + /// If the URL ends with `.appinstaller`, this method will: + /// 1. Download the .appinstaller file + /// 2. Parse it as XML to extract the MainBundle or MainPackage Uri + /// 3. Replace the current URL with the extracted installer URL + pub async fn resolve_appinstaller(&mut self, client: &Client) -> Result<(), reqwest::Error> { + use crate::analysis::{extensions::APPINSTALLER, installers::msix_family::appinstaller}; + + // Check if this is an .appinstaller URL + let path = self.0.path(); + if !path.ends_with(&format!(".{APPINSTALLER}")) { + return Ok(()); + } + + // Download the .appinstaller file + let response = client.get((**self.0).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 + if let Ok(installer_url) = appinstaller::parse_appinstaller(&content) { + // Parse and set the new URL + if let Ok(new_url) = installer_url.parse() { + **self.0 = new_url; + } + } + + Ok(()) + } + /// Returns the serialization of the download's URL. #[inline] pub fn as_str(&self) -> &str { From cd92775395205d1134febdb3d3269dc8e0e17a24 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 06:37:57 +0000 Subject: [PATCH 11/14] Address code review feedback: add logging for parsing failures and optimize string allocation Co-authored-by: pl4nty <21111317+pl4nty@users.noreply.github.com> --- src/download/mod.rs | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/download/mod.rs b/src/download/mod.rs index 676431b6..8ea87d3e 100644 --- a/src/download/mod.rs +++ b/src/download/mod.rs @@ -152,10 +152,12 @@ impl Download { /// 3. Replace the current URL with the extracted installer URL pub async fn resolve_appinstaller(&mut self, client: &Client) -> Result<(), reqwest::Error> { use crate::analysis::{extensions::APPINSTALLER, installers::msix_family::appinstaller}; + use tracing::warn; // Check if this is an .appinstaller URL + const APPINSTALLER_EXT: &str = ".appinstaller"; let path = self.0.path(); - if !path.ends_with(&format!(".{APPINSTALLER}")) { + if !path.ends_with(APPINSTALLER_EXT) { return Ok(()); } @@ -169,10 +171,19 @@ impl Download { let content = response.text().await?; // Parse the XML and extract the installer URL - if let Ok(installer_url) = appinstaller::parse_appinstaller(&content) { - // Parse and set the new URL - if let Ok(new_url) = installer_url.parse() { - **self.0 = new_url; + match appinstaller::parse_appinstaller(&content) { + Ok(installer_url) => { + match installer_url.parse() { + Ok(new_url) => { + **self.0 = new_url; + } + Err(e) => { + warn!("Failed to parse extracted installer URL from .appinstaller: {}", e); + } + } + } + Err(e) => { + warn!("Failed to parse .appinstaller file: {}", e); } } From 17b9cebb363fda88f3f4f5c48327bdc2be1c3f48 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 06:43:46 +0000 Subject: [PATCH 12/14] Use APPINSTALLER constant consistently with formatcp for compile-time string concatenation Co-authored-by: pl4nty <21111317+pl4nty@users.noreply.github.com> --- src/download/mod.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/download/mod.rs b/src/download/mod.rs index 8ea87d3e..c2968d6e 100644 --- a/src/download/mod.rs +++ b/src/download/mod.rs @@ -152,10 +152,11 @@ impl Download { /// 3. Replace the current URL with the extracted installer URL pub async fn resolve_appinstaller(&mut self, client: &Client) -> Result<(), reqwest::Error> { use crate::analysis::{extensions::APPINSTALLER, installers::msix_family::appinstaller}; + use const_format::formatcp; use tracing::warn; // Check if this is an .appinstaller URL - const APPINSTALLER_EXT: &str = ".appinstaller"; + const APPINSTALLER_EXT: &str = formatcp!(".{}", APPINSTALLER); let path = self.0.path(); if !path.ends_with(APPINSTALLER_EXT) { return Ok(()); From 07a79e361a8072b594e2a285e3fab389e5bf6975 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 08:29:07 +0000 Subject: [PATCH 13/14] Refactor appinstaller to use serde deserialization and move logic to installers folder Co-authored-by: pl4nty <21111317+pl4nty@users.noreply.github.com> --- .../installers/msix_family/appinstaller.rs | 154 +++++++++++++----- src/download/downloader.rs | 8 +- src/download/mod.rs | 47 ------ 3 files changed, 119 insertions(+), 90 deletions(-) diff --git a/src/analysis/installers/msix_family/appinstaller.rs b/src/analysis/installers/msix_family/appinstaller.rs index 7132e265..703a26d6 100644 --- a/src/analysis/installers/msix_family/appinstaller.rs +++ b/src/analysis/installers/msix_family/appinstaller.rs @@ -1,43 +1,94 @@ -use color_eyre::eyre::{Result, bail}; -use quick_xml::{Reader, events::Event}; +use color_eyre::eyre::Result; +use quick_xml::de::from_str; +use reqwest::Client; +use serde::Deserialize; +use tracing::warn; -/// Parses an `.appinstaller` file and extracts the installer URL -/// -/// According to the App Installer file schema: -/// https://learn.microsoft.com/en-us/uwp/schemas/appinstallerschema/schema-root +use crate::analysis::extensions::APPINSTALLER; + +/// Resolves `.appinstaller` URLs to the actual installer URL /// -/// The file contains either: -/// - MainBundle with a Uri attribute (for .msixbundle/.appxbundle) -/// - MainPackage with a Uri attribute (for .msix/.appx) -pub fn parse_appinstaller(xml_content: &str) -> Result { - let mut reader = Reader::from_str(xml_content); - let config = reader.config_mut(); - config.expand_empty_elements = true; - config.trim_text(true); - - loop { - match reader.read_event()? { - Event::Start(event) | Event::Empty(event) => { - match event.local_name().as_ref() { - b"MainBundle" | b"MainPackage" => { - for attribute in event.attributes().flatten() { - if attribute.key.as_ref() == b"Uri" { - let uri = String::from_utf8_lossy(&attribute.value).into_owned(); - if !uri.is_empty() { - return Ok(uri); - } - } - } +/// 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) } - Event::Eof => break, - _ => {} + } + Err(e) => { + warn!("Failed to parse .appinstaller file: {}", e); + Ok(None) } } +} - bail!("No MainBundle or MainPackage Uri found in .appinstaller file") +/// +#[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 + .as_ref() + .map(|bundle| bundle.uri.clone()) + .or_else(|| self.main_package.as_ref().map(|package| package.uri.clone())) + } +} + +/// +#[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)] @@ -45,6 +96,26 @@ 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#" @@ -54,9 +125,10 @@ mod tests { "#}; - let result = parse_appinstaller(xml).unwrap(); + let app_installer: AppInstaller = from_str(xml).unwrap(); + let url = app_installer.get_installer_url().unwrap(); assert_eq!( - result, + url, "https://github.com/MicaForEveryone/MicaForEveryone/releases/download/2.0.5.0/MicaForEveryone_2.0.5.0_x64.msixbundle" ); } @@ -70,8 +142,9 @@ mod tests { "#}; - let result = parse_appinstaller(xml).unwrap(); - assert_eq!(result, "https://example.com/TestApp_1.0.0.0_x64.msix"); + 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] @@ -82,11 +155,8 @@ mod tests { "#}; - let result = parse_appinstaller(xml); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("No MainBundle or MainPackage Uri found")); + let app_installer: AppInstaller = from_str(xml).unwrap(); + let url = app_installer.get_installer_url(); + assert!(url.is_none()); } } diff --git a/src/download/downloader.rs b/src/download/downloader.rs index 96c2afc7..0b450903 100644 --- a/src/download/downloader.rs +++ b/src/download/downloader.rs @@ -129,12 +129,18 @@ 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 - download.resolve_appinstaller(client).await?; + 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?; diff --git a/src/download/mod.rs b/src/download/mod.rs index c2968d6e..39c91be9 100644 --- a/src/download/mod.rs +++ b/src/download/mod.rs @@ -144,53 +144,6 @@ impl Download { Ok(()) } - /// Resolves `.appinstaller` URLs to the actual installer URL - /// - /// If the URL ends with `.appinstaller`, this method will: - /// 1. Download the .appinstaller file - /// 2. Parse it as XML to extract the MainBundle or MainPackage Uri - /// 3. Replace the current URL with the extracted installer URL - pub async fn resolve_appinstaller(&mut self, client: &Client) -> Result<(), reqwest::Error> { - use crate::analysis::{extensions::APPINSTALLER, installers::msix_family::appinstaller}; - use const_format::formatcp; - use tracing::warn; - - // Check if this is an .appinstaller URL - const APPINSTALLER_EXT: &str = formatcp!(".{}", APPINSTALLER); - let path = self.0.path(); - if !path.ends_with(APPINSTALLER_EXT) { - return Ok(()); - } - - // Download the .appinstaller file - let response = client.get((**self.0).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 appinstaller::parse_appinstaller(&content) { - Ok(installer_url) => { - match installer_url.parse() { - Ok(new_url) => { - **self.0 = new_url; - } - Err(e) => { - warn!("Failed to parse extracted installer URL from .appinstaller: {}", e); - } - } - } - Err(e) => { - warn!("Failed to parse .appinstaller file: {}", e); - } - } - - Ok(()) - } - /// Returns the serialization of the download's URL. #[inline] pub fn as_str(&self) -> &str { From c114d328a2d330a53a57797c66af53e0b0623abf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Dec 2025 08:31:16 +0000 Subject: [PATCH 14/14] Optimize get_installer_url to take ownership instead of cloning Co-authored-by: pl4nty <21111317+pl4nty@users.noreply.github.com> --- src/analysis/installers/msix_family/appinstaller.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/analysis/installers/msix_family/appinstaller.rs b/src/analysis/installers/msix_family/appinstaller.rs index 703a26d6..7a71f4c6 100644 --- a/src/analysis/installers/msix_family/appinstaller.rs +++ b/src/analysis/installers/msix_family/appinstaller.rs @@ -67,11 +67,10 @@ struct AppInstaller { } impl AppInstaller { - fn get_installer_url(&self) -> Option { + fn get_installer_url(self) -> Option { self.main_bundle - .as_ref() - .map(|bundle| bundle.uri.clone()) - .or_else(|| self.main_package.as_ref().map(|package| package.uri.clone())) + .map(|bundle| bundle.uri) + .or_else(|| self.main_package.map(|package| package.uri)) } }