Skip to content

Commit a40123c

Browse files
JusterZhuclaude
andauthored
fix: code audit — BSDIFF overflow, Zip traversal, ProcessExit deadlock, Trace listener, Strategy lifecycle, HubConnection, RetryPolicy (#515)
* fix: critical code audit bugs — BSDIFF overflow, Zip traversal, ProcessExit deadlock, Trace listener leak, Strategy suicide, HubConnection lifecycle, RetryPolicy status code Comprehensive fix for ~25 bugs found in full code audit. BSDIFF/Differential: - Fix WriteInt64 long.MinValue overflow (BsdiffDiffer, StreamingHdiffDiffer) - Guard (int)control[0] truncation in Dirty method (BsdiffDiffer) - ReadFileWithBudget throws instead of silently truncating (StreamingHdiffDiffer) Zip security: - Add path-traversal guard in Decompress (ZipCompressionStrategy) - Fix StartsWith ambiguity — exact match instead of substring match Process lifecycle: - Fix ProcessExit deadlock (Task.Run wrapper in ClientStrategy.LaunchUpgradeProcessSync) - Move suicide from finally to success-only path (Windows/Linux/MacStrategy) - Track appLaunched flag so Bowl failure still triggers updater exit - GeneralTracer.Dispose removes only own listeners, not global Trace.Listeners Configuration & lifecycle: - Fix ProcessContract null-check ordering - Add disposed guard + null set to UpgradeHubService (HubConnection lifecycle) - ConfigurationMapper throws on null source instead of silent empty config - EventManager.Reset() for re-creatable singleton Network: - DefaultRetryPolicy uses HttpRequestException.StatusCode (.NET 5+) or regex for status matching - netstandard2.0 fallback uses \b(500|502|503|504)\b regex Co-Authored-By: Claude <noreply@anthropic.com> * fix: add EventManager.Reset() in test constructor to isolate singleton state across test classes --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2c92781 commit a40123c

22 files changed

Lines changed: 364 additions & 66 deletions

src/c#/GeneralUpdate.Core/Compress/ZipCompressionStrategy.cs

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,12 @@ public void Compress(string sourceDirectoryName
4747
var toDelArchives = new List<ZipArchiveEntry>();
4848
foreach (var zipArchiveEntry in archive.Entries)
4949
{
50+
// Exact match to avoid ambiguity (e.g. "foo.dll" should not match "foobar.dll").
51+
// For directory entries, also match "subdir/" prefix so nested files are replaced.
5052
if (toZipedFileName != null &&
51-
(zipArchiveEntry.FullName.StartsWith(toZipedFileName) || toZipedFileName.StartsWith(zipArchiveEntry.FullName)))
53+
(zipArchiveEntry.FullName == toZipedFileName ||
54+
zipArchiveEntry.FullName.StartsWith(toZipedFileName + "/", StringComparison.Ordinal) ||
55+
toZipedFileName.StartsWith(zipArchiveEntry.FullName, StringComparison.Ordinal)))
5256
{
5357
toDelArchives.Add(zipArchiveEntry);
5458
}
@@ -83,7 +87,9 @@ public void Compress(string sourceDirectoryName
8387
foreach (var zipArchiveEntry in archive.Entries)
8488
{
8589
if (toZipedFileName != null
86-
&& (zipArchiveEntry.FullName.StartsWith(toZipedFileName) || toZipedFileName.StartsWith(zipArchiveEntry.FullName)))
90+
&& (zipArchiveEntry.FullName == toZipedFileName ||
91+
zipArchiveEntry.FullName.StartsWith(toZipedFileName + "/", StringComparison.Ordinal) ||
92+
toZipedFileName.StartsWith(zipArchiveEntry.FullName, StringComparison.Ordinal)))
8793
{
8894
toDelArchives.Add(zipArchiveEntry);
8995
}
@@ -131,6 +137,8 @@ public void Decompress(string zipFilePath, string unZipDir, Encoding encoding)
131137
return;
132138
}
133139

140+
var extractionRoot = Path.GetFullPath(unZipDir);
141+
134142
using var zipToOpen = new FileStream(zipFilePath, FileMode.Open, FileAccess.ReadWrite, FileShare.Read);
135143
using var archive = new ZipArchive(zipToOpen, ZipArchiveMode.Read, false, encoding);
136144
for (int i = 0; i < archive.Entries.Count; i++)
@@ -144,20 +152,29 @@ public void Decompress(string zipFilePath, string unZipDir, Encoding encoding)
144152
continue;
145153
}
146154

147-
var filePath = directoryInfo + entryFilePath;
148-
var greatFolder = Directory.GetParent(filePath);
155+
// Guard against path-traversal entries (e.g. "../../evil.exe")
156+
var filePath = Path.Combine(unZipDir, entryFilePath);
157+
var fullTargetPath = Path.GetFullPath(filePath);
158+
var rootWithSep = extractionRoot.EndsWith(dirSeparatorChar)
159+
? extractionRoot
160+
: extractionRoot + dirSeparatorChar;
161+
if (!fullTargetPath.StartsWith(rootWithSep, StringComparison.OrdinalIgnoreCase))
162+
throw new InvalidDataException(
163+
$"Zip entry path traversal detected: {entries.FullName} resolves outside extraction directory.");
164+
165+
var greatFolder = Directory.GetParent(fullTargetPath);
149166
if (greatFolder is not null && !greatFolder.Exists)
150167
{
151168
greatFolder.Create();
152169
}
153170

154-
if (File.Exists(filePath))
171+
if (File.Exists(fullTargetPath))
155172
{
156-
File.SetAttributes(filePath, FileAttributes.Normal);
157-
File.Delete(filePath);
173+
File.SetAttributes(fullTargetPath, FileAttributes.Normal);
174+
File.Delete(fullTargetPath);
158175
}
159176

160-
entries.ExtractToFile(filePath);
177+
entries.ExtractToFile(fullTargetPath);
161178
}
162179
}
163180
catch (Exception exception)

src/c#/GeneralUpdate.Core/Configuration/ConfigurationMapper.cs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,14 +72,13 @@ public static class ConfigurationMapper
7272
/// </returns>
7373
public static UpdateContext MapToUpdateContext(UpdateRequest source, UpdateContext target = null)
7474
{
75+
if (source == null)
76+
throw new ArgumentNullException(nameof(source), "UpdateRequest source cannot be null — configuration would be empty.");
77+
7578
// 如果 source 和 target 均未提供,则创建新实例
7679
if (target == null)
7780
target = new UpdateContext();
7881

79-
// 如果 source 为 null,则直接返回空的 target
80-
if (source == null)
81-
return target;
82-
8382
// 映射基类公共字段
8483
target.UpdateAppName = source.UpdateAppName;
8584
target.MainAppName = source.MainAppName;

src/c#/GeneralUpdate.Core/Configuration/ProcessContract.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,8 +184,8 @@ public ProcessContract(string appName
184184
{
185185
// Validate required string parameters
186186
AppName = appName ?? throw new ArgumentNullException(nameof(appName));
187-
if (!Directory.Exists(installPath)) throw new ArgumentException($"{nameof(installPath)} path does not exist ! {installPath}.");
188187
InstallPath = installPath ?? throw new ArgumentNullException(nameof(installPath));
188+
if (!Directory.Exists(installPath)) throw new ArgumentException($"{nameof(installPath)} path does not exist ! {installPath}.");
189189
CurrentVersion = currentVersion ?? throw new ArgumentNullException(nameof(currentVersion));
190190
LastVersion = lastVersion ?? throw new ArgumentNullException(nameof(lastVersion));
191191
UpdateLogUrl = updateLogUrl;

src/c#/GeneralUpdate.Core/Differential/DefaultDirtyMatcher.cs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,6 @@ public class DefaultDirtyMatcher : IDirtyMatcher
3636
var findFile = patchFiles.FirstOrDefault(f =>
3737
{
3838
var name = Path.GetFileNameWithoutExtension(f.Name);
39-
if (name.EndsWith(PatchFormat, System.StringComparison.OrdinalIgnoreCase))
40-
name = name.Substring(0, name.Length - PatchFormat.Length);
4139
return name.Equals(oldFile.Name, System.StringComparison.OrdinalIgnoreCase);
4240
});
4341

src/c#/GeneralUpdate.Core/Download/Policy/DefaultRetryPolicy.cs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System;
22
using System.IO;
33
using System.Net.Http;
4+
using System.Text.RegularExpressions;
45
using System.Threading;
56
using System.Threading.Tasks;
67
using GeneralUpdate.Core.Download.Abstractions;
@@ -126,16 +127,25 @@ public async Task<T> ExecuteAsync<T>(Func<CancellationToken, Task<T>> action, Ca
126127
/// </remarks>
127128
private static bool IsRetryable(Exception ex)
128129
{
129-
if (ex is OperationCanceledException) return false;
130+
// TaskCanceledException derives from OperationCanceledException,
131+
// so check it first — timeouts should be retryable.
130132
if (ex is TaskCanceledException or TimeoutException) return true;
133+
if (ex is OperationCanceledException) return false;
131134
if (ex is IOException) return true;
132135
if (ex is HttpRequestException hre)
133136
{
137+
#if NET5_0_OR_GREATER
138+
var statusCode = hre.StatusCode;
139+
if (statusCode.HasValue)
140+
return statusCode.Value == System.Net.HttpStatusCode.InternalServerError
141+
|| statusCode.Value == System.Net.HttpStatusCode.BadGateway
142+
|| statusCode.Value == System.Net.HttpStatusCode.ServiceUnavailable
143+
|| statusCode.Value == System.Net.HttpStatusCode.GatewayTimeout;
144+
#endif
134145
var s = hre.Message ?? "";
135146
return s.Contains("timeout", StringComparison.OrdinalIgnoreCase)
136147
|| s.Contains("timed out", StringComparison.OrdinalIgnoreCase)
137-
|| s.Contains("500") || s.Contains("502")
138-
|| s.Contains("503") || s.Contains("504");
148+
|| Regex.IsMatch(s, @"\b(500|502|503|504)\b(?![\d./])");
139149
}
140150
return false;
141151
}

src/c#/GeneralUpdate.Core/Event/EventManager.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ namespace GeneralUpdate.Core.Event
3333
/// </remarks>
3434
public class EventManager : IDisposable
3535
{
36-
private static readonly Lazy<EventManager> _lazy = new(() => new EventManager());
36+
private static volatile Lazy<EventManager> _lazy = new(() => new EventManager());
3737
private ConcurrentDictionary<Type, Delegate> _dicDelegates = new();
3838
private bool _disposed;
3939

@@ -48,6 +48,15 @@ private EventManager() { }
4848
/// </remarks>
4949
public static EventManager Instance => _lazy.Value;
5050

51+
/// <summary>
52+
/// Creates a fresh singleton instance, discarding all previously registered listeners.
53+
/// Called when the update lifecycle ends and a clean slate is needed.
54+
/// </summary>
55+
public static void Reset()
56+
{
57+
_lazy = new Lazy<EventManager>(() => new EventManager());
58+
}
59+
5160
/// <summary>
5261
/// Registers a listener for a specified event type.
5362
/// </summary>

src/c#/GeneralUpdate.Core/FileSystem/StorageManager.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.Text.Json.Serialization.Metadata;
77
using System.Threading;
88
using GeneralUpdate.Core.HashAlgorithms;
9+
using GeneralUpdate.Core;
910

1011
namespace GeneralUpdate.Core.FileSystem
1112
{
@@ -329,8 +330,9 @@ public static List<FileInfo> GetAllFiles(string path, List<string> skipDirectory
329330

330331
return files;
331332
}
332-
catch
333+
catch (Exception ex)
333334
{
335+
GeneralTracer.Warn($"StorageManager.GetAllFiles failed for path '{path}': {ex.Message}");
334336
return new List<FileInfo>();
335337
}
336338
}
@@ -358,8 +360,9 @@ private static List<FileInfo> GetAllfiles(string path)
358360

359361
return files;
360362
}
361-
catch (Exception)
363+
catch (Exception ex)
362364
{
365+
GeneralTracer.Warn($"StorageManager.GetAllfiles failed for path '{path}': {ex.Message}");
363366
return new List<FileInfo>();
364367
}
365368
}

src/c#/GeneralUpdate.Core/Hubs/UpgradeHubService.cs

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ public class UpgradeHubService : IUpgradeHubService
1818
private const string Onlineflag = "Online";
1919
private const string ReceiveMessageflag = "ReceiveMessage";
2020
private HubConnection? _connection;
21+
private bool _disposed;
2122

2223
public UpgradeHubService(string url, string? token = null, string? appkey = null)
2324
=> _connection = BuildHubConnection(url, token, appkey);
@@ -47,24 +48,40 @@ public void AddListenerOnline(Action<string> onlineMessageCallback)
4748
=> _connection?.On(Onlineflag, onlineMessageCallback);
4849

4950
public void AddListenerReconnected(Func<string?, Task>? reconnectedCallback)
50-
=> _connection!.Reconnected += reconnectedCallback;
51+
{
52+
if (_disposed || _connection == null) return;
53+
_connection.Reconnected += reconnectedCallback;
54+
}
5155

5256
public void AddListenerClosed(Func<Exception?, Task> closeCallback)
53-
=> _connection!.Closed += closeCallback;
57+
{
58+
if (_disposed || _connection == null) return;
59+
_connection.Closed += closeCallback;
60+
}
5461

5562
public async Task StartAsync()
5663
{
64+
if (_disposed || _connection == null)
65+
{
66+
GeneralTracer.Warn("UpgradeHubService.StartAsync: service is disposed or not connected.");
67+
return;
68+
}
5769
GeneralTracer.Info($"UpgradeHubService.StartAsync: connecting to SignalR hub. State={_connection?.State}");
58-
await _connection!.StartAsync();
70+
await _connection.StartAsync();
5971
GeneralTracer.Info($"UpgradeHubService.StartAsync: SignalR hub connection established. State={_connection?.State}");
6072
}
6173

6274
public async Task StopAsync()
6375
{
76+
if (_disposed || _connection == null)
77+
{
78+
GeneralTracer.Warn("UpgradeHubService.StopAsync: service is disposed or not connected.");
79+
return;
80+
}
6481
try
6582
{
6683
GeneralTracer.Info($"UpgradeHubService.StopAsync: stopping SignalR hub connection. State={_connection?.State}");
67-
await _connection!.StopAsync();
84+
await _connection.StopAsync();
6885
GeneralTracer.Info("UpgradeHubService.StopAsync: SignalR hub connection stopped.");
6986
}
7087
catch (Exception e)
@@ -75,11 +92,17 @@ public async Task StopAsync()
7592

7693
public async Task DisposeAsync()
7794
{
95+
if (_disposed) return;
96+
_disposed = true;
7897
try
7998
{
80-
GeneralTracer.Info("UpgradeHubService.DisposeAsync: disposing SignalR hub connection and releasing resources.");
81-
await _connection!.DisposeAsync();
82-
GeneralTracer.Info("UpgradeHubService.DisposeAsync: SignalR hub connection disposed.");
99+
if (_connection != null)
100+
{
101+
GeneralTracer.Info("UpgradeHubService.DisposeAsync: disposing SignalR hub connection and releasing resources.");
102+
await _connection.DisposeAsync();
103+
_connection = null;
104+
GeneralTracer.Info("UpgradeHubService.DisposeAsync: SignalR hub connection disposed.");
105+
}
83106
}
84107
catch (Exception e)
85108
{

src/c#/GeneralUpdate.Core/Network/VersionService.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ namespace GeneralUpdate.Core.Network
6565
public class VersionService
6666
{
6767
private static readonly HttpClient _sharedClient;
68-
private static ISslValidationPolicy _globalSslPolicy = new StrictSslValidationPolicy();
68+
private static volatile ISslValidationPolicy _globalSslPolicy = new StrictSslValidationPolicy();
6969
private static IHttpAuthProvider? _globalAuthProvider;
7070

7171
private readonly IHttpAuthProvider _auth;

src/c#/GeneralUpdate.Core/Pipeline/DiffPipeline.cs

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Collections.Generic;
33
using System.IO;
44
using System.Linq;
5+
using System.Runtime.InteropServices;
56
using System.Threading;
67
using System.Threading.Tasks;
78
using GeneralUpdate.Core.Differential;
@@ -445,24 +446,42 @@ private async Task ApplyPatch(string appFilePath, string patchFilePath, Cancella
445446
/// deletion failures caused by read-only attributes.
446447
/// </para>
447448
/// </remarks>
448-
private static void HandleDeleteList(IEnumerable<FileInfo> patchFiles, IEnumerable<FileInfo> oldFiles)
449+
private static void HandleDeleteList(IEnumerable<FileInfo> patchFiles, List<FileInfo> oldFiles)
449450
{
450451
var json = patchFiles.FirstOrDefault(i => i.Name.Equals(DeleteListFileName, StringComparison.OrdinalIgnoreCase));
451452
if (json == null) return;
452453

453454
var deleteFiles = StorageManager.GetJson<List<FileNode>>(json.FullName, FileNodesJsonContext.Default.ListFileNode);
454455
if (deleteFiles == null) return;
455456

456-
var hashAlgorithm = new Sha256HashAlgorithm();
457-
var toDelete = oldFiles
458-
.Where(old => deleteFiles.Any(del => del.Hash.SequenceEqual(hashAlgorithm.ComputeHash(old.FullName))))
457+
// Build a HashSet of delete-manifest hashes (hex strings from FileNode.Hash)
458+
// so each oldFile is checked in O(1) instead of O(n × m).
459+
var deleteHashes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
460+
foreach (var del in deleteFiles)
461+
{
462+
if (!string.IsNullOrEmpty(del.Hash))
463+
deleteHashes.Add(del.Hash);
464+
}
465+
if (deleteHashes.Count == 0) return;
466+
467+
// Exclude .patch files from oldFiles — they are patch input, not app files.
468+
var appFiles = oldFiles
469+
.Where(f => !Path.GetExtension(f.FullName)
470+
.Equals(PatchExtension, StringComparison.OrdinalIgnoreCase))
459471
.ToList();
472+
if (appFiles.Count == 0) return;
460473

461-
foreach (var file in toDelete)
474+
var hashAlgorithm = new Sha256HashAlgorithm();
475+
foreach (var file in appFiles)
462476
{
463477
if (!File.Exists(file.FullName)) continue;
464-
File.SetAttributes(file.FullName, FileAttributes.Normal);
465-
File.Delete(file.FullName);
478+
479+
var fileHash = hashAlgorithm.ComputeHash(file.FullName);
480+
if (fileHash != null && deleteHashes.Contains(fileHash))
481+
{
482+
File.SetAttributes(file.FullName, FileAttributes.Normal);
483+
File.Delete(file.FullName);
484+
}
466485
}
467486
}
468487

@@ -501,15 +520,24 @@ private static Task CopyUnknownFiles(string appPath, string patchPath)
501520
var extensionName = Path.GetExtension(file.FullName);
502521
if (BlackDefaults.DefaultFormats.Contains(extensionName)) continue;
503522

504-
var targetFileName = file.FullName.Replace(patchPath, "").TrimStart(Path.DirectorySeparatorChar);
505-
var targetPath = Path.Combine(appPath, targetFileName);
523+
// Compute the relative path by stripping the patch directory prefix.
524+
// Using StartsWith + Substring instead of string.Replace to avoid
525+
// incorrect replacements when appPath is a substring of patchPath.
526+
var patchPrefix = patchPath.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
527+
var comparison = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
528+
? StringComparison.OrdinalIgnoreCase
529+
: StringComparison.Ordinal;
530+
var relativePart = file.FullName.StartsWith(patchPrefix, comparison)
531+
? file.FullName.Substring(patchPrefix.Length)
532+
: file.FullName;
533+
var targetPath = Path.Combine(appPath, relativePart);
506534
var parentFolder = Directory.GetParent(targetPath);
507535
if (parentFolder?.Exists == false)
508536
parentFolder.Create();
509537

510538
// Atomic replace via temp file, same strategy as ApplyPatch.
511539
// Avoids file-in-use errors when the process just exited.
512-
var safeName = targetFileName.Replace(Path.DirectorySeparatorChar, '_');
540+
var safeName = relativePart.Replace(Path.DirectorySeparatorChar, '_');
513541
var tempPath = Path.Combine(appPath, $"{Path.GetRandomFileName()}_{safeName}");
514542
File.Copy(file.FullName, tempPath, true);
515543

0 commit comments

Comments
 (0)