fix(launcher): harden updater crash recovery
This commit is contained in:
parent
2d2a5b5046
commit
1955ca8ab5
27 changed files with 2714 additions and 544 deletions
|
|
@ -55,6 +55,42 @@ public sealed class LauncherOrchestratorTests : IDisposable
|
|||
using UpdateSessionBarrier.ExclusiveLease update = barrier.AcquireExclusive();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisposeKeepsUpdateLeaseUntilLiveChildIsObservedTerminal()
|
||||
{
|
||||
var supervisors = new BlockingStopSupervisorFactory();
|
||||
var barrier = new UpdateSessionBarrier(_paths.DataDirectory);
|
||||
LauncherOrchestrator orchestrator = CreateOrchestrator(
|
||||
supervisorFactory: supervisors,
|
||||
updateSessionBarrier: barrier);
|
||||
try
|
||||
{
|
||||
_ = await orchestrator.LaunchAsync(
|
||||
"Local ACE",
|
||||
"testaccount",
|
||||
"+Acdream",
|
||||
LaunchMode.Headless);
|
||||
BlockingStopSupervisor supervisor = Assert.Single(supervisors.Created);
|
||||
|
||||
Task disposal = Task.Run(orchestrator.Dispose);
|
||||
Assert.True(supervisor.StopEntered.Wait(TimeSpan.FromSeconds(5)));
|
||||
|
||||
Assert.False(disposal.IsCompleted);
|
||||
Assert.Throws<LauncherUpdateException>(barrier.AcquireExclusive);
|
||||
|
||||
supervisor.AllowTerminal.Set();
|
||||
await disposal.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
using UpdateSessionBarrier.ExclusiveLease update = barrier.AcquireExclusive();
|
||||
Assert.Equal(LauncherSessionState.Exited, supervisor.State);
|
||||
Assert.True(supervisor.Disposed);
|
||||
}
|
||||
finally
|
||||
{
|
||||
supervisors.AllowEveryStop();
|
||||
orchestrator.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotProjectsTheFullHierarchyWithoutTheCredential()
|
||||
{
|
||||
|
|
@ -756,6 +792,59 @@ public sealed class LauncherOrchestratorTests : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
private sealed class BlockingStopSupervisorFactory : ILauncherProcessSupervisorFactory
|
||||
{
|
||||
public List<BlockingStopSupervisor> Created { get; } = [];
|
||||
|
||||
public ILauncherProcessSupervisor Create()
|
||||
{
|
||||
var supervisor = new BlockingStopSupervisor();
|
||||
Created.Add(supervisor);
|
||||
return supervisor;
|
||||
}
|
||||
|
||||
public void AllowEveryStop()
|
||||
{
|
||||
foreach (BlockingStopSupervisor supervisor in Created)
|
||||
{
|
||||
supervisor.AllowTerminal.Set();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BlockingStopSupervisor : ILauncherProcessSupervisor
|
||||
{
|
||||
public ManualResetEventSlim StopEntered { get; } = new(false);
|
||||
|
||||
public ManualResetEventSlim AllowTerminal { get; } = new(false);
|
||||
|
||||
public LauncherSessionState State { get; private set; } =
|
||||
LauncherSessionState.Starting;
|
||||
|
||||
public int? ExitCode { get; private set; }
|
||||
|
||||
public bool Disposed { get; private set; }
|
||||
|
||||
public event EventHandler<LauncherSessionState>? StateChanged;
|
||||
|
||||
public void Start(LauncherProcessSpec spec, string? password)
|
||||
{
|
||||
State = LauncherSessionState.Running;
|
||||
StateChanged?.Invoke(this, State);
|
||||
}
|
||||
|
||||
public void Stop(TimeSpan timeout)
|
||||
{
|
||||
StopEntered.Set();
|
||||
AllowTerminal.Wait();
|
||||
State = LauncherSessionState.Exited;
|
||||
ExitCode = 0;
|
||||
StateChanged?.Invoke(this, State);
|
||||
}
|
||||
|
||||
public void Dispose() => Disposed = true;
|
||||
}
|
||||
|
||||
private sealed class QueueStatusSourceFactory : IStatusEventSourceFactory
|
||||
{
|
||||
public List<QueueStatusSource> Created { get; } = [];
|
||||
|
|
|
|||
|
|
@ -177,6 +177,61 @@ public sealed class ClientVersionStoreTests : IDisposable
|
|||
Path.Combine(resolution.Directory!, "AcDream.App" + ExecutableSuffix)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LinuxTreatsNonCanonicalInstallJsonCasingAsUnrecordedContent()
|
||||
{
|
||||
if (!OperatingSystem.IsLinux())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var store = new ClientVersionStore(_paths);
|
||||
ClientVersionResolution installed = await PromoteAsync(store, "1.0.0", "linux-case");
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(installed.Directory!, "INSTALL.JSON"),
|
||||
"must-not-be-hidden");
|
||||
|
||||
ClientVersionResolution resolution = await new ClientVersionStore(_paths)
|
||||
.LoadAndRecoverAsync(_rid);
|
||||
|
||||
Assert.Equal(ClientVersionState.Invalid, resolution.State);
|
||||
Assert.Contains("unrecorded", resolution.Status, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExclusiveStartupReclaimsOnlyCanonicalGuidOwnedResidue()
|
||||
{
|
||||
var store = new ClientVersionStore(_paths);
|
||||
Directory.CreateDirectory(store.AppDirectory);
|
||||
string id = Guid.NewGuid().ToString("N");
|
||||
string nearId = id[..31] + "g";
|
||||
string exactStaging = Path.Combine(store.AppDirectory, ".client-staging-" + id);
|
||||
string exactCorrupt = Path.Combine(store.AppDirectory, ".client-corrupt-" + id);
|
||||
string exactDownload = Path.Combine(
|
||||
store.AppDirectory,
|
||||
".client-download-" + id + ".zip");
|
||||
string nearStaging = exactStaging + "-user";
|
||||
string nearCorrupt = Path.Combine(store.AppDirectory, ".client-corrupt-" + nearId);
|
||||
string nearDownload = Path.Combine(
|
||||
store.AppDirectory,
|
||||
".client-download-" + id + ".zip.user");
|
||||
Directory.CreateDirectory(exactStaging);
|
||||
Directory.CreateDirectory(exactCorrupt);
|
||||
Directory.CreateDirectory(nearStaging);
|
||||
Directory.CreateDirectory(nearCorrupt);
|
||||
await File.WriteAllTextAsync(exactDownload, "owned");
|
||||
await File.WriteAllTextAsync(nearDownload, "preserve");
|
||||
|
||||
_ = await store.LoadAndRecoverAsync(_rid);
|
||||
|
||||
Assert.False(Directory.Exists(exactStaging));
|
||||
Assert.False(Directory.Exists(exactCorrupt));
|
||||
Assert.False(File.Exists(exactDownload));
|
||||
Assert.True(Directory.Exists(nearStaging));
|
||||
Assert.True(Directory.Exists(nearCorrupt));
|
||||
Assert.True(File.Exists(nearDownload));
|
||||
}
|
||||
|
||||
private string ExecutableSuffix => _rid.StartsWith("win-", StringComparison.Ordinal)
|
||||
? ".exe"
|
||||
: string.Empty;
|
||||
|
|
|
|||
|
|
@ -1,23 +1,9 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using AcDream.Launcher.Core.Updates;
|
||||
|
||||
namespace AcDream.Launcher.Core.Tests.Updates;
|
||||
|
||||
public sealed class LauncherSelfUpdateManagerTests : IDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions PlanOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true,
|
||||
Converters =
|
||||
{
|
||||
new JsonStringEnumConverter<SelfUpdatePlanState>(
|
||||
JsonNamingPolicy.CamelCase,
|
||||
allowIntegerValues: false),
|
||||
},
|
||||
};
|
||||
|
||||
private readonly string _root = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"acdream-self-update-tests",
|
||||
|
|
@ -47,6 +33,12 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable
|
|||
await File.WriteAllTextAsync(unrelated, "preserve");
|
||||
|
||||
Assert.Null(await harness.Manager.LoadPendingAsync());
|
||||
using UpdateSessionBarrier.ExclusiveLease lease =
|
||||
harness.Manager.Barrier.AcquireExclusive();
|
||||
Assert.True(harness.Manager.CleanupOwnedResidueUnderLease(
|
||||
pending: null,
|
||||
harness.Target,
|
||||
lease));
|
||||
|
||||
Assert.False(Directory.Exists(orphan));
|
||||
Assert.False(File.Exists(temporary));
|
||||
|
|
@ -121,49 +113,91 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CrashDuringApplyingReplaysReverseJournalToStagedState()
|
||||
public async Task PriorOwnershipRemovesObsoleteFilesAndRollbackRestoresThem()
|
||||
{
|
||||
using var harness = new Harness(_root);
|
||||
byte[] firstArchive = UpdateTestData.CreateZip(
|
||||
[
|
||||
(harness.LauncherName, "launcher-v2"u8.ToArray(), 0x81ED),
|
||||
("support.dat", "support-v2"u8.ToArray(), 0x81A4),
|
||||
("obsolete.dll", "obsolete-v2"u8.ToArray(), 0x81A4),
|
||||
]);
|
||||
_ = await harness.StageAsync("2.0.0", firstArchive);
|
||||
SelfUpdatePlan first = await harness.Manager.ApplyPendingAsync(harness.Target);
|
||||
await harness.Manager.ConfirmAsync(
|
||||
first.TransactionId,
|
||||
harness.Target,
|
||||
harness.LauncherPath);
|
||||
await harness.Manager.CompleteConfirmedAsync(first.TransactionId, harness.Target);
|
||||
string obsoletePath = Path.Combine(harness.Target, "obsolete.dll");
|
||||
Assert.Equal("obsolete-v2", await File.ReadAllTextAsync(obsoletePath));
|
||||
|
||||
byte[] secondArchive = UpdateTestData.CreateZip(
|
||||
[
|
||||
(harness.LauncherName, "launcher-v3"u8.ToArray(), 0x81ED),
|
||||
("support.dat", "support-v3"u8.ToArray(), 0x81A4),
|
||||
]);
|
||||
_ = await harness.StageAsync("3.0.0", secondArchive);
|
||||
SelfUpdatePlan applied = await harness.Manager.ApplyPendingAsync(harness.Target);
|
||||
|
||||
Assert.False(File.Exists(obsoletePath));
|
||||
Assert.Contains(
|
||||
applied.Apply!,
|
||||
entry => entry.Path == "obsolete.dll"
|
||||
&& entry.Operation == SelfUpdateApplyOperation.Remove
|
||||
&& entry.HadOriginal);
|
||||
|
||||
SelfUpdatePlan rolledBack = await harness.Manager
|
||||
.RollbackAwaitingConfirmationAsync(harness.Target);
|
||||
|
||||
Assert.Equal(SelfUpdatePlanState.Staged, rolledBack.State);
|
||||
Assert.Equal("launcher-v2", await File.ReadAllTextAsync(harness.LauncherPath));
|
||||
Assert.Equal("support-v2", await File.ReadAllTextAsync(harness.SupportPath));
|
||||
Assert.Equal("obsolete-v2", await File.ReadAllTextAsync(obsoletePath));
|
||||
|
||||
SelfUpdatePlan retried = await harness.Manager.ApplyPendingAsync(harness.Target);
|
||||
await harness.Manager.ConfirmAsync(
|
||||
retried.TransactionId,
|
||||
harness.Target,
|
||||
harness.LauncherPath);
|
||||
await harness.Manager.CompleteConfirmedAsync(retried.TransactionId, harness.Target);
|
||||
|
||||
Assert.False(File.Exists(obsoletePath));
|
||||
string ownership = await File.ReadAllTextAsync(Path.Combine(
|
||||
harness.Target,
|
||||
LauncherSelfUpdateManager.InstallRecordFileName));
|
||||
Assert.DoesNotContain("obsolete.dll", ownership, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyFailpointAfterCanonicalAtomicReplaceRollsBackToStagedState()
|
||||
{
|
||||
using var harness = new Harness(_root);
|
||||
_ = await harness.StageAsync();
|
||||
SelfUpdatePlan staged = Assert.IsType<SelfUpdatePlan>(
|
||||
await harness.Manager.LoadPendingAsync());
|
||||
SelfUpdateApplyEntry[] apply = staged.Files
|
||||
.Select(file => new SelfUpdateApplyEntry(
|
||||
file.Path,
|
||||
File.Exists(Path.Combine(
|
||||
harness.Target,
|
||||
file.Path.Replace('/', Path.DirectorySeparatorChar)))))
|
||||
.ToArray();
|
||||
SelfUpdatePlan applying = staged with
|
||||
LauncherSelfUpdateManager faulting = harness.CreateManagerWithObserver(observation =>
|
||||
{
|
||||
State = SelfUpdatePlanState.Applying,
|
||||
Apply = apply,
|
||||
};
|
||||
await File.WriteAllTextAsync(
|
||||
harness.Manager.PendingPlanPath,
|
||||
JsonSerializer.Serialize(applying, PlanOptions));
|
||||
if (observation.Boundary == SelfUpdateApplyBoundary.AfterTargetMutation
|
||||
&& string.Equals(
|
||||
observation.Path,
|
||||
harness.LauncherName,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
Assert.True(File.Exists(harness.LauncherPath));
|
||||
throw new InvalidOperationException("failpoint");
|
||||
}
|
||||
});
|
||||
|
||||
SelfUpdateApplyEntry first = apply[0];
|
||||
string payload = Path.Combine(
|
||||
harness.Manager.GetPayloadDirectory(staged.TransactionId),
|
||||
first.Path.Replace('/', Path.DirectorySeparatorChar));
|
||||
string target = Path.Combine(
|
||||
harness.Target,
|
||||
first.Path.Replace('/', Path.DirectorySeparatorChar));
|
||||
string backup = Path.Combine(
|
||||
harness.Manager.GetBackupDirectory(staged.TransactionId),
|
||||
first.Path.Replace('/', Path.DirectorySeparatorChar));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(backup)!);
|
||||
File.Move(target, backup);
|
||||
File.Move(payload, target);
|
||||
|
||||
SelfUpdatePlan recovered = await harness.Manager.RecoverApplyingAsync(harness.Target);
|
||||
InvalidOperationException failure = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => faulting.ApplyPendingAsync(harness.Target));
|
||||
SelfUpdatePlan recovered = Assert.IsType<SelfUpdatePlan>(
|
||||
await harness.Manager.LoadPendingAsync());
|
||||
|
||||
Assert.Equal("failpoint", failure.Message);
|
||||
Assert.Equal(SelfUpdatePlanState.Staged, recovered.State);
|
||||
Assert.Equal("old-launcher", await File.ReadAllTextAsync(harness.LauncherPath));
|
||||
Assert.Equal("old-support", await File.ReadAllTextAsync(harness.SupportPath));
|
||||
Assert.Equal("new-launcher", await File.ReadAllTextAsync(Path.Combine(
|
||||
harness.Manager.GetPayloadDirectory(staged.TransactionId),
|
||||
harness.Manager.GetPayloadDirectory(recovered.TransactionId),
|
||||
harness.LauncherName)));
|
||||
}
|
||||
|
||||
|
|
@ -219,8 +253,8 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable
|
|||
|
||||
Assert.False(confirmation.ShouldExit);
|
||||
Assert.Empty(confirmation.RemainingArguments);
|
||||
Assert.True(harness.Manager.IsConfirmed(applied.TransactionId));
|
||||
await harness.Manager.CompleteConfirmedAsync(applied.TransactionId, harness.Target);
|
||||
Assert.False(File.Exists(harness.Manager.PendingPlanPath));
|
||||
Assert.False(harness.Manager.IsConfirmed(applied.TransactionId));
|
||||
}
|
||||
|
||||
private sealed class Harness : IDisposable
|
||||
|
|
@ -229,9 +263,11 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable
|
|||
private readonly HttpClient _http = new();
|
||||
private readonly byte[] _archive;
|
||||
private readonly ReleaseArtifact _artifact;
|
||||
private readonly string _root;
|
||||
|
||||
public Harness(string root)
|
||||
{
|
||||
_root = root;
|
||||
Target = Path.Combine(root, "published launcher");
|
||||
Directory.CreateDirectory(Target);
|
||||
Rid = LauncherRuntimeIdentity.DetectRid();
|
||||
|
|
@ -270,6 +306,25 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable
|
|||
progress: null,
|
||||
CancellationToken.None);
|
||||
|
||||
public Task<SelfUpdateStageResult> StageAsync(string version, byte[] archive)
|
||||
{
|
||||
_server.Add("launcher.zip", archive);
|
||||
return Manager.StageAsync(
|
||||
LauncherVersion.Parse(version),
|
||||
Rid,
|
||||
new ReleaseArtifact(
|
||||
_server.UriFor("launcher.zip"),
|
||||
UpdateTestData.Sha256(archive),
|
||||
archive.LongLength),
|
||||
Target,
|
||||
progress: null,
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
public LauncherSelfUpdateManager CreateManagerWithObserver(
|
||||
Action<SelfUpdateApplyObservation> observer) =>
|
||||
new(UpdateTestData.Paths(_root), _http, null, observer);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_http.Dispose();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,453 @@
|
|||
using System.Diagnostics;
|
||||
using AcDream.Launcher.Core.Integrity;
|
||||
using AcDream.Launcher.Core.Updates;
|
||||
|
||||
namespace AcDream.Launcher.Core.Tests.Updates;
|
||||
|
||||
public sealed class LauncherSelfUpdateProcessTests : IDisposable
|
||||
{
|
||||
private const string FixtureBaseName =
|
||||
"AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder";
|
||||
private const string DataEnvironment = "ACDREAM_SELF_UPDATE_FIXTURE_DATA";
|
||||
private const string TargetEnvironment = "ACDREAM_SELF_UPDATE_FIXTURE_TARGET";
|
||||
private const string HelperPidEnvironment =
|
||||
"ACDREAM_SELF_UPDATE_FIXTURE_HELPER_PID";
|
||||
|
||||
private readonly string _root = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"acdream-self-update-process-tests",
|
||||
Guid.NewGuid().ToString("N"));
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_root))
|
||||
{
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task KilledAfterCanonicalReplaceCanInvokeCanonicalAndConvergeAutomatically()
|
||||
{
|
||||
string data = Path.Combine(_root, "data");
|
||||
string target = Path.Combine(_root, "launcher");
|
||||
string ready = Path.Combine(_root, "crash.ready");
|
||||
string launched = Path.Combine(_root, "replacement.ready");
|
||||
string helperPidPath = Path.Combine(_root, "helper.pid");
|
||||
Directory.CreateDirectory(_root);
|
||||
string rid = LauncherRuntimeIdentity.DetectRid();
|
||||
PreparedLauncher prepared = PrepareLauncherClosure(target, rid);
|
||||
string oldHash = await FileIntegrity.ComputeSha256HexAsync(prepared.CanonicalPath);
|
||||
using var server = new LocalHttpFixture();
|
||||
server.Add("launcher.zip", prepared.NewArchive);
|
||||
using var http = new HttpClient();
|
||||
var manager = new LauncherSelfUpdateManager(UpdateTestData.Paths(_root), http);
|
||||
SelfUpdateStageResult staged = await manager.StageAsync(
|
||||
LauncherVersion.Parse("2.0.0"),
|
||||
rid,
|
||||
new ReleaseArtifact(
|
||||
server.UriFor("launcher.zip"),
|
||||
UpdateTestData.Sha256(prepared.NewArchive),
|
||||
prepared.NewArchive.LongLength),
|
||||
target,
|
||||
progress: null,
|
||||
CancellationToken.None);
|
||||
SelfUpdatePlan plan = Assert.IsType<SelfUpdatePlan>(await manager.LoadPendingAsync());
|
||||
|
||||
using Process crash = StartFixture(
|
||||
["crash-self-update", data, target, ready, prepared.CanonicalName]);
|
||||
try
|
||||
{
|
||||
await WaitForFileAsync(ready, crash, TimeSpan.FromSeconds(20));
|
||||
Assert.True(File.Exists(prepared.CanonicalPath));
|
||||
crash.Kill(entireProcessTree: true);
|
||||
await crash.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.True(File.Exists(prepared.CanonicalPath));
|
||||
string boundaryHash = await FileIntegrity.ComputeSha256HexAsync(
|
||||
prepared.CanonicalPath);
|
||||
Assert.Contains(boundaryHash, new[] { oldHash, prepared.NewCanonicalHash });
|
||||
|
||||
var environment = new Dictionary<string, string>
|
||||
{
|
||||
[DataEnvironment] = data,
|
||||
[TargetEnvironment] = target,
|
||||
[HelperPidEnvironment] = helperPidPath,
|
||||
};
|
||||
using Process canonical = StartProcess(
|
||||
prepared.CanonicalPath,
|
||||
["canonical-probe", launched],
|
||||
environment);
|
||||
await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20));
|
||||
Assert.Equal(0, canonical.ExitCode);
|
||||
await WaitForFileAsync(launched, process: null, TimeSpan.FromSeconds(30));
|
||||
await WaitUntilAsync(
|
||||
() => !File.Exists(manager.PendingPlanPath),
|
||||
TimeSpan.FromSeconds(30),
|
||||
"The self-update journal did not converge.");
|
||||
|
||||
Assert.Equal(
|
||||
prepared.NewCanonicalHash,
|
||||
await FileIntegrity.ComputeSha256HexAsync(prepared.CanonicalPath));
|
||||
Assert.True(File.Exists(Path.Combine(
|
||||
target,
|
||||
LauncherSelfUpdateManager.InstallRecordFileName)));
|
||||
Assert.False(Directory.Exists(manager.GetTransactionDirectory(
|
||||
plan.TransactionId)));
|
||||
Assert.Empty(Directory.EnumerateDirectories(
|
||||
target,
|
||||
".acdream-self-update-*",
|
||||
SearchOption.TopDirectoryOnly));
|
||||
Assert.Null(await manager.LoadPendingAsync());
|
||||
|
||||
int replacementPid = ParsePid(await File.ReadAllTextAsync(launched));
|
||||
int helperPid = int.Parse(
|
||||
await File.ReadAllTextAsync(helperPidPath),
|
||||
System.Globalization.CultureInfo.InvariantCulture);
|
||||
await WaitForProcessExitAsync(replacementPid, TimeSpan.FromSeconds(10));
|
||||
await WaitForProcessExitAsync(helperPid, TimeSpan.FromSeconds(10));
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
Assert.True(
|
||||
(File.GetUnixFileMode(prepared.CanonicalPath)
|
||||
& (UnixFileMode.UserExecute
|
||||
| UnixFileMode.GroupExecute
|
||||
| UnixFileMode.OtherExecute)) != 0);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!crash.HasExited)
|
||||
{
|
||||
crash.Kill(entireProcessTree: true);
|
||||
await crash.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentStartupCannotDeleteAVisibleSlowStageTransaction()
|
||||
{
|
||||
string data = Path.Combine(_root, "data");
|
||||
string target = Path.Combine(_root, "launcher");
|
||||
string resultPath = Path.Combine(_root, "startup.result");
|
||||
Directory.CreateDirectory(target);
|
||||
string rid = LauncherRuntimeIdentity.DetectRid();
|
||||
string canonicalName = LauncherName(rid);
|
||||
string canonical = Path.Combine(target, canonicalName);
|
||||
await File.WriteAllTextAsync(canonical, "running launcher");
|
||||
byte[] archive = UpdateTestData.LauncherZip(
|
||||
rid,
|
||||
new string('s', 2 * 1024 * 1024));
|
||||
using var server = new LocalHttpFixture();
|
||||
server.Add(
|
||||
"slow-launcher.zip",
|
||||
archive,
|
||||
chunkSize: 4096,
|
||||
chunkDelay: TimeSpan.FromMilliseconds(2));
|
||||
var observer = new LauncherSelfUpdateManager(
|
||||
UpdateTestData.Paths(_root),
|
||||
new HttpClient());
|
||||
using Process staging = StartFixture(
|
||||
[
|
||||
"stage-self-update",
|
||||
data,
|
||||
target,
|
||||
"2.0.0",
|
||||
rid,
|
||||
server.UriFor("slow-launcher.zip").AbsoluteUri,
|
||||
UpdateTestData.Sha256(archive),
|
||||
archive.LongLength.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
]);
|
||||
try
|
||||
{
|
||||
await WaitUntilAsync(
|
||||
() => Directory.Exists(observer.TransactionsDirectory)
|
||||
&& Directory.EnumerateDirectories(observer.TransactionsDirectory).Any(),
|
||||
TimeSpan.FromSeconds(10),
|
||||
"The slow staging transaction did not become visible.",
|
||||
staging);
|
||||
Assert.False(File.Exists(observer.PendingPlanPath));
|
||||
string transaction = Assert.Single(
|
||||
Directory.EnumerateDirectories(observer.TransactionsDirectory));
|
||||
|
||||
using Process startup = StartFixture(
|
||||
["bootstrap-probe", data, target, canonical, resultPath]);
|
||||
await startup.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.Equal(0, startup.ExitCode);
|
||||
Assert.Equal("ordinary", await File.ReadAllTextAsync(resultPath));
|
||||
Assert.True(Directory.Exists(transaction));
|
||||
Assert.False(File.Exists(observer.PendingPlanPath));
|
||||
|
||||
await staging.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(30));
|
||||
Assert.Equal(0, staging.ExitCode);
|
||||
SelfUpdatePlan plan = Assert.IsType<SelfUpdatePlan>(
|
||||
await observer.LoadPendingAsync());
|
||||
Assert.Equal(SelfUpdatePlanState.Staged, plan.State);
|
||||
Assert.True(Directory.Exists(transaction));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!staging.HasExited)
|
||||
{
|
||||
staging.Kill(entireProcessTree: true);
|
||||
await staging.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HelperDefersWithoutRestartWhenSharedSessionLeaseAppears()
|
||||
{
|
||||
string data = Path.Combine(_root, "data");
|
||||
string target = Path.Combine(_root, "launcher");
|
||||
string unexpectedLaunch = Path.Combine(_root, "unexpected-launch");
|
||||
string helperPid = Path.Combine(_root, "helper.pid");
|
||||
Directory.CreateDirectory(target);
|
||||
string rid = LauncherRuntimeIdentity.DetectRid();
|
||||
string canonical = Path.Combine(target, LauncherName(rid));
|
||||
await File.WriteAllTextAsync(canonical, "old-launcher");
|
||||
byte[] archive = UpdateTestData.LauncherZip(rid, "new-launcher");
|
||||
using var server = new LocalHttpFixture();
|
||||
server.Add("launcher.zip", archive);
|
||||
using var http = new HttpClient();
|
||||
var manager = new LauncherSelfUpdateManager(UpdateTestData.Paths(_root), http);
|
||||
_ = await manager.StageAsync(
|
||||
LauncherVersion.Parse("2.0.0"),
|
||||
rid,
|
||||
new ReleaseArtifact(
|
||||
server.UriFor("launcher.zip"),
|
||||
UpdateTestData.Sha256(archive),
|
||||
archive.LongLength),
|
||||
target,
|
||||
progress: null,
|
||||
CancellationToken.None);
|
||||
SelfUpdatePlan plan = Assert.IsType<SelfUpdatePlan>(await manager.LoadPendingAsync());
|
||||
using UpdateSessionBarrier.SessionLease session = manager.Barrier.AcquireSession();
|
||||
var environment = new Dictionary<string, string>
|
||||
{
|
||||
[DataEnvironment] = data,
|
||||
[TargetEnvironment] = target,
|
||||
[HelperPidEnvironment] = helperPid,
|
||||
};
|
||||
|
||||
using Process helper = StartFixture(
|
||||
[
|
||||
LauncherSelfUpdateBootstrap.HelperArgument,
|
||||
int.MaxValue.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
target,
|
||||
plan.TransactionId,
|
||||
"canonical-probe",
|
||||
unexpectedLaunch,
|
||||
], environment);
|
||||
await helper.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.Equal(LauncherSelfUpdateBootstrap.DeferredLeaseExitCode, helper.ExitCode);
|
||||
Assert.True(File.Exists(helperPid));
|
||||
Assert.False(File.Exists(unexpectedLaunch));
|
||||
Assert.Equal("old-launcher", await File.ReadAllTextAsync(canonical));
|
||||
SelfUpdatePlan deferred = Assert.IsType<SelfUpdatePlan>(
|
||||
await manager.LoadPendingAsync());
|
||||
Assert.Equal(SelfUpdatePlanState.Staged, deferred.State);
|
||||
Assert.Equal(plan.TransactionId, deferred.TransactionId);
|
||||
}
|
||||
|
||||
private PreparedLauncher PrepareLauncherClosure(string target, string rid)
|
||||
{
|
||||
string fixtureDirectory = GetFixtureDirectory();
|
||||
string fixtureAppHost = Path.Combine(
|
||||
fixtureDirectory,
|
||||
FixtureBaseName + (OperatingSystem.IsWindows() ? ".exe" : string.Empty));
|
||||
Assert.True(File.Exists(fixtureAppHost), $"Missing fixture apphost: {fixtureAppHost}");
|
||||
Directory.CreateDirectory(target);
|
||||
string canonicalName = LauncherName(rid);
|
||||
string canonicalPath = Path.Combine(target, canonicalName);
|
||||
var archiveEntries = new List<(string Name, byte[] Content, int? UnixAttributes)>();
|
||||
foreach (string source in Directory.EnumerateFiles(
|
||||
fixtureDirectory,
|
||||
"*",
|
||||
SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
string sourceName = Path.GetFileName(source);
|
||||
bool isAppHost = PathsEqual(source, fixtureAppHost);
|
||||
string targetName = isAppHost ? canonicalName : sourceName;
|
||||
byte[] oldContent = File.ReadAllBytes(source);
|
||||
byte[] newContent = oldContent;
|
||||
if (isAppHost)
|
||||
{
|
||||
oldContent = [.. oldContent, .. "-old"u8.ToArray()];
|
||||
newContent = [.. newContent, .. "-new"u8.ToArray()];
|
||||
}
|
||||
|
||||
string targetPath = Path.Combine(target, targetName);
|
||||
File.WriteAllBytes(targetPath, oldContent);
|
||||
int unixAttributes = 0x81A4;
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
UnixFileMode mode = File.GetUnixFileMode(source);
|
||||
if (isAppHost)
|
||||
{
|
||||
mode |= UnixFileMode.UserExecute;
|
||||
}
|
||||
|
||||
File.SetUnixFileMode(targetPath, mode);
|
||||
unixAttributes = 0x8000 | (int)mode;
|
||||
}
|
||||
else if (isAppHost)
|
||||
{
|
||||
unixAttributes = 0x81ED;
|
||||
}
|
||||
|
||||
archiveEntries.Add((targetName, newContent, unixAttributes));
|
||||
}
|
||||
|
||||
byte[] archive = UpdateTestData.CreateZip(archiveEntries);
|
||||
byte[] newCanonical = Assert.Single(
|
||||
archiveEntries,
|
||||
entry => entry.Name == canonicalName).Content;
|
||||
return new PreparedLauncher(
|
||||
canonicalName,
|
||||
canonicalPath,
|
||||
archive,
|
||||
UpdateTestData.Sha256(newCanonical));
|
||||
}
|
||||
|
||||
private static Process StartFixture(
|
||||
IReadOnlyList<string> arguments,
|
||||
IReadOnlyDictionary<string, string>? environment = null) =>
|
||||
StartProcess("dotnet", [GetFixtureDllPath(), .. arguments], environment);
|
||||
|
||||
private static Process StartProcess(
|
||||
string executable,
|
||||
IReadOnlyList<string> arguments,
|
||||
IReadOnlyDictionary<string, string>? environment = null)
|
||||
{
|
||||
var start = new ProcessStartInfo(executable)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardOutput = true,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
foreach (string argument in arguments)
|
||||
{
|
||||
start.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
if (environment is not null)
|
||||
{
|
||||
foreach ((string name, string value) in environment)
|
||||
{
|
||||
start.Environment[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return Process.Start(start)
|
||||
?? throw new InvalidOperationException($"Could not start '{executable}'.");
|
||||
}
|
||||
|
||||
private static async Task WaitForFileAsync(
|
||||
string path,
|
||||
Process? process,
|
||||
TimeSpan timeout) =>
|
||||
await WaitUntilAsync(
|
||||
() => File.Exists(path),
|
||||
timeout,
|
||||
$"Timed out waiting for '{path}'.",
|
||||
process);
|
||||
|
||||
private static async Task WaitUntilAsync(
|
||||
Func<bool> condition,
|
||||
TimeSpan timeout,
|
||||
string failure,
|
||||
Process? process = null)
|
||||
{
|
||||
DateTimeOffset deadline = DateTimeOffset.UtcNow + timeout;
|
||||
while (!condition())
|
||||
{
|
||||
if (process?.HasExited == true)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{failure} Process exited {process.ExitCode}. stdout: "
|
||||
+ await process.StandardOutput.ReadToEndAsync()
|
||||
+ " stderr: "
|
||||
+ await process.StandardError.ReadToEndAsync());
|
||||
}
|
||||
|
||||
if (DateTimeOffset.UtcNow >= deadline)
|
||||
{
|
||||
throw new TimeoutException(failure);
|
||||
}
|
||||
|
||||
await Task.Delay(20);
|
||||
}
|
||||
}
|
||||
|
||||
private static int ParsePid(string marker)
|
||||
{
|
||||
string value = marker.Split('|', 2)[0];
|
||||
return int.Parse(value, System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static async Task WaitForProcessExitAsync(int pid, TimeSpan timeout)
|
||||
{
|
||||
try
|
||||
{
|
||||
using Process process = Process.GetProcessById(pid);
|
||||
await process.WaitForExitAsync().WaitAsync(timeout);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
// It exited before the test opened the process handle.
|
||||
}
|
||||
}
|
||||
|
||||
private static string LauncherName(string rid) =>
|
||||
"acdream-launcher"
|
||||
+ (rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty);
|
||||
|
||||
private static string GetFixtureDllPath() =>
|
||||
Path.Combine(GetFixtureDirectory(), FixtureBaseName + ".dll");
|
||||
|
||||
private static string GetFixtureDirectory()
|
||||
{
|
||||
string configuration = new DirectoryInfo(AppContext.BaseDirectory)
|
||||
.Parent?.Name ?? "Release";
|
||||
return Path.Combine(
|
||||
FindRepositoryRoot(),
|
||||
"tests",
|
||||
"AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder",
|
||||
"bin",
|
||||
configuration,
|
||||
"net10.0");
|
||||
}
|
||||
|
||||
private static string FindRepositoryRoot()
|
||||
{
|
||||
for (var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
directory is not null;
|
||||
directory = directory.Parent)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||
{
|
||||
return directory.FullName;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Repository root was not found.");
|
||||
}
|
||||
|
||||
private static bool PathsEqual(string left, string right) => string.Equals(
|
||||
Path.GetFullPath(left),
|
||||
Path.GetFullPath(right),
|
||||
OperatingSystem.IsWindows()
|
||||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal);
|
||||
|
||||
private sealed record PreparedLauncher(
|
||||
string CanonicalName,
|
||||
string CanonicalPath,
|
||||
byte[] NewArchive,
|
||||
string NewCanonicalHash);
|
||||
}
|
||||
|
|
@ -30,7 +30,8 @@ public sealed class LauncherUpdaterIntegrationTests : IDisposable
|
|||
byte[] launcher = UpdateTestData.LauncherZip(_rid, "release-2");
|
||||
ConfigureRelease(server, "2.0.0", "1.0.0", _rid, client, launcher);
|
||||
using var http = new HttpClient();
|
||||
using var source = new ReleaseManifestClient(http, server.UriFor("manifest.json"));
|
||||
using var source = ReleaseManifestClient.CreateLoopbackFixture(
|
||||
server.UriFor("manifest.json"));
|
||||
var versions = new ClientVersionStore(_paths);
|
||||
var updater = new LauncherUpdater(
|
||||
source,
|
||||
|
|
@ -74,7 +75,8 @@ public sealed class LauncherUpdaterIntegrationTests : IDisposable
|
|||
byte[] launcher = UpdateTestData.LauncherZip(_rid);
|
||||
ConfigureRelease(server, "3.0.0", "2.0.0", _rid, client, launcher);
|
||||
using var http = new HttpClient();
|
||||
using var source = new ReleaseManifestClient(http, server.UriFor("manifest.json"));
|
||||
using var source = ReleaseManifestClient.CreateLoopbackFixture(
|
||||
server.UriFor("manifest.json"));
|
||||
var versions = new ClientVersionStore(_paths);
|
||||
var updater = new LauncherUpdater(
|
||||
source,
|
||||
|
|
@ -108,7 +110,8 @@ public sealed class LauncherUpdaterIntegrationTests : IDisposable
|
|||
byte[] launcher = UpdateTestData.LauncherZip(_rid);
|
||||
ConfigureRelease(server, "2.0.0", "1.0.0", _rid, client, launcher);
|
||||
using var http = new HttpClient();
|
||||
using var source = new ReleaseManifestClient(http, server.UriFor("manifest.json"));
|
||||
using var source = ReleaseManifestClient.CreateLoopbackFixture(
|
||||
server.UriFor("manifest.json"));
|
||||
var versions = new ClientVersionStore(_paths);
|
||||
bool running = true;
|
||||
var updater = new LauncherUpdater(
|
||||
|
|
@ -156,7 +159,8 @@ public sealed class LauncherUpdaterIntegrationTests : IDisposable
|
|||
launcher),
|
||||
contentType: "application/json");
|
||||
using var http = new HttpClient();
|
||||
using var source = new ReleaseManifestClient(http, server.UriFor("manifest.json"));
|
||||
using var source = ReleaseManifestClient.CreateLoopbackFixture(
|
||||
server.UriFor("manifest.json"));
|
||||
var versions = new ClientVersionStore(_paths);
|
||||
var updater = new LauncherUpdater(
|
||||
source,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Net;
|
||||
using System.Text;
|
||||
using AcDream.Launcher.Core.Updates;
|
||||
|
||||
|
|
@ -63,7 +64,8 @@ public sealed class ReleaseManifestClientTests
|
|||
launcher),
|
||||
contentType: "application/json");
|
||||
using var http = new HttpClient();
|
||||
using var source = new ReleaseManifestClient(http, server.UriFor("manifest.json"));
|
||||
using var source = ReleaseManifestClient.CreateLoopbackFixture(
|
||||
server.UriFor("manifest.json"));
|
||||
|
||||
ReleaseManifest manifest = await source.FetchAsync();
|
||||
|
||||
|
|
@ -96,16 +98,132 @@ public sealed class ReleaseManifestClientTests
|
|||
ValidJson().Replace("\"size\":12", "\"size\":0"),
|
||||
ValidJson().Replace("\"size\":12", "\"size\":12,\"extra\":true"),
|
||||
ValidJson().Replace("\"version\":\"2.0.0\"", "\"version\":\"2.0.0\",\"version\":\"2.0.1\""),
|
||||
ValidJson().Replace("http://127.0.0.1", "http://example.test"),
|
||||
ValidJson().Replace("https://example.test/client", "http://example.test/client"),
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[InlineData("clients", "client")]
|
||||
[InlineData("launchers", "launcher")]
|
||||
public void ProductionManifestRejectsLoopbackHttpArtifacts(
|
||||
string section,
|
||||
string artifact)
|
||||
{
|
||||
string json = ValidJson().Replace(
|
||||
$"https://example.test/{artifact}",
|
||||
$"http://127.0.0.1/{artifact}",
|
||||
StringComparison.Ordinal);
|
||||
|
||||
LauncherUpdateException error = Assert.Throws<LauncherUpdateException>(() =>
|
||||
ReleaseManifestClient.Parse(Encoding.UTF8.GetBytes(json)));
|
||||
|
||||
Assert.Contains(section, error.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("HTTPS", error.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProductionRedirectToLoopbackIsRejectedBeforePlaintextRequest()
|
||||
{
|
||||
var handler = new SequenceHandler((request, _) => Redirect(
|
||||
HttpStatusCode.Found,
|
||||
new Uri("http://127.0.0.1/manifest.json")));
|
||||
using var source = ReleaseManifestClient.CreateForTransportTest(
|
||||
ReleaseManifestClient.ProductionManifestUri,
|
||||
allowLoopbackHttp: false,
|
||||
handler);
|
||||
|
||||
LauncherUpdateException error = await Assert.ThrowsAsync<LauncherUpdateException>(
|
||||
() => source.FetchAsync());
|
||||
|
||||
Assert.Contains("HTTPS", error.Message, StringComparison.Ordinal);
|
||||
Assert.Equal([ReleaseManifestClient.ProductionManifestUri], handler.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpsRedirectDowngradeIsRejectedBeforeIntermediateHop()
|
||||
{
|
||||
var start = new Uri("https://example.test/start");
|
||||
var handler = new SequenceHandler((request, _) => Redirect(
|
||||
HttpStatusCode.TemporaryRedirect,
|
||||
new Uri("http://example.test/plaintext-hop")));
|
||||
using var source = ReleaseManifestClient.CreateForTransportTest(
|
||||
start,
|
||||
allowLoopbackHttp: false,
|
||||
handler);
|
||||
|
||||
await Assert.ThrowsAsync<LauncherUpdateException>(() => source.FetchAsync());
|
||||
|
||||
Assert.Equal([start], handler.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RedirectLoopIsRejectedWithoutRepeatingARequest()
|
||||
{
|
||||
var first = new Uri("https://example.test/first");
|
||||
var second = new Uri("https://example.test/second");
|
||||
var handler = new SequenceHandler((request, _) => Redirect(
|
||||
HttpStatusCode.PermanentRedirect,
|
||||
request.RequestUri == first ? second : first));
|
||||
using var source = ReleaseManifestClient.CreateForTransportTest(
|
||||
first,
|
||||
allowLoopbackHttp: false,
|
||||
handler);
|
||||
|
||||
LauncherUpdateException error = await Assert.ThrowsAsync<LauncherUpdateException>(
|
||||
() => source.FetchAsync());
|
||||
|
||||
Assert.Contains("loop", error.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal([first, second], handler.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RedirectLimitRejectsBeforeRequestingTheSixthHop()
|
||||
{
|
||||
var start = new Uri("https://example.test/hop-0");
|
||||
var handler = new SequenceHandler((_, index) => Redirect(
|
||||
HttpStatusCode.Found,
|
||||
new Uri($"https://example.test/hop-{index + 1}")));
|
||||
using var source = ReleaseManifestClient.CreateForTransportTest(
|
||||
start,
|
||||
allowLoopbackHttp: false,
|
||||
handler);
|
||||
|
||||
LauncherUpdateException error = await Assert.ThrowsAsync<LauncherUpdateException>(
|
||||
() => source.FetchAsync());
|
||||
|
||||
Assert.Contains("5 redirects", error.Message, StringComparison.Ordinal);
|
||||
Assert.Equal(6, handler.Requests.Count);
|
||||
Assert.Equal(new Uri("https://example.test/hop-5"), handler.Requests[^1]);
|
||||
}
|
||||
|
||||
private static string ValidJson() =>
|
||||
"{\"schemaVersion\":1,\"version\":\"2.0.0\","
|
||||
+ "\"minimumLauncherVersion\":\"1.0.0\","
|
||||
+ "\"clients\":{\"win-x64\":{\"url\":\"http://127.0.0.1/client\","
|
||||
+ $"\"sha256\":\"{new string('a', 64)}\",\"size\":12}},"
|
||||
+ "\"launchers\":{\"win-x64\":{\"url\":\"https://example.test/launcher\","
|
||||
+ $"\"sha256\":\"{new string('b', 64)}\",\"size\":12}}}}";
|
||||
$$$$"""
|
||||
{"schemaVersion":1,"version":"2.0.0","minimumLauncherVersion":"1.0.0","clients":{"win-x64":{"url":"https://example.test/client","sha256":"{{{{new string('a', 64)}}}}","size":12}},"launchers":{"win-x64":{"url":"https://example.test/launcher","sha256":"{{{{new string('b', 64)}}}}","size":12}}}
|
||||
""";
|
||||
|
||||
private static HttpResponseMessage Redirect(HttpStatusCode status, Uri location)
|
||||
{
|
||||
var response = new HttpResponseMessage(status);
|
||||
response.Headers.Location = location;
|
||||
return response;
|
||||
}
|
||||
|
||||
private sealed class SequenceHandler(
|
||||
Func<HttpRequestMessage, int, HttpResponseMessage> respond)
|
||||
: HttpMessageHandler
|
||||
{
|
||||
public List<Uri> Requests { get; } = [];
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Uri uri = request.RequestUri
|
||||
?? throw new InvalidOperationException("Test request has no URI.");
|
||||
int index = Requests.Count;
|
||||
Requests.Add(uri);
|
||||
return Task.FromResult(respond(request, index));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class VerifiedArtifactDownloaderTests : IDisposable
|
||||
|
|
@ -211,9 +329,45 @@ public sealed class VerifiedArtifactDownloaderTests : IDisposable
|
|||
Assert.Equal("preserve", await File.ReadAllTextAsync(destination));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpsArtifactRedirectDowngradeIsRejectedBeforePlaintextHop()
|
||||
{
|
||||
var handler = new RedirectHandler();
|
||||
using var http = new HttpClient(handler);
|
||||
var downloader = new VerifiedArtifactDownloader(http);
|
||||
string destination = Path.Combine(_root, "redirect.zip");
|
||||
|
||||
LauncherUpdateException error = await Assert.ThrowsAsync<LauncherUpdateException>(() =>
|
||||
downloader.DownloadAsync(
|
||||
new ReleaseArtifact(
|
||||
new Uri("https://example.test/artifact"),
|
||||
new string('a', 64),
|
||||
12),
|
||||
destination));
|
||||
|
||||
Assert.Contains("HTTPS", error.Message, StringComparison.Ordinal);
|
||||
Assert.Equal([new Uri("https://example.test/artifact")], handler.Requests);
|
||||
Assert.False(File.Exists(destination));
|
||||
}
|
||||
|
||||
private sealed class ImmediateProgress(Action<ArtifactDownloadProgress> callback)
|
||||
: IProgress<ArtifactDownloadProgress>
|
||||
{
|
||||
public void Report(ArtifactDownloadProgress value) => callback(value);
|
||||
}
|
||||
|
||||
private sealed class RedirectHandler : HttpMessageHandler
|
||||
{
|
||||
public List<Uri> Requests { get; } = [];
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Requests.Add(request.RequestUri!);
|
||||
var response = new HttpResponseMessage(HttpStatusCode.Found);
|
||||
response.Headers.Location = new Uri("http://example.test/plaintext");
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,12 @@ public sealed class SafeZipExtractorTests : IDisposable
|
|||
[InlineData("a/./b")]
|
||||
[InlineData("CON")]
|
||||
[InlineData("aux.txt")]
|
||||
[InlineData("CLOCK$/value")]
|
||||
[InlineData("CONIN$.txt")]
|
||||
[InlineData("CONOUT$/value")]
|
||||
[InlineData("COM¹.dll")]
|
||||
[InlineData("com²/value")]
|
||||
[InlineData("LPT³.log")]
|
||||
[InlineData("trailing.")]
|
||||
[InlineData("trailing ")]
|
||||
public async Task RejectsTraversalRootedAdsAndPortableUnsafeNames(string entry)
|
||||
|
|
@ -66,6 +72,15 @@ public sealed class SafeZipExtractorTests : IDisposable
|
|||
Assert.False(File.Exists(Path.Combine(_root, "escape")));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("CONIN$.txt")]
|
||||
[InlineData("CONOUT$/child")]
|
||||
[InlineData("COM¹.dll")]
|
||||
[InlineData("LPT³/child")]
|
||||
[InlineData("CLOCK$")]
|
||||
public void VersionMetadataUsesTheSameCompletePortableDeviceRules(string path) =>
|
||||
Assert.False(ClientVersionStore.IsNormalizedRelative(path));
|
||||
|
||||
[Fact]
|
||||
public async Task RejectsDuplicateCaseAndFileDirectoryCollisionsBeforeExtraction()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue