diff --git a/AsarSharp/AsarCreator.cs b/AsarSharp/AsarCreator.cs index 9b1276f..804f69f 100644 --- a/AsarSharp/AsarCreator.cs +++ b/AsarSharp/AsarCreator.cs @@ -73,22 +73,26 @@ namespace AsarSharp filesystem.InsertFile(filename, shouldUnpack, file, placeholder); break; case FileType.Link: - throw new NotImplementedException(); + throw new NotSupportedException($"Packing symlinks is not supported: '{filename}'"); } } - private bool ShouldUnpackPath(string relativePath) + /// + /// Matches the directory path (relative to the archive root) against the unpack regex. + /// + private bool ShouldUnpackPath(string relativeParentPath) { - return _options?.Unpack?.IsMatch(relativePath) == true; + return _options?.Unpack?.IsMatch(relativeParentPath) == true; } private void InsertsDone(Filesystem filesystem, List files) { - Directory.CreateDirectory( - Path.GetDirectoryName(_destPath) - ?? throw new InvalidOperationException()); + string dir = Path.GetDirectoryName(_destPath); + if (!string.IsNullOrEmpty(dir)) + Directory.CreateDirectory(dir); + Disk.WriteFileSystem(_destPath, filesystem, - new Disk.FilesystemFilesAndLinks { Files = files, Links = null }, _metadata); + new Disk.FilesystemFilesAndLinks { Files = files }, _metadata); } } } diff --git a/AsarSharp/AsarExtractor.cs b/AsarSharp/AsarExtractor.cs index 96aca5d..29ba9d9 100644 --- a/AsarSharp/AsarExtractor.cs +++ b/AsarSharp/AsarExtractor.cs @@ -159,13 +159,6 @@ namespace AsarSharp FilesystemEntry file, HashSet dirCache) { var linkSrcPath = Extensions.GetDirectoryName(Path.Combine(dest, file.Link)); - var linkDestPath = Extensions.GetDirectoryName(destFilename); - var relativeLinkPath = Extensions.GetRelativePath(linkDestPath, linkSrcPath); - - try { File.Delete(destFilename); } - catch { /* ignore — failing to remove an existing link is non-fatal */ } - - var linkTo = Path.Combine(relativeLinkPath, Path.GetFileName(file.Link)); if (!Extensions.IsPathInside(dest, linkSrcPath)) { @@ -173,6 +166,12 @@ namespace AsarSharp $"{fullPath}: file \"{file.Link}\" links out of the package to \"{linkSrcPath}\""); } + try { File.Delete(destFilename); } + catch (Exception e) when (e is IOException || e is UnauthorizedAccessException) + { + // Nothing to replace, or the old entry is locked; the copy below reports the real failure. + } + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { var targetPath = Path.Combine(linkSrcPath, Path.GetFileName(file.Link)); @@ -189,8 +188,10 @@ namespace AsarSharp } else { + var linkDestPath = Extensions.GetDirectoryName(destFilename); + var relativeLinkPath = Extensions.GetRelativePath(linkDestPath, linkSrcPath); EnsureParentDir(destFilename, dirCache); - Extensions.CreateSymbolicLink(linkTo, destFilename); + Extensions.CreateSymbolicLink(Path.Combine(relativeLinkPath, Path.GetFileName(file.Link)), destFilename); } } } diff --git a/AsarSharp/AsarFileSystem/Disk.cs b/AsarSharp/AsarFileSystem/Disk.cs index d83e11a..509fc5e 100644 --- a/AsarSharp/AsarFileSystem/Disk.cs +++ b/AsarSharp/AsarFileSystem/Disk.cs @@ -12,8 +12,6 @@ namespace AsarSharp.AsarFileSystem public static class Disk { private const int StreamBufferSize = 1024 * 1024; - private static readonly ConcurrentDictionary _filesystemCache = - new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); public class ArchiveHeader { @@ -25,7 +23,6 @@ namespace AsarSharp.AsarFileSystem public class FilesystemFilesAndLinks { public List Files { get; set; } = new List(); - public List Links { get; set; } = new List(); } public class BasicFileInfo @@ -42,14 +39,14 @@ namespace AsarSharp.AsarFileSystem 65536, FileOptions.SequentialScan)) { byte[] sizeBuf = new byte[8]; - if (fs.Read(sizeBuf, 0, 8) != 8) + if (fs.ReadFull(sizeBuf, 0, 8) != 8) throw new Exception("Unable to read header size"); var sizePickle = Pickle.CreateFromBuffer(sizeBuf); var size = sizePickle.CreateIterator().ReadUInt32(); var headerBuf = new byte[size]; - if (fs.Read(headerBuf, 0, (int)size) != size) + if (fs.ReadFull(headerBuf, 0, (int)size) != size) throw new Exception("Unable to read header"); var headerPickle = Pickle.CreateFromBuffer(headerBuf); @@ -65,62 +62,28 @@ namespace AsarSharp.AsarFileSystem } } + /// + /// Reads the header fresh every time: an archive is repacked in place during a patch run, + /// so a cached header would hand out stale offsets on the next read of the same path. + /// public static Filesystem ReadFilesystemSync(string archivePath) { - return _filesystemCache.GetOrAdd(archivePath, key => - { - var header = ReadArchiveHeaderSync(key); - var filesystem = new Filesystem(key); - filesystem.SetHeader(header.Header, header.HeaderSize); - return filesystem; - }); - } - - public static byte[] ReadFileSync(Filesystem filesystem, string filename, FilesystemEntry info) - { - if (!info.IsFile || !info.Size.HasValue) - throw new ArgumentException("Entry is not a file", nameof(info)); - - long size = info.Size.Value; - byte[] buffer = new byte[size]; - - if (size <= 0) return buffer; - - if (info.Unpacked == true) - { - string filePath = Path.Combine($"{filesystem.GetRootPath()}.unpacked", filename); - return File.ReadAllBytes(filePath); - } - - using (var fs = new FileStream(filesystem.GetRootPath(), FileMode.Open, FileAccess.Read, - FileShare.Read, 65536, FileOptions.RandomAccess)) - { - long offset = 8 + filesystem.GetHeaderSize() + long.Parse(info.Offset); - fs.Position = offset; - int bytesRead = fs.Read(buffer, 0, (int)size); - if (bytesRead != size) - throw new Exception($"Failed to read entire file, got {bytesRead} bytes instead of {size}"); - } - - return buffer; + var header = ReadArchiveHeaderSync(archivePath); + var filesystem = new Filesystem(archivePath); + filesystem.SetHeader(header.Header, header.HeaderSize); + return filesystem; } #endregion - public static bool UncacheFilesystem(string archivePath) - { - return _filesystemCache.TryRemove(archivePath, out _); - } - - public static void UncacheAll() - { - _filesystemCache.Clear(); - } - public static void CopyFile(string dest, string rootPath, string filename) { - if (dest == null || rootPath == null || filename == null) - throw new ArgumentNullException(); + if (dest == null) + throw new ArgumentNullException(nameof(dest)); + if (rootPath == null) + throw new ArgumentNullException(nameof(rootPath)); + if (filename == null) + throw new ArgumentNullException(nameof(filename)); string normalizedDestRoot = Path.GetFullPath(dest) .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); @@ -192,6 +155,18 @@ namespace AsarSharp.AsarFileSystem var patchedSizePickle = Pickle.CreateEmpty(); patchedSizePickle.WriteUInt32((uint)patchedPickle.GetTotalSize()); + // The rewrite lands on top of the placeholder header, so it must be exactly as + // long. Placeholder hashes are the same width as real ones, so this holds unless + // a file changed size between crawl and write - which would silently shred the + // payload that follows. + if (patchedPickle.GetTotalSize() != headerPickle.GetTotalSize() || + patchedSizePickle.GetTotalSize() != sizePickleSize) + { + throw new InvalidOperationException( + "ASAR header changed size while packing (a source file was modified mid-build). " + + "Aborting rather than writing a corrupt archive."); + } + fs.Position = 0; patchedSizePickle.WriteTo(fs); patchedPickle.WriteTo(fs); diff --git a/AsarSharp/AsarFileSystem/FileSystem.cs b/AsarSharp/AsarFileSystem/FileSystem.cs index e577c4e..0410245 100644 --- a/AsarSharp/AsarFileSystem/FileSystem.cs +++ b/AsarSharp/AsarFileSystem/FileSystem.cs @@ -33,7 +33,7 @@ namespace AsarSharp.AsarFileSystem _headerSize = headerSize; } - public FilesystemEntry SearchNodeFromDirectory(string p) + public FilesystemEntry SearchNodeFromDirectory(string p, bool create = true) { FilesystemEntry json = _header; @@ -59,12 +59,31 @@ namespace AsarSharp.AsarFileSystem string seg = p.Substring(start, segLen); if (!json.IsDirectory) - throw new Exception($"Unexpected directory state while traversing: {p}"); + { + if (create) + throw new Exception($"Unexpected directory state while traversing: {p}"); + return null; + } + + if (json.Files == null) + { + if (create) + json.Files = new Dictionary(StringComparer.Ordinal); + else + return null; + } if (!json.Files.TryGetValue(seg, out var child)) { - child = new FilesystemEntry { Files = new Dictionary(StringComparer.Ordinal) }; - json.Files[seg] = child; + if (create) + { + child = new FilesystemEntry { Files = new Dictionary(StringComparer.Ordinal) }; + json.Files[seg] = child; + } + else + { + return null; + } } json = child; start = end + 1; @@ -81,7 +100,7 @@ namespace AsarSharp.AsarFileSystem string name = Path.GetFileName(rel); string dir = Extensions.GetDirectoryName(rel); - var parent = SearchNodeFromDirectory(dir); + var parent = SearchNodeFromDirectory(dir, true); if (parent.Files == null) parent.Files = new Dictionary(StringComparer.Ordinal); @@ -111,18 +130,23 @@ namespace AsarSharp.AsarFileSystem } } - public FilesystemEntry GetNode(string p, bool followLinks = true) + public FilesystemEntry GetNode(string p, bool followLinks = true, int linkDepth = 0) { + if (linkDepth > 40) + throw new Exception($"Symlink loop detected at {p}"); + p = p.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar); - FilesystemEntry node = SearchNodeFromDirectory(Extensions.GetDirectoryName(p)); + FilesystemEntry node = SearchNodeFromDirectory(Extensions.GetDirectoryName(p), false); + if (node == null) + return null; string name = Path.GetFileName(p); if (node.IsLink && followLinks) - return GetNode(Path.Combine(node.Link, name)); + return GetNode(Path.Combine(node.Link, name), followLinks, linkDepth + 1); if (!string.IsNullOrEmpty(name)) { - if (node.IsDirectory && node.Files.TryGetValue(name, out var entry)) + if (node.IsDirectory && node.Files != null && node.Files.TryGetValue(name, out var entry)) return entry; return null; } @@ -130,16 +154,17 @@ namespace AsarSharp.AsarFileSystem return node; } - public FilesystemEntry GetFile(string p, bool followLinks = true) + public FilesystemEntry GetFile(string p, bool followLinks = true, int linkDepth = 0) { - FilesystemEntry info = GetNode(p, followLinks); + if (linkDepth > 40) + throw new Exception($"Symlink loop detected at {p}"); + + FilesystemEntry info = GetNode(p, followLinks, linkDepth); if (info == null) throw new Exception($"\"{p}\" was not found in this archive"); - if (info.IsLink && followLinks) return GetFile(info.Link, followLinks); + if (info.IsLink && followLinks) return GetFile(info.Link, followLinks, linkDepth + 1); return info; } - public static string ReadLink(string path) => throw new NotImplementedException(); - #region Writing public FilesystemEntry SearchNodeFromPath(string p) @@ -159,7 +184,7 @@ namespace AsarSharp.AsarFileSystem public void InsertFile(string path, bool shouldUnpack, CrawledFileType file, IntegrityHelper.FileIntegrity precomputedIntegrity = null) { - var (dirNode, _) = SearchNodeFromPathWithParent(Path.GetDirectoryName(path) ?? path); + var (dirNode, _) = SearchNodeFromPathWithParent(path); var node = SearchNodeFromPath(path); long size; diff --git a/AsarSharp/AsarFileSystem/FileSystemCrawler.cs b/AsarSharp/AsarFileSystem/FileSystemCrawler.cs index a4ee35e..6bf5ea9 100644 --- a/AsarSharp/AsarFileSystem/FileSystemCrawler.cs +++ b/AsarSharp/AsarFileSystem/FileSystemCrawler.cs @@ -9,13 +9,6 @@ namespace AsarSharp.AsarFileSystem { public FileType Type { get; set; } public FileSystemInfo Stat { get; set; } - public TransformedFile Transformed { get; set; } - } - - public class TransformedFile - { - public string Path { get; set; } - public FileSystemInfo Stat { get; set; } } public enum FileType @@ -36,7 +29,7 @@ namespace AsarSharp.AsarFileSystem } catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) { - return null; + throw new IOException($"Failed to read attributes for '{filename}'", ex); } bool isDirectory = (attributes & FileAttributes.Directory) == FileAttributes.Directory; @@ -59,7 +52,6 @@ namespace AsarSharp.AsarFileSystem foreach (var fullPath in CrawlIterative(dir)) { var type = DetermineFileType(fullPath); - if (type == null) continue; metadata[fullPath] = type; if (type.Type == FileType.Link) links.Add(fullPath); filenames.Add(fullPath); @@ -77,7 +69,8 @@ namespace AsarSharp.AsarFileSystem { if (string.Equals(filename, link, StringComparison.OrdinalIgnoreCase)) continue; - if (filename.StartsWith(link, StringComparison.OrdinalIgnoreCase)) + // Require a separator after the prefix so "…/foobar" does not match link "…/foo". + if (filename.StartsWith(link + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) { string rel = Extensions.GetRelativePath(link, fileDir); if (!rel.StartsWith("..", StringComparison.Ordinal)) @@ -120,7 +113,7 @@ namespace AsarSharp.AsarFileSystem foreach (var entry in entries) { result.Add(entry.FullName); - if (entry is DirectoryInfo subDir) + if (entry is DirectoryInfo subDir && (subDir.Attributes & FileAttributes.ReparsePoint) == 0) stack.Push(subDir); } } diff --git a/AsarSharp/Integrity/IntegrityHelper.cs b/AsarSharp/Integrity/IntegrityHelper.cs index f6c5302..fb76582 100644 --- a/AsarSharp/Integrity/IntegrityHelper.cs +++ b/AsarSharp/Integrity/IntegrityHelper.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Security.Cryptography; +using AsarSharp.Utils; using Newtonsoft.Json; namespace AsarSharp.Integrity @@ -60,7 +61,9 @@ namespace AsarSharp.Integrity var blockHashes = new List(estimatedBlockCount); int bytesRead; - while ((bytesRead = fileStream.Read(reusableBuffer, 0, reusableBuffer.Length)) > 0) + // ReadFull, not Read: a short read would hash a partial block and produce + // integrity blocks Electron rejects. + while ((bytesRead = fileStream.ReadFull(reusableBuffer, 0, reusableBuffer.Length)) > 0) { blockHashes.Add(ToLowerHex(blockHash.ComputeHash(reusableBuffer, 0, bytesRead))); fileHash.AppendData(reusableBuffer, 0, bytesRead); diff --git a/AsarSharp/PickleTools/Pickle.cs b/AsarSharp/PickleTools/Pickle.cs index c192a8f..b02e72a 100644 --- a/AsarSharp/PickleTools/Pickle.cs +++ b/AsarSharp/PickleTools/Pickle.cs @@ -28,16 +28,18 @@ namespace AsarSharp.PickleTools { if (buffer != null) { + if (buffer.Length < SIZE_UINT32) + throw new ArgumentException("Buffer is too small.", nameof(buffer)); + _header = buffer; - _headerSize = buffer.Length - GetPayloadSize(); + int payloadSize = GetPayloadSize(); + if (payloadSize > buffer.Length) + throw new ArgumentException("Payload size exceeds buffer length.", nameof(buffer)); + + _headerSize = buffer.Length - payloadSize; _capacityAfterHeader = CAPACITY_READ_ONLY; _writeOffset = 0; - if (_headerSize > buffer.Length) - { - _headerSize = 0; - } - if (_headerSize != AlignInt(_headerSize, SIZE_UINT32)) { _headerSize = 0; @@ -86,7 +88,7 @@ namespace AsarSharp.PickleTools } - public bool WriteBool(bool value) => WriteInt(value ? 1 : 0); + public bool WriteInt(int value) { @@ -121,74 +123,7 @@ namespace AsarSharp.PickleTools return true; } - public bool WriteInt64(long value) - { - const int dataLength = SIZE_INT64; - int newSize = _writeOffset + dataLength; - if (newSize > _capacityAfterHeader) - { - Resize(Math.Max((int)_capacityAfterHeader * 2, newSize)); - } - - WriteInt64LE(value, _headerSize + _writeOffset); - SetPayloadSize(newSize); - _writeOffset = newSize; - return true; - } - - - public bool WriteUInt64(ulong value) - { - const int dataLength = SIZE_UINT64; - int newSize = _writeOffset + dataLength; - - if (newSize > _capacityAfterHeader) - { - Resize(Math.Max((int)_capacityAfterHeader * 2, newSize)); - } - - WriteUInt64LE(value, _headerSize + _writeOffset); - SetPayloadSize(newSize); - _writeOffset = newSize; - return true; - } - - public bool WriteFloat(float value) - { - const int dataLength = SIZE_FLOAT; - int newSize = _writeOffset + dataLength; - - if (newSize > _capacityAfterHeader) - { - Resize(Math.Max((int)_capacityAfterHeader * 2, newSize)); - } - - int bits = BitConverter.ToInt32(BitConverter.GetBytes(value), 0); - WriteInt32LE(bits, _headerSize + _writeOffset); - - SetPayloadSize(newSize); - _writeOffset = newSize; - return true; - } - - public bool WriteDouble(double value) - { - const int dataLength = SIZE_DOUBLE; - int newSize = _writeOffset + dataLength; - - if (newSize > _capacityAfterHeader) - { - Resize(Math.Max((int)_capacityAfterHeader * 2, newSize)); - } - - long bits = BitConverter.DoubleToInt64Bits(value); - WriteInt64LE(bits, _headerSize + _writeOffset); - - SetPayloadSize(newSize); - _writeOffset = newSize; - return true; - } public bool WriteString(string value) { @@ -226,7 +161,13 @@ namespace AsarSharp.PickleTools WriteUInt32LE((uint)payloadSize, 0); } - public int GetPayloadSize() => (int)ReadUInt32LE(0); + public int GetPayloadSize() + { + uint size = ReadUInt32LE(0); + if (size > int.MaxValue) + throw new InvalidOperationException("Payload size exceeds maximum allowed (2GB)."); + return (int)size; + } private void Resize(int newCapacity) { @@ -275,29 +216,7 @@ namespace AsarSharp.PickleTools _header[offset + 3] = (byte)(value >> 24); } - private void WriteInt64LE(long value, int offset) - { - _header[offset] = (byte)value; - _header[offset + 1] = (byte)(value >> 8); - _header[offset + 2] = (byte)(value >> 16); - _header[offset + 3] = (byte)(value >> 24); - _header[offset + 4] = (byte)(value >> 32); - _header[offset + 5] = (byte)(value >> 40); - _header[offset + 6] = (byte)(value >> 48); - _header[offset + 7] = (byte)(value >> 56); - } - private void WriteUInt64LE(ulong value, int offset) - { - _header[offset] = (byte)value; - _header[offset + 1] = (byte)(value >> 8); - _header[offset + 2] = (byte)(value >> 16); - _header[offset + 3] = (byte)(value >> 24); - _header[offset + 4] = (byte)(value >> 32); - _header[offset + 5] = (byte)(value >> 40); - _header[offset + 6] = (byte)(value >> 48); - _header[offset + 7] = (byte)(value >> 56); - } #endregion diff --git a/AsarSharp/PickleTools/PickleIterator.cs b/AsarSharp/PickleTools/PickleIterator.cs index 11d069a..95647e7 100644 --- a/AsarSharp/PickleTools/PickleIterator.cs +++ b/AsarSharp/PickleTools/PickleIterator.cs @@ -18,10 +18,7 @@ namespace AsarSharp.PickleTools _endIndex = pickle.GetPayloadSize(); } - public bool ReadBool() - { - return ReadInt() != 0; - } + public int ReadInt() { @@ -33,25 +30,7 @@ namespace AsarSharp.PickleTools return ReadBytes(Pickle.SIZE_UINT32, BitConverter.ToUInt32); } - public long ReadInt64() - { - return ReadBytes(Pickle.SIZE_INT64, BitConverter.ToInt64); - } - public ulong ReadUInt64() - { - return ReadBytes(Pickle.SIZE_UINT64, BitConverter.ToUInt64); - } - - public float ReadFloat() - { - return ReadBytes(Pickle.SIZE_FLOAT, BitConverter.ToSingle); - } - - public double ReadDouble() - { - return ReadBytes(Pickle.SIZE_DOUBLE, BitConverter.ToDouble); - } public string ReadString() { @@ -75,7 +54,7 @@ namespace AsarSharp.PickleTools private int GetReadPayloadOffsetAndAdvance(int length) { - if (length > _endIndex - _readIndex) + if (length < 0 || length > _endIndex - _readIndex) { _readIndex = _endIndex; throw new InvalidOperationException($"Failed to read data with length of {length}"); diff --git a/AsarSharp/Utils/Extensions.cs b/AsarSharp/Utils/Extensions.cs index 3abaf5f..34dad7f 100644 --- a/AsarSharp/Utils/Extensions.cs +++ b/AsarSharp/Utils/Extensions.cs @@ -5,8 +5,30 @@ using System.Text; namespace AsarSharp.Utils { - internal static class Extensions + public static class Extensions { + /// + /// Fills bytes. Stream.Read may legally return fewer than + /// asked for; treating a short read as EOF corrupts header parsing and block hashes. + /// Returns the bytes actually read, which is less than count only at end of stream. + /// + public static int ReadFull(this Stream stream, byte[] buffer, int offset, int count) + { + int total = 0; + while (total < count) + { + int read = stream.Read(buffer, offset + total, count - total); + if (read <= 0) + { + break; + } + + total += read; + } + + return total; + } + /// /// Compute path relative to . /// Fast common-case (path is inside relativeTo): plain prefix-strip. @@ -170,19 +192,7 @@ namespace AsarSharp.Utils if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return; - var process = new System.Diagnostics.Process - { - StartInfo = new System.Diagnostics.ProcessStartInfo - { - FileName = "chmod", - Arguments = $"{permission} \"{filePath}\"", - UseShellExecute = false, - RedirectStandardOutput = true, - CreateNoWindow = true - } - }; - process.Start(); - process.WaitForExit(); + RunTool("chmod", $"{permission} \"{filePath}\""); } @@ -190,32 +200,41 @@ namespace AsarSharp.Utils { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - NativeMethods.CreateSymbolicLink(linkPath, linkTarget, + bool success = NativeMethods.CreateSymbolicLink(linkPath, linkTarget, Directory.Exists(linkTarget) ? NativeMethods.SymLinkFlag.Directory : NativeMethods.SymLinkFlag.File); + if (!success) + throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error()); return; } - var process = new System.Diagnostics.Process - { - StartInfo = new System.Diagnostics.ProcessStartInfo - { - FileName = "ln", - Arguments = $"-s \"{linkTarget}\" \"{linkPath}\"", - UseShellExecute = false, - RedirectStandardOutput = true, - CreateNoWindow = true - } - }; - process.Start(); - process.WaitForExit(); + RunTool("ln", $"-s \"{linkTarget}\" \"{linkPath}\""); } - public static bool IsWindowsPlatform() { - return Environment.OSVersion.Platform == PlatformID.Win32NT; + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + } + + private static void RunTool(string fileName, string arguments) + { + using (var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + UseShellExecute = false, + CreateNoWindow = true + } + }) + { + process.Start(); + process.WaitForExit(); + if (process.ExitCode != 0) + throw new InvalidOperationException($"Tool {fileName} failed with exit code {process.ExitCode}."); + } } } }