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
This commit is contained in:
HeroponRikiBestest
2026-01-25 13:36:17 -05:00
committed by GitHub
parent 7f7d0f84ef
commit 5dfe6aefb0
10 changed files with 367 additions and 148 deletions

View File

@@ -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<byte>(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<byte>(0xFF, 1024)]);
var deserializer = new InstallShieldExecutableFile();
var deserializer = new InstallShieldExecutable();
var actual = deserializer.Deserialize(data);
Assert.Null(actual);

View File

@@ -0,0 +1,15 @@
namespace SabreTools.Data.Models.InstallShieldExecutable
{
public static class Constants
{
/// <summary>
/// The signature for one of two kinds of currently unsupported ISEXE formats.
/// </summary>
public const string ISSignatureString = "InstallShield";
/// <summary>
/// The signature for one of two kinds of currently unsupported ISEXE formats.
/// </summary>
public const string ISSetupSignatureString = "ISSetupStream";
}
}

View File

@@ -1,5 +1,8 @@
namespace SabreTools.Data.Models.InstallShieldExecutable
{
/// <summary>
/// Set of attributes for each fileEntry in an InstallShield Executable
/// </summary>
public class FileEntry
{
/// <summary>
@@ -24,5 +27,11 @@ namespace SabreTools.Data.Models.InstallShieldExecutable
/// Length of the file. Stored in the installshield executable as a string.
/// </summary>
public ulong Length { get; set; }
/// <summary>
/// Offset of the file.
/// </summary>
/// <remarks>This is not stored in the installshield executable, but it needs to be stored here for extraction.</remarks>
public long Offset { get; set; }
}
}

View File

@@ -25,7 +25,6 @@ namespace SabreTools.Data.Models.InstallShieldExecutable
/// documents the plain variant. Clearer naming and separation between the types is yet
/// to come.
/// </remarks>
/// 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.

View File

@@ -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<SFX>
{
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<FileEntry>();
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;
}
}
/// <summary>
/// Parse a Stream into a FileEntry
/// </summary>
/// <param name="data">Stream to parse</param>
/// <returns>Filled FileEntry on success, null on error</returns>
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;
}
}
}

View File

@@ -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<FileEntry>
{
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;
}
}
/// <summary>
/// Parse a Stream into a FileEntry
/// </summary>
/// <param name="data">Stream to parse</param>
/// <returns>Filled FileEntry on success, null on error</returns>
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;
}
}
}

View File

@@ -0,0 +1,65 @@
using System;
using System.IO;
using SabreTools.IO.Extensions;
namespace SabreTools.Serialization.Wrappers
{
public partial class InstallShieldExecutable : IExtractable
{
#region Extraction
/// <inheritdoc/>
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
}
}

View File

@@ -0,0 +1,97 @@
using System.IO;
using SabreTools.Data.Models.InstallShieldExecutable;
namespace SabreTools.Serialization.Wrappers
{
public partial class InstallShieldExecutable : WrapperBase<SFX>
{
#region Descriptive Properties
/// <inheritdoc/>
public override string DescriptionString => "InstallShield Executable";
#endregion
#region Extension Properties
/// <inheritdoc cref="SFX.FileEntry"/>
public FileEntry[] Entries => Model.Entries;
#endregion
#region Constructors
/// <inheritdoc/>
public InstallShieldExecutable(SFX model, byte[] data) : base(model, data) { }
/// <inheritdoc/>
public InstallShieldExecutable(SFX model, byte[] data, int offset) : base(model, data, offset) { }
/// <inheritdoc/>
public InstallShieldExecutable(SFX model, byte[] data, int offset, int length) : base(model, data, offset, length) { }
/// <inheritdoc/>
public InstallShieldExecutable(SFX model, Stream data) : base(model, data) { }
/// <inheritdoc/>
public InstallShieldExecutable(SFX model, Stream data, long offset) : base(model, data, offset) { }
/// <inheritdoc/>
public InstallShieldExecutable(SFX model, Stream data, long offset, long length) : base(model, data, offset, length) { }
#endregion
#region Static Constructors
/// <summary>
/// Create an InstallShield Executable from a byte array and offset
/// </summary>
/// <param name="data">Byte array representing the executable</param>
/// <param name="offset">Offset within the array to parse</param>
/// <returns>An executable wrapper on success, null on failure</returns>
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);
}
/// <summary>
/// Create an InstallShield Executable from a Stream
/// </summary>
/// <param name="data">Stream representing the executable</param>
/// <returns>An executable wrapper on success, null on failure</returns>
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
}
}

View File

@@ -161,78 +161,6 @@ namespace SabreTools.Serialization.Wrappers
}
}
/// <summary>
/// Extract data from an InstallShield Executable
/// </summary>
/// <param name="outputDirectory">Output directory to write to</param>
/// <param name="includeDebug">True to include debug data, false otherwise</param>
/// <returns>True if extraction succeeded, false otherwise</returns>
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;
}
}
/// <summary>
/// Extract data from the overlay
/// </summary>
@@ -611,6 +539,22 @@ namespace SabreTools.Serialization.Wrappers
// Attempt to extract package
return MatroschkaPackage.Extract(outputDirectory, includeDebug);
}
/// <summary>
/// Extract data from an Installshield Executable
/// </summary>
/// <param name="outputDirectory">Output directory to write to</param>
/// <param name="includeDebug">True to include debug data, false otherwise</param>
/// <returns>True if extraction succeeded, false otherwise</returns>
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);
}
/// <summary>
/// Extract a Spoon Installer SFX overlay

View File

@@ -257,6 +257,56 @@ namespace SabreTools.Serialization.Wrappers
}
}
} = null;
/// <summary>
/// InstallShield Executable wrapper, if it exists
/// </summary>
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;
/// <inheritdoc cref="Executable.OptionalHeader"/>
public Data.Models.PortableExecutable.OptionalHeader OptionalHeader => Model.OptionalHeader;
@@ -1028,6 +1078,16 @@ namespace SabreTools.Serialization.Wrappers
/// </summary>
private readonly object _headerPaddingStringsLock = new();
/// <summary>
/// Lock object for <see cref="InstallShieldExecutable"/>
/// </summary>
private readonly object _installshieldExecutableLock = new();
/// <summary>
/// Cached attempt at creation for <see cref="InstallShieldExecutable"/>
/// </summary>
private bool _installshieldExecutableFailed = false;
/// <summary>
/// Lock object for <see cref="MatroschkaPackage"/>
/// </summary>