From 27c930e598144b9f06c804e42c449163d118b59d Mon Sep 17 00:00:00 2001 From: ModerRAS Date: Tue, 12 May 2026 17:58:05 +0800 Subject: [PATCH 1/2] Improve self-update package downloads --- .../Update/SelfUpdateBootstrapTests.cs | 184 ++++++++++ .../Update/SelfUpdateBootstrap.Windows.cs | 337 ++++++++++++++++-- 2 files changed, 492 insertions(+), 29 deletions(-) diff --git a/TelegramSearchBot.Test/Service/Update/SelfUpdateBootstrapTests.cs b/TelegramSearchBot.Test/Service/Update/SelfUpdateBootstrapTests.cs index 9ff68ba9..83ea0d69 100644 --- a/TelegramSearchBot.Test/Service/Update/SelfUpdateBootstrapTests.cs +++ b/TelegramSearchBot.Test/Service/Update/SelfUpdateBootstrapTests.cs @@ -1,11 +1,20 @@ #if WINDOWS using System; +using System.Collections.Concurrent; using System.Diagnostics; +using System.Formats.Tar; using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; using System.Reflection; +using System.Text; +using System.Text.Json; +using System.Threading; using Xunit; using TelegramSearchBot.Service.AppUpdate; using TelegramSearchBot.Common.Model.Update; +using ZstdSharp; namespace TelegramSearchBot.Test.Service.Update; @@ -341,6 +350,105 @@ private static object CreateUpdateCheckResult(string factoryMethod, params objec return method.Invoke(null, args)!; } + private static MemoryStream CreateTestPackage(Dictionary files) + { + var manifest = new UpdateManifest + { + TargetVersion = "2.0.0", + MinSourceVersion = "1.0.0", + MaxSourceVersion = "1.0.0", + IsAnchor = false, + IsCumulative = false, + ChainDepth = 1, + Files = files.Keys + .Select(path => new UpdateFile + { + RelativePath = path, + NewChecksum = "test" + }) + .ToList(), + Checksum = string.Empty, + CreatedAt = DateTime.UtcNow + }; + + using var tarStream = new MemoryStream(); + using (var tarWriter = new TarWriter(tarStream, leaveOpen: true)) + { + WriteTarEntry(tarWriter, "manifest.json", JsonSerializer.SerializeToUtf8Bytes(manifest)); + foreach (var file in files) + { + WriteTarEntry(tarWriter, file.Key, Encoding.UTF8.GetBytes(file.Value)); + } + } + + tarStream.Position = 0; + using var compressedStream = new MemoryStream(); + using (var compressionStream = new CompressionStream(compressedStream, leaveOpen: true)) + { + tarStream.CopyTo(compressionStream); + } + + compressedStream.Position = 0; + var packageStream = new MemoryStream(); + packageStream.Write([0x4D, 0x55, 0x50, 0x00]); + compressedStream.CopyTo(packageStream); + packageStream.Position = 0; + return packageStream; + } + + private static void WriteTarEntry(TarWriter tarWriter, string relativePath, byte[] content) + { + var entry = new PaxTarEntry(TarEntryType.RegularFile, relativePath.Replace('\\', '/')) + { + DataStream = new MemoryStream(content) + }; + tarWriter.WriteEntry(entry); + } + + private sealed class RangeHttpMessageHandler : HttpMessageHandler + { + private readonly byte[] _content; + + public ConcurrentBag<(long Start, long End)> RangeRequests { get; } = new(); + + public RangeHttpMessageHandler(byte[] content) + { + _content = content; + } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var range = request.Headers.Range?.Ranges.SingleOrDefault(); + if (range is null) + { + var fullResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(_content) + }; + fullResponse.Content.Headers.ContentLength = _content.LongLength; + return Task.FromResult(fullResponse); + } + + var start = range.From ?? 0; + var end = range.To ?? _content.LongLength - 1; + RangeRequests.Add((start, end)); + + var length = checked((int)(end - start + 1)); + var bytes = new byte[length]; + Buffer.BlockCopy(_content, checked((int)start), bytes, 0, length); + + var response = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent(bytes) + }; + response.Content.Headers.ContentRange = new ContentRangeHeaderValue(start, end, _content.LongLength); + response.Content.Headers.ContentLength = bytes.LongLength; + return Task.FromResult(response); + } + } + // ── PlanUpdatePath ───────────────────────────────────────────── [Fact] @@ -562,6 +670,82 @@ public void ValidatePackageMagic_TooShortStream_ReturnsFalse() Assert.False(result); } + // ── Package extraction ───────────────────────────────────────── + + [Fact] + public void DecompressPackage_ReturnsBeforeConsumingWholePackage() + { + using var package = CreateTestPackage(new Dictionary + { + ["nested/file.txt"] = "hello" + }); + package.Position = 4; + + using var decompressed = InvokePrivateStatic("DecompressPackage", package)!; + + Assert.True(package.Position < package.Length); + } + + [Fact] + public void ExtractPackageToDirectory_ExtractsZstdTarPackage() + { + using var package = CreateTestPackage(new Dictionary + { + ["nested/file.txt"] = "hello" + }); + var targetDirectory = Path.Combine(Path.GetTempPath(), "SelfUpdateBootstrapTests", Guid.NewGuid().ToString("N")); + + try + { + InvokePrivateStatic("ExtractPackageToDirectory", package, targetDirectory); + + var extractedPath = Path.Combine(targetDirectory, "nested", "file.txt"); + Assert.True(File.Exists(extractedPath)); + Assert.Equal("hello", File.ReadAllText(extractedPath)); + } + finally + { + if (Directory.Exists(targetDirectory)) + { + Directory.Delete(targetDirectory, recursive: true); + } + } + } + + [Fact] + public async Task DownloadFileInPartsAsync_RequestsRangesAndMergesFile() + { + var payload = Encoding.UTF8.GetBytes("abcdefghijklmnopqrstuvwxyz"); + var handler = new RangeHttpMessageHandler(payload); + using var httpClient = new HttpClient(handler); + var targetDirectory = Path.Combine(Path.GetTempPath(), "SelfUpdateBootstrapTests", Guid.NewGuid().ToString("N")); + var partialPath = Path.Combine(targetDirectory, "download.partial"); + Directory.CreateDirectory(targetDirectory); + + try + { + var task = InvokePrivateStatic( + "DownloadFileInPartsAsync", + httpClient, + "https://updates.test/package.zst", + partialPath, + (long)payload.Length, + CancellationToken.None)!; + await task; + + Assert.Equal(payload, File.ReadAllBytes(partialPath)); + Assert.True(handler.RangeRequests.Count >= 2); + Assert.DoesNotContain(Directory.GetFiles(targetDirectory), path => Path.GetFileName(path).Contains(".part0", StringComparison.Ordinal)); + } + finally + { + if (Directory.Exists(targetDirectory)) + { + Directory.Delete(targetDirectory, recursive: true); + } + } + } + // ── ToManagedUpdateResult ────────────────────────────────────── [Fact] diff --git a/TelegramSearchBot/Service/Update/SelfUpdateBootstrap.Windows.cs b/TelegramSearchBot/Service/Update/SelfUpdateBootstrap.Windows.cs index af3e670e..f43812e0 100644 --- a/TelegramSearchBot/Service/Update/SelfUpdateBootstrap.Windows.cs +++ b/TelegramSearchBot/Service/Update/SelfUpdateBootstrap.Windows.cs @@ -5,7 +5,9 @@ using System.Formats.Tar; using System.IO; using System.Linq; +using System.Net; using System.Net.Http; +using System.Net.Http.Headers; using System.Reflection; using System.Security.Cryptography; using System.Text.Json; @@ -22,6 +24,9 @@ namespace TelegramSearchBot.Service.AppUpdate; public static partial class SelfUpdateBootstrap { private const string UpdaterFileName = "moder_update_updater.exe"; + private const long MultipartDownloadThresholdBytes = 32L * 1024 * 1024; + private const long MultipartDownloadMinimumPartSizeBytes = 8L * 1024 * 1024; + private const int MultipartDownloadMaxParallelism = 8; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; @@ -270,24 +275,24 @@ private static async Task PrepareUpdateChainAsync( $"{SanitizeVersion(currentVersion)}-to-{SanitizeVersion(finalTarget)}"); var stagingDir = Path.Combine(updateRoot, "staging"); var backupDir = Path.Combine(updateRoot, "backup"); + var packageCacheDir = Path.Combine(updateRoot, "packages"); ResetDirectory(stagingDir); ResetDirectory(backupDir); + ResetDirectory(packageCacheDir); foreach (var entry in updatePath) { Log.Information("下载更新包: {PackagePath} (from {MinVersion})", entry.PackagePath, entry.MinSourceVersion); - await using var packageStream = await DownloadStreamAsync( - httpClient, entry.PackagePath, cancellationToken); - await using var packageBuffer = new MemoryStream(); - await packageStream.CopyToAsync(packageBuffer, cancellationToken); - packageBuffer.Position = 0; + var packagePath = Path.Combine(packageCacheDir, GetPackageCacheFileName(entry.PackagePath)); + await DownloadFileAsync(httpClient, entry.PackagePath, packagePath, cancellationToken); - VerifyPackageChecksum(packageBuffer, entry); - packageBuffer.Position = 0; - ExtractPackageToDirectory(packageBuffer, stagingDir); + await using var packageStream = File.OpenRead(packagePath); + VerifyPackageChecksum(packageStream, entry); + packageStream.Position = 0; + ExtractPackageToDirectory(packageStream, stagingDir); } var updaterPath = await DownloadUpdaterAsync(httpClient, cancellationToken); @@ -328,25 +333,271 @@ private static bool CanApplyEntry(Version currentVersion, UpdateCatalogEntry ent private static async Task DownloadUpdaterAsync(HttpClient httpClient, CancellationToken cancellationToken) { Directory.CreateDirectory(Path.GetDirectoryName(UpdaterCachePath)!); - await using var updaterStream = await DownloadStreamAsync(httpClient, UpdaterFileName, cancellationToken); - await using var updaterBuffer = new MemoryStream(); - await updaterStream.CopyToAsync(updaterBuffer, cancellationToken); - VerifyUpdaterChecksum(updaterBuffer); - updaterBuffer.Position = 0; - await using var fileStream = File.Create(UpdaterCachePath); - await updaterBuffer.CopyToAsync(fileStream, cancellationToken); + var downloadPath = UpdaterCachePath + ".download"; + await DownloadFileAsync(httpClient, UpdaterFileName, downloadPath, cancellationToken); + + try { + await using var updaterStream = File.OpenRead(downloadPath); + VerifyUpdaterChecksum(updaterStream); + } catch { + File.Delete(downloadPath); + throw; + } + + File.Move(downloadPath, UpdaterCachePath, overwrite: true); return UpdaterCachePath; } - private static async Task DownloadStreamAsync(HttpClient httpClient, string relativePath, CancellationToken cancellationToken) + private static async Task DownloadFileAsync( + HttpClient httpClient, + string relativePath, + string targetPath, + CancellationToken cancellationToken) + { + var requestUri = $"{Env.UpdateBaseUrl.TrimEnd('/')}/{relativePath.TrimStart('/')}"; + await DownloadFileFromUriAsync(httpClient, requestUri, targetPath, cancellationToken); + } + + private static async Task DownloadFileFromUriAsync( + HttpClient httpClient, + string requestUri, + string targetPath, + CancellationToken cancellationToken) + { + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + var partialPath = targetPath + ".partial"; + + try { + var probe = await ProbeDownloadAsync(httpClient, requestUri, cancellationToken); + if (probe.SupportsRanges + && probe.ContentLength is >= MultipartDownloadThresholdBytes) { + try { + await DownloadFileInPartsAsync( + httpClient, + requestUri, + partialPath, + probe.ContentLength.Value, + cancellationToken); + File.Move(partialPath, targetPath, overwrite: true); + return; + } catch (Exception ex) when (ex is not OperationCanceledException) { + DeleteFileIfExists(partialPath); + Log.Warning(ex, "分片下载失败,回退到单连接下载: {RequestUri}", requestUri); + } + } + + await DownloadFileSingleStreamAsync( + httpClient, + requestUri, + partialPath, + probe.ContentLength, + cancellationToken); + File.Move(partialPath, targetPath, overwrite: true); + } catch { + DeleteFileIfExists(partialPath); + throw; + } + } + + private static async Task ProbeDownloadAsync( + HttpClient httpClient, + string requestUri, + CancellationToken cancellationToken) + { + try { + using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + request.Headers.Range = new RangeHeaderValue(0, 0); + using var response = await httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + + if (response.StatusCode == HttpStatusCode.PartialContent + && response.Content.Headers.ContentRange?.Length is { } rangeLength) { + return new DownloadProbe(true, rangeLength); + } + + if (response.IsSuccessStatusCode) { + return new DownloadProbe(false, response.Content.Headers.ContentLength); + } + } catch (Exception ex) when (ex is not OperationCanceledException) { + Log.Debug(ex, "探测分片下载能力失败,将使用单连接下载: {RequestUri}", requestUri); + } + + return new DownloadProbe(false, null); + } + + private static async Task DownloadFileSingleStreamAsync( + HttpClient httpClient, + string requestUri, + string partialPath, + long? expectedLength, + CancellationToken cancellationToken) { - using var response = await httpClient.GetAsync($"{Env.UpdateBaseUrl.TrimEnd('/')}/{relativePath.TrimStart('/')}", cancellationToken); + using var response = await httpClient.GetAsync( + requestUri, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); response.EnsureSuccessStatusCode(); - await using var sourceStream = await response.Content.ReadAsStreamAsync(cancellationToken); - var buffer = new MemoryStream(); - await sourceStream.CopyToAsync(buffer, cancellationToken); - buffer.Position = 0; - return buffer; + + try { + await using (var sourceStream = await response.Content.ReadAsStreamAsync(cancellationToken)) + await using (var fileStream = new FileStream( + partialPath, + FileMode.Create, + FileAccess.Write, + FileShare.None, + bufferSize: 81920, + FileOptions.Asynchronous | FileOptions.SequentialScan)) { + await sourceStream.CopyToAsync(fileStream, cancellationToken); + } + + VerifyDownloadedLength(partialPath, expectedLength); + } catch { + DeleteFileIfExists(partialPath); + throw; + } + } + + private static async Task DownloadFileInPartsAsync( + HttpClient httpClient, + string requestUri, + string partialPath, + long contentLength, + CancellationToken cancellationToken) + { + var ranges = CreateDownloadRanges(contentLength); + if (ranges.Count == 0) { + throw new InvalidDataException("远端文件长度无效,无法分片下载。"); + } + + var partPaths = ranges + .Select((_, index) => $"{partialPath}.part{index:D4}") + .ToArray(); + + try { + var downloadTasks = ranges + .Select((range, index) => DownloadFilePartAsync( + httpClient, + requestUri, + partPaths[index], + range, + cancellationToken)) + .ToArray(); + + await Task.WhenAll(downloadTasks); + await MergeDownloadPartsAsync(partialPath, partPaths, cancellationToken); + VerifyDownloadedLength(partialPath, contentLength); + } finally { + foreach (var partPath in partPaths) { + DeleteFileIfExists(partPath); + } + } + } + + private static async Task DownloadFilePartAsync( + HttpClient httpClient, + string requestUri, + string partPath, + DownloadRange range, + CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + request.Headers.Range = new RangeHeaderValue(range.Start, range.End); + using var response = await httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + + if (response.StatusCode != HttpStatusCode.PartialContent) { + throw new InvalidDataException( + $"服务器未返回分片内容,状态码: {(int)response.StatusCode} {response.StatusCode}。"); + } + + if (response.Content.Headers.ContentLength is { } contentLength + && contentLength != range.Length) { + throw new InvalidDataException( + $"分片长度不匹配,期望 {range.Length},实际 {contentLength}。"); + } + + await using (var sourceStream = await response.Content.ReadAsStreamAsync(cancellationToken)) + await using (var fileStream = new FileStream( + partPath, + FileMode.Create, + FileAccess.Write, + FileShare.None, + bufferSize: 81920, + FileOptions.Asynchronous | FileOptions.SequentialScan)) { + await sourceStream.CopyToAsync(fileStream, cancellationToken); + } + + VerifyDownloadedLength(partPath, range.Length); + } + + private static async Task MergeDownloadPartsAsync( + string partialPath, + IReadOnlyList partPaths, + CancellationToken cancellationToken) + { + await using var output = new FileStream( + partialPath, + FileMode.Create, + FileAccess.Write, + FileShare.None, + bufferSize: 81920, + FileOptions.Asynchronous | FileOptions.SequentialScan); + + foreach (var partPath in partPaths) { + await using var input = new FileStream( + partPath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 81920, + FileOptions.Asynchronous | FileOptions.SequentialScan); + await input.CopyToAsync(output, cancellationToken); + } + } + + private static List CreateDownloadRanges(long contentLength) + { + if (contentLength <= 0) { + return []; + } + + var partCount = (int)Math.Min( + MultipartDownloadMaxParallelism, + Math.Ceiling(contentLength / (double)MultipartDownloadMinimumPartSizeBytes)); + if (contentLength > 1 && partCount < 2) { + partCount = 2; + } + + var partSize = (long)Math.Ceiling(contentLength / (double)partCount); + var ranges = new List(partCount); + for (var index = 0; index < partCount; index++) { + var start = index * partSize; + if (start >= contentLength) { + break; + } + + var end = Math.Min(contentLength - 1, start + partSize - 1); + ranges.Add(new DownloadRange(start, end)); + } + + return ranges; + } + + private static void VerifyDownloadedLength(string path, long? expectedLength) + { + if (expectedLength is null) { + return; + } + + var actualLength = new FileInfo(path).Length; + if (actualLength != expectedLength.Value) { + throw new InvalidDataException( + $"下载文件长度不匹配,期望 {expectedLength.Value},实际 {actualLength}。"); + } } private static void ExtractPackageToDirectory(Stream packageStream, string targetDirectory) @@ -395,13 +646,7 @@ private static void ExtractPackageToDirectory(Stream packageStream, string targe private static Stream DecompressPackage(Stream packageStream) { - var output = new MemoryStream(); - using (var decompressionStream = new DecompressionStream(packageStream, leaveOpen: true)) { - decompressionStream.CopyTo(output); - } - - output.Position = 0; - return output; + return new DecompressionStream(packageStream, leaveOpen: true); } private static bool ValidatePackageMagic(Stream packageStream) @@ -536,6 +781,33 @@ private static string NormalizeTarPath(string path) private static string SanitizeVersion(string version) => version.Replace('.', '_'); + private static string GetPackageCacheFileName(string relativePath) + { + var fileName = Path.GetFileName(relativePath.Replace('/', Path.DirectorySeparatorChar)); + if (string.IsNullOrWhiteSpace(fileName)) { + fileName = "package.zst"; + } + + foreach (var invalidChar in Path.GetInvalidFileNameChars()) { + fileName = fileName.Replace(invalidChar, '_'); + } + + return fileName; + } + + private static void DeleteFileIfExists(string path) + { + try { + if (File.Exists(path)) { + File.Delete(path); + } + } catch (IOException ex) { + Log.Warning(ex, "清理临时下载文件失败: {Path}", path); + } catch (UnauthorizedAccessException ex) { + Log.Warning(ex, "清理临时下载文件失败: {Path}", path); + } + } + private static void EnsurePathWithinDirectory(string targetPath, string directoryPrefix, string entryName) { if (!targetPath.StartsWith(directoryPrefix, StringComparison.OrdinalIgnoreCase)) { @@ -566,6 +838,13 @@ public bool AppliesTo(Version currentVersion) } } + private readonly record struct DownloadProbe(bool SupportsRanges, long? ContentLength); + + private readonly record struct DownloadRange(long Start, long End) + { + public long Length => End - Start + 1; + } + private sealed class UpdatePlanNode { public Version? PreviousVersion { get; init; } From 1ef1e5e243b23f26ba2448ea818ecb45b0fa36de Mon Sep 17 00:00:00 2001 From: ModerRAS Date: Tue, 12 May 2026 18:09:08 +0800 Subject: [PATCH 2/2] Address self-update download review feedback --- .../Update/SelfUpdateBootstrapTests.cs | 42 ++++++++++++++++++- .../Update/SelfUpdateBootstrap.Windows.cs | 11 ++++- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/TelegramSearchBot.Test/Service/Update/SelfUpdateBootstrapTests.cs b/TelegramSearchBot.Test/Service/Update/SelfUpdateBootstrapTests.cs index 83ea0d69..4f815de0 100644 --- a/TelegramSearchBot.Test/Service/Update/SelfUpdateBootstrapTests.cs +++ b/TelegramSearchBot.Test/Service/Update/SelfUpdateBootstrapTests.cs @@ -408,12 +408,15 @@ private static void WriteTarEntry(TarWriter tarWriter, string relativePath, byte private sealed class RangeHttpMessageHandler : HttpMessageHandler { private readonly byte[] _content; + private readonly bool _supportsRanges; public ConcurrentBag<(long Start, long End)> RangeRequests { get; } = new(); + public int FullRequestCount { get; private set; } - public RangeHttpMessageHandler(byte[] content) + public RangeHttpMessageHandler(byte[] content, bool supportsRanges = true) { _content = content; + _supportsRanges = supportsRanges; } protected override Task SendAsync( @@ -421,8 +424,9 @@ protected override Task SendAsync( CancellationToken cancellationToken) { var range = request.Headers.Range?.Ranges.SingleOrDefault(); - if (range is null) + if (range is null || !_supportsRanges) { + FullRequestCount++; var fullResponse = new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(_content) @@ -746,6 +750,40 @@ public async Task DownloadFileInPartsAsync_RequestsRangesAndMergesFile() } } + [Fact] + public async Task DownloadFileFromUriAsync_FallsBackToSingleStreamWhenRangesUnsupported() + { + var payload = Encoding.UTF8.GetBytes("abcdefghijklmnopqrstuvwxyz"); + var handler = new RangeHttpMessageHandler(payload, supportsRanges: false); + using var httpClient = new HttpClient(handler); + var targetDirectory = Path.Combine(Path.GetTempPath(), "SelfUpdateBootstrapTests", Guid.NewGuid().ToString("N")); + var targetPath = Path.Combine(targetDirectory, "download.bin"); + Directory.CreateDirectory(targetDirectory); + + try + { + var task = InvokePrivateStatic( + "DownloadFileFromUriAsync", + httpClient, + "https://updates.test/package.zst", + targetPath, + CancellationToken.None)!; + await task; + + Assert.Equal(payload, File.ReadAllBytes(targetPath)); + Assert.Empty(handler.RangeRequests); + Assert.Equal(2, handler.FullRequestCount); + Assert.DoesNotContain(Directory.GetFiles(targetDirectory), path => Path.GetFileName(path).Contains(".part0", StringComparison.Ordinal)); + } + finally + { + if (Directory.Exists(targetDirectory)) + { + Directory.Delete(targetDirectory, recursive: true); + } + } + } + // ── ToManagedUpdateResult ────────────────────────────────────── [Fact] diff --git a/TelegramSearchBot/Service/Update/SelfUpdateBootstrap.Windows.cs b/TelegramSearchBot/Service/Update/SelfUpdateBootstrap.Windows.cs index f43812e0..c49a50c9 100644 --- a/TelegramSearchBot/Service/Update/SelfUpdateBootstrap.Windows.cs +++ b/TelegramSearchBot/Service/Update/SelfUpdateBootstrap.Windows.cs @@ -412,8 +412,11 @@ private static async Task ProbeDownloadAsync( HttpCompletionOption.ResponseHeadersRead, cancellationToken); + var contentRange = response.Content.Headers.ContentRange; if (response.StatusCode == HttpStatusCode.PartialContent - && response.Content.Headers.ContentRange?.Length is { } rangeLength) { + && contentRange?.From == 0 + && contentRange.To == 0 + && contentRange.Length is { } rangeLength) { return new DownloadProbe(true, rangeLength); } @@ -514,6 +517,12 @@ private static async Task DownloadFilePartAsync( $"服务器未返回分片内容,状态码: {(int)response.StatusCode} {response.StatusCode}。"); } + var contentRange = response.Content.Headers.ContentRange; + if (contentRange?.From != range.Start || contentRange.To != range.End) { + throw new InvalidDataException( + $"分片范围不匹配,期望 {range.Start}-{range.End},实际 {contentRange?.From}-{contentRange?.To}。"); + } + if (response.Content.Headers.ContentLength is { } contentLength && contentLength != range.Length) { throw new InvalidDataException(