Minor cleanup and additions

This commit is contained in:
Matt Nadareski
2025-10-23 16:05:12 -04:00
parent d5ab37a5a6
commit 5df1af9c17
5 changed files with 168 additions and 58 deletions

View File

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

View File

@@ -1,20 +1,23 @@
namespace SabreTools.Data.Models.InstallShieldExecutable
{
public class ExtractableFile
public class FileEntry
{
/// <summary>
/// Name of the file, only ASCII characters(?)
/// Name of the file
/// </summary>
/// <remarks>May only contain ASCII (7-bit) characters</remarks>
public string? Name { get; set; }
/// <summary>
/// Path of the file, only ASCII characters(?), seems to usually use \ filepaths
/// Path of the file, seems to usually use \ filepaths
/// </summary>
public string? Path { get; set; }
/// <remarks>May only contain ASCII (7-bit) characters</remarks>
public string? Path { get; set; }
/// <summary>
/// Version of the file
/// </summary>
/// <remarks>May only contain ASCII (7-bit) characters</remarks>
public string? Version { get; set; }
/// <summary>
@@ -22,4 +25,4 @@ namespace SabreTools.Data.Models.InstallShieldExecutable
/// </summary>
public ulong Length { get; set; }
}
}
}

View File

@@ -0,0 +1,42 @@
namespace SabreTools.Data.Models.InstallShieldExecutable
{
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </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.
///
/// It will need to be made very apparent that the dictionary
/// does not directly represent the structure.
public class SFX
{
/// <summary>
/// Set of file entries
/// </summary>
public FileEntry[]? Entries { get; set; }
}
}

View File

@@ -4,9 +4,10 @@ using SabreTools.IO.Extensions;
namespace SabreTools.Serialization.Readers
{
public class InstallShieldExecutableFile : BaseBinaryReader<ExtractableFile>
// TODO: This should parse an entire SFX, not just a single entry
public class InstallShieldExecutableFile : BaseBinaryReader<FileEntry>
{
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;
}
}
}
/// <summary>
/// Parse a Stream into an InstallShield Executable file header
/// Parse a Stream into a FileEntry
/// </summary>
/// <param name="data">Stream to parse</param>
/// <returns>Filled InstallShield Executable file header on success, null on error</returns>
public static ExtractableFile? ParseExtractableFileHeader(Stream? data)
/// <returns>Filled FileEntry on success, null on error</returns>
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;
}
}
}
}

View File

@@ -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;
}
/// <summary>
@@ -160,7 +160,7 @@ namespace SabreTools.Serialization.Wrappers
return false;
}
}
/// <summary>
/// Extract data from an InstallShield Executable
/// </summary>
@@ -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)