From 5dfe6aefb0c97d6b0b60c9b6df7b6e5b02569078 Mon Sep 17 00:00:00 2001 From: HeroponRikiBestest <50224630+HeroponRikiBestest@users.noreply.github.com> Date: Sun, 25 Jan 2026 13:36:17 -0500 Subject: [PATCH] Convert Installshield Executable code to use proper reader/wrapper instead of living in PortableExecutable (#59) * Figure out how to access OverlayAddress in wrapper or reader (ideally the latter) for a non-PE reader/wrapper * Code works * Remove TODOs * First round of fixes. * use constants * remove comment --- .../InstallShieldExecutableFileTests.cs | 14 +-- .../InstallShieldExecutable/Constants.cs | 15 +++ .../InstallShieldExecutable/FileEntry.cs | 9 ++ .../Models/InstallShieldExecutable/SFX.cs | 1 - .../Readers/InstallShieldExecutable.cs | 98 +++++++++++++++++++ .../Readers/InstallShieldExecutableFile.cs | 68 ------------- .../InstallShieldExecutable.Extraction.cs | 65 ++++++++++++ .../Wrappers/InstallShieldExecutable.cs | 97 ++++++++++++++++++ .../Wrappers/PortableExecutable.Extraction.cs | 88 +++-------------- .../Wrappers/PortableExecutable.cs | 60 ++++++++++++ 10 files changed, 367 insertions(+), 148 deletions(-) create mode 100644 SabreTools.Serialization/Models/InstallShieldExecutable/Constants.cs create mode 100644 SabreTools.Serialization/Readers/InstallShieldExecutable.cs delete mode 100644 SabreTools.Serialization/Readers/InstallShieldExecutableFile.cs create mode 100644 SabreTools.Serialization/Wrappers/InstallShieldExecutable.Extraction.cs create mode 100644 SabreTools.Serialization/Wrappers/InstallShieldExecutable.cs diff --git a/SabreTools.Serialization.Test/Readers/InstallShieldExecutableFileTests.cs b/SabreTools.Serialization.Test/Readers/InstallShieldExecutableFileTests.cs index a11c426f..0db81f14 100644 --- a/SabreTools.Serialization.Test/Readers/InstallShieldExecutableFileTests.cs +++ b/SabreTools.Serialization.Test/Readers/InstallShieldExecutableFileTests.cs @@ -5,14 +5,14 @@ using Xunit; namespace SabreTools.Serialization.Test.Readers { - public class InstallShieldExecutableFileTests + public class InstallShieldExecutableTests { [Fact] public void NullArray_Null() { byte[]? data = null; int offset = 0; - var deserializer = new InstallShieldExecutableFile(); + var deserializer = new InstallShieldExecutable(); var actual = deserializer.Deserialize(data, offset); Assert.Null(actual); @@ -23,7 +23,7 @@ namespace SabreTools.Serialization.Test.Readers { byte[]? data = []; int offset = 0; - var deserializer = new InstallShieldExecutableFile(); + var deserializer = new InstallShieldExecutable(); var actual = deserializer.Deserialize(data, offset); Assert.Null(actual); @@ -34,7 +34,7 @@ namespace SabreTools.Serialization.Test.Readers { byte[]? data = [.. Enumerable.Repeat(0xFF, 1024)]; int offset = 0; - var deserializer = new InstallShieldExecutableFile(); + var deserializer = new InstallShieldExecutable(); var actual = deserializer.Deserialize(data, offset); Assert.Null(actual); @@ -44,7 +44,7 @@ namespace SabreTools.Serialization.Test.Readers public void NullStream_Null() { Stream? data = null; - var deserializer = new InstallShieldExecutableFile(); + var deserializer = new InstallShieldExecutable(); var actual = deserializer.Deserialize(data); Assert.Null(actual); @@ -54,7 +54,7 @@ namespace SabreTools.Serialization.Test.Readers public void EmptyStream_Null() { Stream? data = new MemoryStream([]); - var deserializer = new InstallShieldExecutableFile(); + var deserializer = new InstallShieldExecutable(); var actual = deserializer.Deserialize(data); Assert.Null(actual); @@ -64,7 +64,7 @@ namespace SabreTools.Serialization.Test.Readers public void InvalidStream_Null() { Stream? data = new MemoryStream([.. Enumerable.Repeat(0xFF, 1024)]); - var deserializer = new InstallShieldExecutableFile(); + var deserializer = new InstallShieldExecutable(); var actual = deserializer.Deserialize(data); Assert.Null(actual); diff --git a/SabreTools.Serialization/Models/InstallShieldExecutable/Constants.cs b/SabreTools.Serialization/Models/InstallShieldExecutable/Constants.cs new file mode 100644 index 00000000..88038c83 --- /dev/null +++ b/SabreTools.Serialization/Models/InstallShieldExecutable/Constants.cs @@ -0,0 +1,15 @@ +namespace SabreTools.Data.Models.InstallShieldExecutable +{ + public static class Constants + { + /// + /// The signature for one of two kinds of currently unsupported ISEXE formats. + /// + public const string ISSignatureString = "InstallShield"; + + /// + /// The signature for one of two kinds of currently unsupported ISEXE formats. + /// + public const string ISSetupSignatureString = "ISSetupStream"; + } +} \ No newline at end of file diff --git a/SabreTools.Serialization/Models/InstallShieldExecutable/FileEntry.cs b/SabreTools.Serialization/Models/InstallShieldExecutable/FileEntry.cs index 91a76988..f35fe833 100644 --- a/SabreTools.Serialization/Models/InstallShieldExecutable/FileEntry.cs +++ b/SabreTools.Serialization/Models/InstallShieldExecutable/FileEntry.cs @@ -1,5 +1,8 @@ namespace SabreTools.Data.Models.InstallShieldExecutable { + /// + /// Set of attributes for each fileEntry in an InstallShield Executable + /// public class FileEntry { /// @@ -24,5 +27,11 @@ namespace SabreTools.Data.Models.InstallShieldExecutable /// Length of the file. Stored in the installshield executable as a string. /// public ulong Length { get; set; } + + /// + /// Offset of the file. + /// + /// This is not stored in the installshield executable, but it needs to be stored here for extraction. + public long Offset { get; set; } } } diff --git a/SabreTools.Serialization/Models/InstallShieldExecutable/SFX.cs b/SabreTools.Serialization/Models/InstallShieldExecutable/SFX.cs index f2c0553b..ee2e572e 100644 --- a/SabreTools.Serialization/Models/InstallShieldExecutable/SFX.cs +++ b/SabreTools.Serialization/Models/InstallShieldExecutable/SFX.cs @@ -25,7 +25,6 @@ namespace SabreTools.Data.Models.InstallShieldExecutable /// 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. diff --git a/SabreTools.Serialization/Readers/InstallShieldExecutable.cs b/SabreTools.Serialization/Readers/InstallShieldExecutable.cs new file mode 100644 index 00000000..dd722d34 --- /dev/null +++ b/SabreTools.Serialization/Readers/InstallShieldExecutable.cs @@ -0,0 +1,98 @@ +using System.Collections.Generic; +using System.IO; +using SabreTools.Data.Models.InstallShieldExecutable; +using SabreTools.IO.Extensions; +using static SabreTools.Data.Models.InstallShieldExecutable.Constants; + +namespace SabreTools.Serialization.Readers +{ + public class InstallShieldExecutable : BaseBinaryReader + { + public override SFX? Deserialize(Stream? data) + { + // If the data is invalid + if (data == null || !data.CanRead) + return null; + + try + { + var sfx = new SFX(); + + // Cache the initial offset + long initialOffset = data.Position; + + var sfxList = new List(); + + while (data.Position < data.Length) + { + // Try to parse the entry + var fileEntry = ParseFileEntry(data, initialOffset); + if (fileEntry == null) + break; + + // Get the length, and make sure it won't EOF + long length = (long)fileEntry.Length; + if (length > data.Length - data.Position) + break; + + data.SeekIfPossible(length, SeekOrigin.Current); + sfxList.Add(fileEntry); + } + + if (sfxList.Count == 0) + return null; + + sfx.Entries = [.. sfxList]; + return sfx; + } + catch + { + // Ignore the actual error + return null; + } + } + + /// + /// Parse a Stream into a FileEntry + /// + /// Stream to parse + /// Filled FileEntry on success, null on error + public static FileEntry? ParseFileEntry(Stream data, long initialOffset) + { + string? name = data.ReadNullTerminatedAnsiString(); + if (name == null) + return null; + + // Both of these strings indicate that this is a different kind of encrypted and/or compressed format of + // ISEXE that is not yet supported, but will be in the future. + // They return early because no extraction can be performed, like how MsCab currently returns if a folder + // is LZX or Quantum. + if (name == ISSignatureString) + return null; + + if (name == ISSetupSignatureString) + return null; + + string? path = data.ReadNullTerminatedAnsiString(); + if (path == null) + return null; + + string? version = data.ReadNullTerminatedAnsiString(); + if (version == null) + return null; + + var lengthString = data.ReadNullTerminatedAnsiString(); + if (lengthString == null || !ulong.TryParse(lengthString, out var lengthValue)) + return null; + + var obj = new FileEntry(); + obj.Name = name; + obj.Path = path; + obj.Version = version; + obj.Length = lengthValue; + obj.Offset = data.Position - initialOffset; + + return obj; + } + } +} diff --git a/SabreTools.Serialization/Readers/InstallShieldExecutableFile.cs b/SabreTools.Serialization/Readers/InstallShieldExecutableFile.cs deleted file mode 100644 index 2f23e496..00000000 --- a/SabreTools.Serialization/Readers/InstallShieldExecutableFile.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System.IO; -using SabreTools.Data.Models.InstallShieldExecutable; -using SabreTools.IO.Extensions; - -namespace SabreTools.Serialization.Readers -{ - // TODO: This should parse an entire SFX, not just a single entry - public class InstallShieldExecutableFile : BaseBinaryReader - { - public override FileEntry? Deserialize(Stream? data) - { - // If the data is invalid - if (data == null || !data.CanRead) - return null; - - try - { - // Cache the initial offset - long initialOffset = data.Position; - - // Try to parse the entry - var fileEntry = ParseFileEntry(data); - if (fileEntry == null) - return null; - - return fileEntry; - } - catch - { - // Ignore the actual error - return null; - } - } - - /// - /// Parse a Stream into a FileEntry - /// - /// Stream to parse - /// Filled FileEntry on success, null on error - public static FileEntry? ParseFileEntry(Stream data) - { - string? name = data.ReadNullTerminatedAnsiString(); - if (name == null) - return null; - - string? path = data.ReadNullTerminatedAnsiString(); - if (path == null) - return null; - - string? version = data.ReadNullTerminatedAnsiString(); - if (version == null) - return null; - - var lengthString = data.ReadNullTerminatedAnsiString(); - if (lengthString == null || !ulong.TryParse(lengthString, out var lengthValue)) - return null; - - var obj = new FileEntry(); - - obj.Name = name; - obj.Path = path; - obj.Version = version; - obj.Length = lengthValue; - - return obj; - } - } -} diff --git a/SabreTools.Serialization/Wrappers/InstallShieldExecutable.Extraction.cs b/SabreTools.Serialization/Wrappers/InstallShieldExecutable.Extraction.cs new file mode 100644 index 00000000..9e222e70 --- /dev/null +++ b/SabreTools.Serialization/Wrappers/InstallShieldExecutable.Extraction.cs @@ -0,0 +1,65 @@ +using System; +using System.IO; +using SabreTools.IO.Extensions; + +namespace SabreTools.Serialization.Wrappers +{ + public partial class InstallShieldExecutable : IExtractable + { + #region Extraction + + /// + public bool Extract(string outputDirectory, bool includeDebug) + { + const int chunkSize = 2048 * 1024; + try + { + for (int i = 0; i < Entries.Length; i++) + { + var entry = Entries[i]; + _dataSource.SeekIfPossible(entry.Offset, SeekOrigin.Begin); + + // Get the length, and make sure it won't EOF + long length = (long)entry.Length; + if (length > _dataSource.Length - _dataSource.Position) + break; + + // Ensure directory separators are consistent + var filename = entry.Path.TrimEnd('\0'); + if (Path.DirectorySeparatorChar == '\\') + filename = filename.Replace('/', '\\'); + else if (Path.DirectorySeparatorChar == '/') + filename = filename.Replace('\\', '/'); + + // Ensure the full output directory exists + filename = Path.Combine(outputDirectory, filename); + var directoryName = Path.GetDirectoryName(filename); + if (directoryName != null && !Directory.Exists(directoryName)) + Directory.CreateDirectory(directoryName); + + // Write the output file + using var fs = File.Open(filename, FileMode.Create, FileAccess.Write, FileShare.ReadWrite); + while (length > 0) + { + 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) + { + if (includeDebug) Console.Error.WriteLine(ex); + return false; + } + } + + #endregion + } +} diff --git a/SabreTools.Serialization/Wrappers/InstallShieldExecutable.cs b/SabreTools.Serialization/Wrappers/InstallShieldExecutable.cs new file mode 100644 index 00000000..3c8b82c0 --- /dev/null +++ b/SabreTools.Serialization/Wrappers/InstallShieldExecutable.cs @@ -0,0 +1,97 @@ +using System.IO; +using SabreTools.Data.Models.InstallShieldExecutable; + +namespace SabreTools.Serialization.Wrappers +{ + public partial class InstallShieldExecutable : WrapperBase + { + #region Descriptive Properties + + /// + public override string DescriptionString => "InstallShield Executable"; + + #endregion + + #region Extension Properties + + /// + public FileEntry[] Entries => Model.Entries; + + #endregion + + #region Constructors + + /// + public InstallShieldExecutable(SFX model, byte[] data) : base(model, data) { } + + /// + public InstallShieldExecutable(SFX model, byte[] data, int offset) : base(model, data, offset) { } + + /// + public InstallShieldExecutable(SFX model, byte[] data, int offset, int length) : base(model, data, offset, length) { } + + /// + public InstallShieldExecutable(SFX model, Stream data) : base(model, data) { } + + /// + public InstallShieldExecutable(SFX model, Stream data, long offset) : base(model, data, offset) { } + + /// + public InstallShieldExecutable(SFX model, Stream data, long offset, long length) : base(model, data, offset, length) { } + + #endregion + + #region Static Constructors + + /// + /// Create an InstallShield Executable from a byte array and offset + /// + /// Byte array representing the executable + /// Offset within the array to parse + /// An executable wrapper on success, null on failure + public static InstallShieldExecutable? Create(byte[]? data, int offset) + { + // If the data is invalid + if (data == null || data.Length == 0) + return null; + + // If the offset is out of bounds + if (offset < 0 || offset >= data.Length) + return null; + + // Create a memory stream and use that + var dataStream = new MemoryStream(data, offset, data.Length - offset); + return Create(dataStream); + } + + /// + /// Create an InstallShield Executable from a Stream + /// + /// Stream representing the executable + /// An executable wrapper on success, null on failure + public static InstallShieldExecutable? Create(Stream? data) + { + // If the data is invalid + if (data == null || !data.CanRead) + return null; + + try + { + // Cache the current offset + long currentOffset = data.Position; + + var model = new Readers.InstallShieldExecutable().Deserialize(data); + if (model == null) + return null; + + return new InstallShieldExecutable(model, data, currentOffset); + } + catch + { + return null; + } + } + + #endregion + } +} diff --git a/SabreTools.Serialization/Wrappers/PortableExecutable.Extraction.cs b/SabreTools.Serialization/Wrappers/PortableExecutable.Extraction.cs index e63f2c73..5d6d8fe1 100644 --- a/SabreTools.Serialization/Wrappers/PortableExecutable.Extraction.cs +++ b/SabreTools.Serialization/Wrappers/PortableExecutable.Extraction.cs @@ -161,78 +161,6 @@ namespace SabreTools.Serialization.Wrappers } } - /// - /// Extract data from an InstallShield Executable - /// - /// Output directory to write to - /// True to include debug data, false otherwise - /// True if extraction succeeded, false otherwise - public bool ExtractInstallShieldExecutable(string outputDirectory, bool includeDebug) - { - try - { - // Return if overlay doesn't exist. - long overlayAddress = OverlayAddress; - if (overlayAddress < 0) - return false; - - const int chunkSize = 2048 * 1024; - var reader = new Readers.InstallShieldExecutableFile(); - - lock (_dataSourceLock) - { - // Ensure the stream is starting at the overlay address - _dataSource.SeekIfPossible(overlayAddress, SeekOrigin.Begin); - - while (_dataSource.Position < _dataSource.Length) - { - // Try to deserialize the source data - var entry = reader.Deserialize(_dataSource); - if (entry?.Path == null) - return false; - - // Get the length, and make sure it won't EOF - long length = (long)entry.Length; - if (length > _dataSource.Length - _dataSource.Position) - break; - - // Ensure directory separators are consistent - var filename = entry.Path.TrimEnd('\0'); - if (Path.DirectorySeparatorChar == '\\') - filename = filename.Replace('/', '\\'); - else if (Path.DirectorySeparatorChar == '/') - filename = filename.Replace('\\', '/'); - - // Ensure the full output directory exists - filename = Path.Combine(outputDirectory, filename); - var directoryName = Path.GetDirectoryName(filename); - if (directoryName != null && !Directory.Exists(directoryName)) - Directory.CreateDirectory(directoryName); - - // Write the output file - using var fs = File.Open(filename, FileMode.Create, FileAccess.Write, FileShare.ReadWrite); - while (length > 0) - { - 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) - { - if (includeDebug) Console.Error.WriteLine(ex); - return false; - } - } - /// /// Extract data from the overlay /// @@ -611,6 +539,22 @@ namespace SabreTools.Serialization.Wrappers // Attempt to extract package return MatroschkaPackage.Extract(outputDirectory, includeDebug); } + + /// + /// Extract data from an Installshield Executable + /// + /// Output directory to write to + /// True to include debug data, false otherwise + /// True if extraction succeeded, false otherwise + public bool ExtractInstallShieldExecutable(string outputDirectory, bool includeDebug) + { + // Check if executable contains an InstallShield Executable or not + if (ISEXE == null) + return false; + + // Attempt to extract package + return ISEXE.Extract(outputDirectory, includeDebug); + } /// /// Extract a Spoon Installer SFX overlay diff --git a/SabreTools.Serialization/Wrappers/PortableExecutable.cs b/SabreTools.Serialization/Wrappers/PortableExecutable.cs index c07c8aeb..8bb41617 100644 --- a/SabreTools.Serialization/Wrappers/PortableExecutable.cs +++ b/SabreTools.Serialization/Wrappers/PortableExecutable.cs @@ -257,6 +257,56 @@ namespace SabreTools.Serialization.Wrappers } } } = null; + + /// + /// InstallShield Executable wrapper, if it exists + /// + public InstallShieldExecutable? ISEXE + { + get + { + lock (_installshieldExecutableLock) + { + // Use the cached data if possible + if (field != null) + return field; + + // Check to see if creation has already been attempted + if (_installshieldExecutableFailed) + return null; + + // Get the available source length, if possible + var dataLength = Length; + if (dataLength == -1) + { + _installshieldExecutableFailed = true; + return null; + } + + // Check if there's a valid OverlayAddress + if (OverlayAddress < 0 || OverlayAddress > dataLength) + { + _installshieldExecutableFailed = true; + return null; + } + + // Parse the package + lock (_dataSourceLock) + { + _dataSource.SeekIfPossible(OverlayAddress, SeekOrigin.Begin); + field = InstallShieldExecutable.Create(_dataSource); + } + + if (field?.Entries.Length == 0) + { + _installshieldExecutableFailed = true; + return null; + } + + return field; + } + } + } = null; /// public Data.Models.PortableExecutable.OptionalHeader OptionalHeader => Model.OptionalHeader; @@ -1028,6 +1078,16 @@ namespace SabreTools.Serialization.Wrappers /// private readonly object _headerPaddingStringsLock = new(); + /// + /// Lock object for + /// + private readonly object _installshieldExecutableLock = new(); + + /// + /// Cached attempt at creation for + /// + private bool _installshieldExecutableFailed = false; + /// /// Lock object for ///