diff --git a/src/analysis/installers/exe.rs b/src/analysis/installers/exe.rs index 84ebcc5e..0a72b52e 100644 --- a/src/analysis/installers/exe.rs +++ b/src/analysis/installers/exe.rs @@ -6,7 +6,8 @@ use tracing::debug; use winget_types::installer::{Installer, InstallerSwitches, InstallerType}; use super::{ - super::Installers, AdvancedInstaller, Burn, InstallShield, Nsis, Qt, SevenZipSfx, Squirrel, + super::Installers, AdvancedInstaller, Burn, InstallShield, Nsis, Qt, SetupFactory, SevenZipSfx, + Squirrel, }; use crate::{ analysis::installers::{ @@ -16,6 +17,7 @@ use crate::{ nsis::NsisError, pe::{PE, VSVersionInfo}, qt::QtError, + setup_factory::SetupFactoryError, sevenzip_sfx::SevenZipSfxError, squirrel::SquirrelError, }, @@ -40,6 +42,7 @@ pub enum ExeType { InstallShield(Box), Nsis(Nsis), Qt(Qt), + SetupFactory(SetupFactory), SevenZipSfx(SevenZipSfx), Squirrel(Squirrel), Generic(Box), @@ -172,6 +175,19 @@ impl Exe { Err(error) => return Err(error.into()), } + match SetupFactory::new(&mut reader, &pe) { + Ok(setup_factory) => { + return Ok(Self { + r#type: ExeType::SetupFactory(setup_factory), + legal_copyright, + product_name, + company_name, + }); + } + Err(SetupFactoryError::NotSetupFactoryFile) => {} + Err(error) => return Err(error.into()), + } + let internal_name = string_table .as_ref() .and_then(|table| table.get("InternalName").copied()) @@ -233,6 +249,7 @@ impl Installers for Exe { ExeType::InstallShield(installshield) => installshield.installers(), ExeType::Nsis(nsis) => nsis.installers(), ExeType::Qt(qt) => qt.installers(), + ExeType::SetupFactory(setup_factory) => setup_factory.installers(), ExeType::SevenZipSfx(sfx) => sfx.installers(), ExeType::Squirrel(squirrel) => squirrel.installers(), ExeType::Generic(installer) => vec![*installer.clone()], diff --git a/src/analysis/installers/mod.rs b/src/analysis/installers/mod.rs index 5d658104..16e7b26c 100644 --- a/src/analysis/installers/mod.rs +++ b/src/analysis/installers/mod.rs @@ -8,6 +8,7 @@ pub mod msix_family; pub mod nsis; pub mod pe; mod qt; +mod setup_factory; mod sevenzip_sfx; pub mod squirrel; pub mod utils; @@ -20,6 +21,7 @@ pub use installshield::InstallShield; pub use msi::Msi; pub use nsis::Nsis; pub use qt::Qt; +pub use setup_factory::SetupFactory; pub use sevenzip_sfx::SevenZipSfx; pub use squirrel::Squirrel; pub use zip::Zip; diff --git a/src/analysis/installers/setup_factory/archive.rs b/src/analysis/installers/setup_factory/archive.rs new file mode 100644 index 00000000..a9698881 --- /dev/null +++ b/src/analysis/installers/setup_factory/archive.rs @@ -0,0 +1,139 @@ +//! Walks the Setup Factory 8/9 archive stored in the PE overlay to recover the compiled setup +//! script (`irsetup.dat`). +//! +//! Layout (little-endian), based on the format documented by +//! [`sfextract`](https://github.com/CybercentreCanada/sfextract): +//! +//! ```text +//! [16] signature (SIGNATURE) +//! [10] unknown +//! [ 8] irsetup.exe size, then that many XOR-obfuscated bytes +//! [ 4] file count (if > 1000 it is really the size of an embedded lua5.1.dll) +//! per file: +//! [264] NUL-terminated name +//! [ 8] size +//! [ 4] CRC-32 +//! [ 4] unknown +//! [ ..] compressed data (LZMA / LZMA2 / PKWARE, auto-detected from the first bytes) +//! ``` + +use std::io::{self, Cursor, Read, Seek, SeekFrom}; + +use liblzma::{ + read::XzDecoder, + stream::{Filters, Stream}, +}; +use zerocopy::LittleEndian; + +use crate::read::ReadBytesExt; + +/// Marks a Setup Factory 8/9 archive at the start of the PE overlay. +pub const SIGNATURE: [u8; 16] = [ + 0xe0, 0xe0, 0xe1, 0xe1, 0xe2, 0xe2, 0xe3, 0xe3, 0xe4, 0xe4, 0xe5, 0xe5, 0xe6, 0xe6, 0xe7, 0xe7, +]; + +/// Bytes to skip from the overlay start to reach the first embedded file (signature + 10 unknown). +const HEADER_LENGTH: i64 = SIGNATURE.len() as i64 + 10; + +/// Fixed width of a file name entry in the file table. +const FILENAME_LENGTH: usize = 264; + +/// Name of the compiled setup script within the archive. +const SCRIPT_NAME: &[u8] = b"irsetup.dat"; + +/// After `irsetup.exe`, Setup Factory 9 stubs embed a second special file (`lua5.1.dll`) before the +/// file table, but older layouts do not. The two are told apart by reading a `u32`: a real file +/// count is small, whereas the low half of `lua5.1.dll`'s ~350 KB `u64` size is far larger. A value +/// above this threshold is therefore the DLL's size rather than a count. Every installer tested so +/// far (DIALux evo, LDraw AIOI, gloCOM, Communicator and the Locklizard Safeguard family, all Setup +/// Factory 9.5) embeds the DLL and takes this path. +const MAX_PLAUSIBLE_FILE_COUNT: u32 = 1000; + +/// Reads and decompresses the `irsetup.dat` script from the archive at `overlay_start`. +/// +/// Returns `Ok(None)` when the script is absent or uses an unsupported compression. Best-effort: +/// callers treat any error as "no metadata". +pub fn read_script( + reader: &mut R, + overlay_start: u64, +) -> io::Result>> { + reader.seek(SeekFrom::Start(overlay_start))?; + reader.seek(SeekFrom::Current(HEADER_LENGTH))?; + + // Embedded irsetup.exe special file: an 8-byte size followed by that many bytes. + skip_special_file(reader)?; + + let mut file_count = reader.read_u32::()?; + if file_count > MAX_PLAUSIBLE_FILE_COUNT { + // What we read was actually the size of an embedded lua5.1.dll special file. + reader.seek(SeekFrom::Current(-(size_of::() as i64)))?; + skip_special_file(reader)?; + file_count = reader.read_u32::()?; + } + + for _ in 0..file_count { + let mut name = [0; FILENAME_LENGTH]; + reader.read_exact(&mut name)?; + let name = &name[..name + .iter() + .position(|&byte| byte == 0) + .unwrap_or(name.len())]; + + let size = read_size(reader)?; + reader.seek(SeekFrom::Current(size_of::() as i64))?; // CRC-32 + reader.seek(SeekFrom::Current(size_of::() as i64))?; // unknown + + if name.eq_ignore_ascii_case(SCRIPT_NAME) { + // Bounded read that grows with the data rather than pre-allocating from the size field. + let mut data = Vec::new(); + reader.take(size.unsigned_abs()).read_to_end(&mut data)?; + return Ok(decompress(&data)); + } + + reader.seek(SeekFrom::Current(size))?; + } + + Ok(None) +} + +/// Skips an 8-byte size field and the bytes it describes. +fn skip_special_file(reader: &mut R) -> io::Result<()> { + let size = read_size(reader)?; + reader.seek(SeekFrom::Current(size))?; + Ok(()) +} + +/// Reads an 8-byte little-endian size, clamping negatives to zero. +fn read_size(reader: &mut R) -> io::Result { + let mut bytes = [0; size_of::()]; + reader.read_exact(&mut bytes)?; + Ok(i64::from_le_bytes(bytes).max(0)) +} + +/// Decompresses a file whose compression is auto-detected from its leading bytes. +/// +/// Handles the LZMA ("alone") and LZMA2 streams used by the setup script; returns `None` for the +/// PKWARE-compressed scripts of older Setup Factory versions and anything unrecognised. +fn decompress(data: &[u8]) -> Option> { + let mut output = Vec::new(); + match (data.first()?, data.get(1)?) { + // Classic LZMA "alone" stream (properties byte + dictionary size + uncompressed size). + (0x5D, 0x00) => { + let stream = Stream::new_lzma_decoder(u64::MAX).ok()?; + XzDecoder::new_stream(Cursor::new(data), stream) + .read_to_end(&mut output) + .ok()?; + } + // Raw LZMA2 stream: one property byte, an 8-byte size, then the raw payload. + (0x18, _) => { + let mut filters = Filters::new(); + filters.lzma2_properties(&data[..1]).ok()?; + let stream = Stream::new_raw_decoder(&filters).ok()?; + XzDecoder::new_stream(Cursor::new(data.get(9..)?), stream) + .read_to_end(&mut output) + .ok()?; + } + _ => return None, + } + Some(output) +} diff --git a/src/analysis/installers/setup_factory/mod.rs b/src/analysis/installers/setup_factory/mod.rs new file mode 100644 index 00000000..1c112c39 --- /dev/null +++ b/src/analysis/installers/setup_factory/mod.rs @@ -0,0 +1,99 @@ +mod archive; +mod script; + +use std::io::{Read, Seek, SeekFrom}; + +use script::ScriptMetadata; +use thiserror::Error; +use tracing::debug; +use winget_types::{ + Version, + installer::{ + AppsAndFeaturesEntries, AppsAndFeaturesEntry, Architecture, InstallModes, Installer, + InstallerSwitches, InstallerType, + }, +}; + +use crate::{ + analysis::{Installers, installers::pe::PE}, + traits::FromMachine, +}; + +#[derive(Error, Debug)] +pub enum SetupFactoryError { + #[error("File is not a Setup Factory installer")] + NotSetupFactoryFile, + #[error(transparent)] + Io(#[from] std::io::Error), +} + +pub struct SetupFactory { + architecture: Architecture, + metadata: ScriptMetadata, +} + +impl SetupFactory { + // Detects Indigo Rose Setup Factory installers from the Setup Factory 8/9 signature at the start + // of the PE overlay, then walks the overlay archive to recover the compiled setup script and its + // session variable table - the real product name, publisher and version the author entered. + // (The stub's own version info describes the run-time, not the packaged application, and is often + // left at Setup Factory's defaults, so it is not used.) + pub fn new(mut reader: R, pe: &PE) -> Result { + let overlay_start = pe + .overlay_offset() + .ok_or(SetupFactoryError::NotSetupFactoryFile)?; + + let mut signature = [0; archive::SIGNATURE.len()]; + if reader.seek(SeekFrom::Start(overlay_start)).is_err() + || reader.read_exact(&mut signature).is_err() + || signature != archive::SIGNATURE + { + return Err(SetupFactoryError::NotSetupFactoryFile); + } + + let metadata = archive::read_script(&mut reader, overlay_start) + .ok() + .flatten() + .map(|script| ScriptMetadata::parse(&script)) + .unwrap_or_default(); + + debug!(?metadata, "Setup Factory"); + + Ok(Self { + architecture: Architecture::from_machine(pe.machine()), + metadata, + }) + } +} + +impl Installers for SetupFactory { + fn installers(&self) -> Vec { + let apps_and_features_entry = AppsAndFeaturesEntry::builder() + .maybe_display_name(self.metadata.display_name.clone()) + .maybe_publisher(self.metadata.publisher.clone()) + .maybe_display_version( + self.metadata + .display_version + .as_deref() + .and_then(|version| version.parse::().ok()), + ) + .build(); + + vec![Installer { + architecture: self.architecture, + r#type: Some(InstallerType::Exe), + install_modes: InstallModes::all(), + // https://www.indigorose.com/webhelp/suf9/Program_Reference/Command_Line_Options.htm + switches: InstallerSwitches::builder() + .silent("/S".parse().unwrap()) + .silent_with_progress("/S".parse().unwrap()) + .build(), + apps_and_features_entries: if apps_and_features_entry.is_empty() { + AppsAndFeaturesEntries::default() + } else { + apps_and_features_entry.into() + }, + ..Installer::default() + }] + } +} diff --git a/src/analysis/installers/setup_factory/script.rs b/src/analysis/installers/setup_factory/script.rs new file mode 100644 index 00000000..ac40a8ad --- /dev/null +++ b/src/analysis/installers/setup_factory/script.rs @@ -0,0 +1,223 @@ +//! Parsing of the compiled Setup Factory setup script (`irsetup.dat`). +//! +//! The script embeds a table of the project's *session variables* - the values the author entered +//! in the Setup Factory designer, such as `%ProductName%`, `%CompanyName%` and `%ProductVer%`. Each +//! entry is a length-prefixed name (always beginning and ending with `%`) immediately followed by a +//! length-prefixed value: +//! +//! ```text +//! ..\x0d%ProductName%\x06gloCOM\x01\x00\x00\x00..\x0d%CompanyName%\x0dBicom Systems.. +//! ``` +//! +//! Entries are separated by a short, version-dependent run of bytes, so rather than decode that +//! separator exactly the walker locates the `%ProductName%` entry and then scans a small window +//! ahead for the next `[len]%name%` pair. This is resilient to the separator changing between +//! Setup Factory versions. + +/// The name of the session variable holding the product's display name. +const PRODUCT_NAME: &str = "%ProductName%"; +/// The name of the session variable holding the publisher. +const COMPANY_NAME: &str = "%CompanyName%"; +/// The name of the session variable holding the product version. +const PRODUCT_VERSION: &str = "%ProductVer%"; + +/// The maximum number of bytes between one entry's value and the next entry's length byte. +const MAX_SEPARATOR_LEN: usize = 16; + +/// The maximum number of table entries to walk before giving up (a malformed-input guard). +const MAX_ENTRIES: usize = 512; + +/// Values extracted from the setup script's session variable table. +#[derive(Debug, Default)] +pub struct ScriptMetadata { + pub display_name: Option, + pub publisher: Option, + pub display_version: Option, +} + +impl ScriptMetadata { + pub fn parse(script: &[u8]) -> Self { + let table = SessionVarTable::walk(script); + + // Skip empty values and those that are just references to other variables (e.g. a + // `%CompanyName%` whose value is left as `%CompanyURL%`). + let value = |name: &str| { + table + .iter() + .find(|(key, _)| key == name) + .map(|(_, value)| value.as_str()) + .filter(|value| !value.is_empty() && !value.contains('%')) + .map(str::to_owned) + }; + + Self { + display_name: value(PRODUCT_NAME), + publisher: value(COMPANY_NAME), + // The version variable is author-defined and is sometimes left as a bare major number + // (e.g. gloCOM ships `%ProductVer%` = "4" while the real version lives in a custom + // variable). Only trust it when it looks like a real dotted version. + display_version: value(PRODUCT_VERSION).filter(|version| version.contains('.')), + } + } +} + +struct SessionVarTable; + +impl SessionVarTable { + fn walk(script: &[u8]) -> Vec<(String, String)> { + // Anchor the table at the `%ProductName%` entry: its `%`-delimited name is preceded by its + // own length byte. + let anchor = { + let mut anchor = Vec::with_capacity(PRODUCT_NAME.len() + 1); + anchor.push(PRODUCT_NAME.len() as u8); + anchor.extend_from_slice(PRODUCT_NAME.as_bytes()); + anchor + }; + let Some(mut pos) = script + .windows(anchor.len()) + .position(|window| window == anchor) + else { + return Vec::new(); + }; + + let mut entries = Vec::new(); + for _ in 0..MAX_ENTRIES { + let Some((name, value, next)) = read_entry(script, pos) else { + break; + }; + entries.push((name, value)); + + // Find the next entry's length byte within the separator window. + let Some(offset) = (next..script.len().min(next + MAX_SEPARATOR_LEN)) + .find(|&candidate| is_entry_start(script, candidate)) + else { + break; + }; + pos = offset; + } + entries + } +} + +/// Reads a `[name_len][name][value_len][value]` entry at `pos`, returning the name, value and the +/// offset immediately after the value. Returns `None` if the bytes at `pos` are not a valid entry. +fn read_entry(script: &[u8], pos: usize) -> Option<(String, String, usize)> { + let name_len = usize::from(*script.get(pos)?); + let name = script.get(pos + 1..pos + 1 + name_len)?; + if !is_variable_name(name) { + return None; + } + + let value_pos = pos + 1 + name_len; + let value_len = usize::from(*script.get(value_pos)?); + let value = script.get(value_pos + 1..value_pos + 1 + value_len)?; + + Some(( + String::from_utf8_lossy(name).into_owned(), + String::from_utf8_lossy(value).into_owned(), + value_pos + 1 + value_len, + )) +} + +/// Returns whether a valid `[len]%name%` pair begins at `pos`. +fn is_entry_start(script: &[u8], pos: usize) -> bool { + let Some(&len) = script.get(pos) else { + return false; + }; + let len = usize::from(len); + script + .get(pos + 1..pos + 1 + len) + .is_some_and(is_variable_name) +} + +/// A session variable name is delimited by `%` and contains only printable ASCII. +fn is_variable_name(bytes: &[u8]) -> bool { + bytes.len() >= 3 + && bytes.first() == Some(&b'%') + && bytes.last() == Some(&b'%') + && bytes.iter().all(|&byte| byte.is_ascii_graphic()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Encodes a `[len][name][len][value]` entry as it appears in the session variable table. + fn entry(name: &str, value: &str) -> Vec { + let mut bytes = vec![name.len() as u8]; + bytes.extend_from_slice(name.as_bytes()); + bytes.push(value.len() as u8); + bytes.extend_from_slice(value.as_bytes()); + bytes + } + + /// Builds a table from entries joined by the four-byte separator seen between real entries. + fn table(entries: &[(&str, &str)]) -> Vec { + let mut bytes = b"leading noise".to_vec(); + for (index, (name, value)) in entries.iter().enumerate() { + if index > 0 { + bytes.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); + } + bytes.extend(entry(name, value)); + } + bytes + } + + #[test] + fn extracts_name_publisher_and_dotted_version() { + let script = table(&[ + ("%ProductName%", "Locklizard Safeguard - PDF Writer"), + ("%CompanyName%", "Locklizard Ltd."), + ("%ProductVer%", "4.0.24"), + ]); + + let metadata = ScriptMetadata::parse(&script); + + assert_eq!( + metadata.display_name.as_deref(), + Some("Locklizard Safeguard - PDF Writer") + ); + assert_eq!(metadata.publisher.as_deref(), Some("Locklizard Ltd.")); + assert_eq!(metadata.display_version.as_deref(), Some("4.0.24")); + } + + #[test] + fn ignores_bare_major_version() { + // gloCOM leaves `%ProductVer%` as a bare "4"; the real version lives elsewhere. + let script = table(&[ + ("%ProductName%", "gloCOM"), + ("%CompanyName%", "Bicom Systems"), + ("%ProductVer%", "4"), + ]); + + let metadata = ScriptMetadata::parse(&script); + + assert_eq!(metadata.display_name.as_deref(), Some("gloCOM")); + assert_eq!(metadata.publisher.as_deref(), Some("Bicom Systems")); + assert_eq!(metadata.display_version, None); + } + + #[test] + fn skips_empty_and_placeholder_values() { + let script = table(&[ + ("%ProductName%", "Communicator"), + ("%CompanyName%", ""), + ("%ProductVer%", "%CustomVersion%"), + ]); + + let metadata = ScriptMetadata::parse(&script); + + assert_eq!(metadata.display_name.as_deref(), Some("Communicator")); + assert_eq!(metadata.publisher, None); + assert_eq!(metadata.display_version, None); + } + + #[test] + fn returns_default_without_product_name() { + let metadata = ScriptMetadata::parse(b"no session variable table here"); + + assert!(metadata.display_name.is_none()); + assert!(metadata.publisher.is_none()); + assert!(metadata.display_version.is_none()); + } +}