From 5df1af9c17c8040478271c18465dab5d7685ce8a Mon Sep 17 00:00:00 2001 From: Matt Nadareski Date: Thu, 23 Oct 2025 16:05:12 -0400 Subject: [PATCH] Minor cleanup and additions --- .../InstallShieldExecutableFileTests.cs | 73 +++++++++++++++++++ .../{ExtractableFile.cs => FileEntry.cs} | 13 ++-- .../Models/InstallShieldExecutable/SFX.cs | 42 +++++++++++ .../Readers/InstallShieldExecutableFile.cs | 47 ++++++------ .../Wrappers/PortableExecutable.Extraction.cs | 51 ++++++------- 5 files changed, 168 insertions(+), 58 deletions(-) create mode 100644 SabreTools.Serialization.Test/Readers/InstallShieldExecutableFileTests.cs rename SabreTools.Serialization/Models/InstallShieldExecutable/{ExtractableFile.cs => FileEntry.cs} (56%) create mode 100644 SabreTools.Serialization/Models/InstallShieldExecutable/SFX.cs diff --git a/SabreTools.Serialization.Test/Readers/InstallShieldExecutableFileTests.cs b/SabreTools.Serialization.Test/Readers/InstallShieldExecutableFileTests.cs new file mode 100644 index 00000000..a11c426f --- /dev/null +++ b/SabreTools.Serialization.Test/Readers/InstallShieldExecutableFileTests.cs @@ -0,0 +1,73 @@ +using System.IO; +using System.Linq; +using SabreTools.Serialization.Readers; +using Xunit; + +namespace SabreTools.Serialization.Test.Readers +{ + public class InstallShieldExecutableFileTests + { + [Fact] + public void NullArray_Null() + { + byte[]? data = null; + int offset = 0; + var deserializer = new InstallShieldExecutableFile(); + + var actual = deserializer.Deserialize(data, offset); + Assert.Null(actual); + } + + [Fact] + public void EmptyArray_Null() + { + byte[]? data = []; + int offset = 0; + var deserializer = new InstallShieldExecutableFile(); + + var actual = deserializer.Deserialize(data, offset); + Assert.Null(actual); + } + + [Fact] + public void InvalidArray_Null() + { + byte[]? data = [.. Enumerable.Repeat(0xFF, 1024)]; + int offset = 0; + var deserializer = new InstallShieldExecutableFile(); + + var actual = deserializer.Deserialize(data, offset); + Assert.Null(actual); + } + + [Fact] + public void NullStream_Null() + { + Stream? data = null; + var deserializer = new InstallShieldExecutableFile(); + + var actual = deserializer.Deserialize(data); + Assert.Null(actual); + } + + [Fact] + public void EmptyStream_Null() + { + Stream? data = new MemoryStream([]); + var deserializer = new InstallShieldExecutableFile(); + + var actual = deserializer.Deserialize(data); + Assert.Null(actual); + } + + [Fact] + public void InvalidStream_Null() + { + Stream? data = new MemoryStream([.. Enumerable.Repeat(0xFF, 1024)]); + var deserializer = new InstallShieldExecutableFile(); + + var actual = deserializer.Deserialize(data); + Assert.Null(actual); + } + } +} diff --git a/SabreTools.Serialization/Models/InstallShieldExecutable/ExtractableFile.cs b/SabreTools.Serialization/Models/InstallShieldExecutable/FileEntry.cs similarity index 56% rename from SabreTools.Serialization/Models/InstallShieldExecutable/ExtractableFile.cs rename to SabreTools.Serialization/Models/InstallShieldExecutable/FileEntry.cs index 10d54bcc..1d6a5c8f 100644 --- a/SabreTools.Serialization/Models/InstallShieldExecutable/ExtractableFile.cs +++ b/SabreTools.Serialization/Models/InstallShieldExecutable/FileEntry.cs @@ -1,20 +1,23 @@ namespace SabreTools.Data.Models.InstallShieldExecutable { - public class ExtractableFile + public class FileEntry { /// - /// Name of the file, only ASCII characters(?) + /// Name of the file /// + /// May only contain ASCII (7-bit) characters public string? Name { get; set; } /// - /// Path of the file, only ASCII characters(?), seems to usually use \ filepaths + /// Path of the file, seems to usually use \ filepaths /// - public string? Path { get; set; } + /// May only contain ASCII (7-bit) characters + public string? Path { get; set; } /// /// Version of the file /// + /// May only contain ASCII (7-bit) characters public string? Version { get; set; } /// @@ -22,4 +25,4 @@ namespace SabreTools.Data.Models.InstallShieldExecutable /// public ulong Length { get; set; } } -} \ No newline at end of file +} diff --git a/SabreTools.Serialization/Models/InstallShieldExecutable/SFX.cs b/SabreTools.Serialization/Models/InstallShieldExecutable/SFX.cs new file mode 100644 index 00000000..2a8c6e3f --- /dev/null +++ b/SabreTools.Serialization/Models/InstallShieldExecutable/SFX.cs @@ -0,0 +1,42 @@ +namespace SabreTools.Data.Models.InstallShieldExecutable +{ + /// + /// Represents the layout of of the overlay area of an + /// InstallShield executable. + /// + /// The layout of this is derived from the layout in the + /// physical file. + /// + /// + /// According to ISx source, there are two categories of installshield executables, + /// plain and encrypted. Encrypted has two different "types" it can be, specified by + /// a header. "InstallShield" and a newer format from 2015?-onwards called "ISSetupStream". + /// Plain executables have no central header, and each file is unencrypted. Files + /// in "InstallShield" encrypted executables have encryption applied over block sizes + /// of 1024 bytes, and files in "ISSetupStream" encrypted executables are encrypted + /// per-file. There's also something about leading data that isn't explained + /// (at least not clearly), and these encrypted executables can also additionally have + /// their files compressed with inflate. + /// + /// While not stated in ISx; from experience, executables with "InstallShield" often + /// (if not always?) mainly consist of a singular, large MSI installer along with some + /// helper files, whereas plain executables often (if not always?) mainly consist of + /// regular installshield cabinets within. At the moment, this code only supports and + /// documents the plain variant. Clearer naming and separation between the types is yet + /// to come. + /// + /// TODO: Look into making the array a dictionary + /// There is no unified header or footer that indicates a file + /// table, so having either each file entry cache the data + /// or be associated with an offset may make it more useful. + /// + /// It will need to be made very apparent that the dictionary + /// does not directly represent the structure. + public class SFX + { + /// + /// Set of file entries + /// + public FileEntry[]? Entries { get; set; } + } +} diff --git a/SabreTools.Serialization/Readers/InstallShieldExecutableFile.cs b/SabreTools.Serialization/Readers/InstallShieldExecutableFile.cs index 56305e2f..ee4b427d 100644 --- a/SabreTools.Serialization/Readers/InstallShieldExecutableFile.cs +++ b/SabreTools.Serialization/Readers/InstallShieldExecutableFile.cs @@ -4,9 +4,10 @@ using SabreTools.IO.Extensions; namespace SabreTools.Serialization.Readers { - public class InstallShieldExecutableFile : BaseBinaryReader + // TODO: This should parse an entire SFX, not just a single entry + public class InstallShieldExecutableFile : BaseBinaryReader { - public override ExtractableFile? Deserialize(Stream? data) + public override FileEntry? Deserialize(Stream? data) { // If the data is invalid if (data == null || !data.CanRead) @@ -17,50 +18,48 @@ namespace SabreTools.Serialization.Readers // Cache the initial offset long initialOffset = data.Position; - // Try to parse the header - var header = ParseExtractableFileHeader(data); - if (header == null) + // Try to parse the entry + var fileEntry = ParseFileEntry(data); + if (fileEntry == null) return null; - return header; + return fileEntry; } catch { // Ignore the actual error return null; - } + } } - + /// - /// Parse a Stream into an InstallShield Executable file header + /// Parse a Stream into a FileEntry /// /// Stream to parse - /// Filled InstallShield Executable file header on success, null on error - public static ExtractableFile? ParseExtractableFileHeader(Stream? data) + /// Filled FileEntry on success, null on error + public static FileEntry? ParseFileEntry(Stream? data) { - var obj = new ExtractableFile(); - + var obj = new FileEntry(); + obj.Name = data.ReadNullTerminatedAnsiString(); if (obj.Name == null) return null; - + obj.Path = data.ReadNullTerminatedAnsiString(); if (obj.Path == null) return null; - + obj.Version = data.ReadNullTerminatedAnsiString(); if (obj.Version == null) return null; - - var versionString = data.ReadNullTerminatedAnsiString(); - if (versionString == null || !ulong.TryParse(versionString, out var foundVersion)) + + var lengthString = data.ReadNullTerminatedAnsiString(); + if (lengthString == null || !ulong.TryParse(lengthString, out var lengthValue)) return null; - - obj.Length = foundVersion; - if (obj.Name == null) - return null; - + + obj.Length = lengthValue; + return obj; } } -} \ No newline at end of file +} diff --git a/SabreTools.Serialization/Wrappers/PortableExecutable.Extraction.cs b/SabreTools.Serialization/Wrappers/PortableExecutable.Extraction.cs index b76a0c49..a455ee4a 100644 --- a/SabreTools.Serialization/Wrappers/PortableExecutable.Extraction.cs +++ b/SabreTools.Serialization/Wrappers/PortableExecutable.Extraction.cs @@ -42,8 +42,8 @@ namespace SabreTools.Serialization.Wrappers bool overlay = cai || issexe || spoon || wiseOverlay || ExtractFromOverlay(outputDirectory, includeDebug); - return cai || cexe || issexe || matroschka || overlay || resources || spoon - || wiseOverlay || wiseSection; + return cai || cexe || issexe || matroschka || overlay || resources + || spoon || wiseOverlay || wiseSection; } /// @@ -160,7 +160,7 @@ namespace SabreTools.Serialization.Wrappers return false; } } - + /// /// Extract data from an InstallShield Executable /// @@ -171,37 +171,32 @@ namespace SabreTools.Serialization.Wrappers { try { - long overlayAddress = OverlayAddress; - // Return if overlay doesn't exist. - if (overlayAddress == -1) + long overlayAddress = OverlayAddress; + if (overlayAddress < 0) return false; - - // Ensure the stream is starting at the overlay address - _dataSource.Seek(overlayAddress, SeekOrigin.Begin); - var streamLength = _dataSource.Length; - const int chunkSize = 65536; - var deserializer = new Readers.InstallShieldExecutableFile(); - - while (_dataSource.Position < streamLength) + const int chunkSize = 64 * 1024; + var reader = new Readers.InstallShieldExecutableFile(); + + lock (_dataSourceLock) { - lock (_dataSourceLock) + // Ensure the stream is starting at the overlay address + _dataSource.Seek(overlayAddress, SeekOrigin.Begin); + + while (_dataSource.Position < _dataSource.Length) { // Try to deserialize the source data - - var entry = deserializer.Deserialize(_dataSource); + var entry = reader.Deserialize(_dataSource); if (entry?.Path == null) return false; - + // Get the length, and make sure it won't EOF - var length = (long)entry.Length; - if (length > streamLength - _dataSource.Position) + long length = (long)entry.Length; + if (length > _dataSource.Length - _dataSource.Position) break; // Ensure directory separators are consistent - // Path is used instead of Name because Path contains the filename anyways. - var filename = entry.Path.TrimEnd('\0'); if (Path.DirectorySeparatorChar == '\\') filename = filename.Replace('/', '\\'); @@ -216,21 +211,19 @@ namespace SabreTools.Serialization.Wrappers // Write the output file using var fs = File.Open(filename, FileMode.Create, FileAccess.Write, FileShare.ReadWrite); - Console.WriteLine($"Attempting to extract {entry.Name} from potential InstallShield Executable"); - - // Read from file in chunks in order to save memory, since some extracted files will be large - // Chunk size is purely arbitrary and can be adjusted as needed. - // Read file from InstallShield Executable and write it as an output file. while (length > 0) { - var bytesToRead = (int)Math.Min(length, chunkSize); - var buffer = _dataSource.ReadBytes(bytesToRead); + int bytesToRead = (int)Math.Min(length, chunkSize); + + byte[] buffer = _dataSource.ReadBytes(bytesToRead); fs.Write(buffer, 0, bytesToRead); fs.Flush(); + length -= bytesToRead; } } } + return true; } catch (Exception ex)