Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion src/analysis/installers/exe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand All @@ -16,6 +17,7 @@ use crate::{
nsis::NsisError,
pe::{PE, VSVersionInfo},
qt::QtError,
setup_factory::SetupFactoryError,
sevenzip_sfx::SevenZipSfxError,
squirrel::SquirrelError,
},
Expand All @@ -40,6 +42,7 @@ pub enum ExeType {
InstallShield(Box<InstallShield>),
Nsis(Nsis),
Qt(Qt),
SetupFactory(SetupFactory),
SevenZipSfx(SevenZipSfx),
Squirrel(Squirrel),
Generic(Box<Installer>),
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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()],
Expand Down
2 changes: 2 additions & 0 deletions src/analysis/installers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
139 changes: 139 additions & 0 deletions src/analysis/installers/setup_factory/archive.rs
Original file line number Diff line number Diff line change
@@ -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;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

which test cases have this? is the real issue that we're not parsing a specific Factory version properly?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All seven test installers take this path — it's the normal Setup Factory 9 layout, not a version mis-parse. After irsetup.exe, SF9 embeds a second special file (lua5.1.dll, ~350 KB) before the file table. The u32 read here to disambiguate is the low half of that DLL's 64-bit size (~350000), hence >1000; a genuine file count is small. Expanded the doc comment in 484018c to spell this out.


Generated by Claude Code


/// 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<R: Read + Seek>(
reader: &mut R,
overlay_start: u64,
) -> io::Result<Option<Vec<u8>>> {
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::<LittleEndian>()?;
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::<u32>() as i64)))?;
skip_special_file(reader)?;
file_count = reader.read_u32::<LittleEndian>()?;
}

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::<u32>() as i64))?; // CRC-32
reader.seek(SeekFrom::Current(size_of::<u32>() 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<R: Read + Seek>(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<R: Read>(reader: &mut R) -> io::Result<i64> {
let mut bytes = [0; size_of::<i64>()];
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<Vec<u8>> {
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)
}
99 changes: 99 additions & 0 deletions src/analysis/installers/setup_factory/mod.rs
Original file line number Diff line number Diff line change
@@ -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<R: Read + Seek>(mut reader: R, pe: &PE) -> Result<Self, SetupFactoryError> {
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<Installer> {
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::<Version>().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()
}]
}
}
Loading
Loading