fix(launcher): harden Campaign LA11 gate evidence

This commit is contained in:
Erik 2026-08-15 01:08:41 +02:00
parent 134edabed2
commit accd01a008
16 changed files with 1820 additions and 210 deletions

View file

@ -27,7 +27,7 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
}
[Fact]
public async Task KilledAfterCanonicalReplaceCanInvokeCanonicalAndConvergeAutomatically()
public async Task KilledApplyingPlanRecoversPriorAndContinuesCanonicalWithoutRetryLoop()
{
string data = Path.Combine(_root, "data");
string target = Path.Combine(_root, "launcher");
@ -94,13 +94,21 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
"The self-update journal did not converge.");
Assert.Equal(
prepared.NewCanonicalHash,
oldHash,
await FileIntegrity.ComputeSha256HexAsync(prepared.CanonicalPath));
Assert.True(File.Exists(Path.Combine(
Assert.False(File.Exists(Path.Combine(
target,
LauncherSelfUpdateManager.InstallRecordFileName)));
Assert.False(Directory.Exists(manager.GetTransactionDirectory(
plan.TransactionId)));
using (UpdateSessionBarrier.ExclusiveLease cleanupLease =
manager.Barrier.AcquireExclusive())
{
Assert.True(manager.CleanupOwnedResidueUnderLease(
pending: null,
target,
cleanupLease));
}
Assert.Empty(Directory.EnumerateDirectories(
target,
".acdream-self-update-*",
@ -113,11 +121,8 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
launchMarker,
StringComparison.Ordinal);
int replacementPid = ParsePid(launchMarker);
int helperPid = int.Parse(
await File.ReadAllTextAsync(helperPidPath),
System.Globalization.CultureInfo.InvariantCulture);
await WaitForProcessExitAsync(replacementPid, TimeSpan.FromSeconds(10));
await WaitForProcessExitAsync(helperPid, TimeSpan.FromSeconds(10));
Assert.False(File.Exists(helperPidPath));
if (OperatingSystem.IsLinux())
{
Assert.True(
@ -156,13 +161,8 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
["canonical-probe", launched],
BootstrapEnvironment(crashed, helperPidPath));
await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20));
Assert.Equal(0, canonical.ExitCode);
await WaitForFileAsync(helperPidPath, process: null, TimeSpan.FromSeconds(20));
await WaitForProcessExitAsync(
int.Parse(
await File.ReadAllTextAsync(helperPidPath),
System.Globalization.CultureInfo.InvariantCulture),
TimeSpan.FromSeconds(20));
Assert.NotEqual(0, canonical.ExitCode);
Assert.False(File.Exists(helperPidPath));
Assert.False(File.Exists(launched));
SelfUpdatePlan preserved = Assert.IsType<SelfUpdatePlan>(
@ -204,13 +204,8 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
["canonical-probe", launched],
BootstrapEnvironment(crashed, helperPidPath));
await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20));
Assert.Equal(0, canonical.ExitCode);
await WaitForFileAsync(helperPidPath, process: null, TimeSpan.FromSeconds(20));
await WaitForProcessExitAsync(
int.Parse(
await File.ReadAllTextAsync(helperPidPath),
System.Globalization.CultureInfo.InvariantCulture),
TimeSpan.FromSeconds(20));
Assert.NotEqual(0, canonical.ExitCode);
Assert.False(File.Exists(helperPidPath));
Assert.False(File.Exists(launched));
Assert.True(File.Exists(outsideCanonical));
@ -297,8 +292,8 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
["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.NotEqual(0, startup.ExitCode);
Assert.False(File.Exists(resultPath));
Assert.True(Directory.Exists(transaction));
Assert.False(File.Exists(observer.PendingPlanPath));
@ -328,9 +323,9 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
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");
PreparedLauncher prepared = PrepareLauncherClosure(target, rid);
string canonical = prepared.CanonicalPath;
byte[] archive = prepared.NewArchive;
using var server = new LocalHttpFixture();
server.Add("launcher.zip", archive);
using var http = new HttpClient();
@ -354,7 +349,8 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
[HelperPidEnvironment] = helperPid,
};
using Process helper = StartFixture(
using Process helper = StartProcess(
manager.GetStagedLauncherPath(plan),
[
LauncherSelfUpdateBootstrap.HelperArgument,
int.MaxValue.ToString(System.Globalization.CultureInfo.InvariantCulture),
@ -365,16 +361,151 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
], environment);
await helper.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
Assert.Equal(LauncherSelfUpdateBootstrap.DeferredLeaseExitCode, helper.ExitCode);
string helperError = await helper.StandardError.ReadToEndAsync();
string helperOutput = await helper.StandardOutput.ReadToEndAsync();
Assert.True(
helper.ExitCode == LauncherSelfUpdateBootstrap.UpdateLeaseBusyExitCode,
$"helper exit {helper.ExitCode}; stdout: {helperOutput}; stderr: {helperError}");
Assert.True(File.Exists(helperPid));
Assert.False(File.Exists(unexpectedLaunch));
Assert.Equal("old-launcher", await File.ReadAllTextAsync(canonical));
Assert.NotEqual(
prepared.NewCanonicalHash,
await FileIntegrity.ComputeSha256HexAsync(canonical));
SelfUpdatePlan deferred = Assert.IsType<SelfUpdatePlan>(
await manager.LoadPendingAsync());
Assert.Equal(SelfUpdatePlanState.Staged, deferred.State);
Assert.Equal(plan.TransactionId, deferred.TransactionId);
}
[Fact]
public async Task SpoofedInternalPrefixesCannotBypassAnyDurablePlanState()
{
string data = Path.Combine(_root, "data");
string target = Path.Combine(_root, "launcher");
string rid = LauncherRuntimeIdentity.DetectRid();
PreparedLauncher prepared = PrepareLauncherClosure(target, rid);
using var server = new LocalHttpFixture();
server.Add("launcher.zip", prepared.NewArchive);
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(prepared.NewArchive),
prepared.NewArchive.LongLength),
target,
progress: null,
CancellationToken.None);
SelfUpdatePlan plan = Assert.IsType<SelfUpdatePlan>(await manager.LoadPendingAsync());
var environment = new Dictionary<string, string>
{
[DataEnvironment] = data,
[TargetEnvironment] = target,
};
await AssertInternalSpoofsRejectedAsync(
prepared.CanonicalPath,
target,
plan.TransactionId,
environment,
"staged");
plan = await manager.ApplyPendingAsync(target);
await SetPlanStateAsync(manager.PendingPlanPath, "applying");
await AssertInternalSpoofsRejectedAsync(
prepared.CanonicalPath,
target,
plan.TransactionId,
environment,
"applying");
plan = await manager.RecoverApplyingAsync(target);
plan = await manager.ApplyPendingAsync(target);
Assert.Equal(SelfUpdatePlanState.AwaitingConfirmation, plan.State);
await AssertInternalSpoofsRejectedAsync(
prepared.CanonicalPath,
target,
plan.TransactionId,
environment,
"awaitingConfirmation");
plan = await manager.RollbackAwaitingConfirmationAsync(target);
await AssertInternalSpoofsRejectedAsync(
prepared.CanonicalPath,
target,
plan.TransactionId,
environment,
"rolledBack");
await File.WriteAllTextAsync(manager.PendingPlanPath, "{ambiguous");
await AssertInternalSpoofsRejectedAsync(
prepared.CanonicalPath,
target,
plan.TransactionId,
environment,
"ambiguous");
}
private static async Task AssertInternalSpoofsRejectedAsync(
string canonicalPath,
string targetDirectory,
string transactionId,
IReadOnlyDictionary<string, string> environment,
string state)
{
(string Name, string[] Arguments, int? ExactExit)[] attempts =
[
(
"deferred",
["--acdream-self-update-deferred-v1"],
64),
(
"helper",
[
LauncherSelfUpdateBootstrap.HelperArgument,
int.MaxValue.ToString(
System.Globalization.CultureInfo.InvariantCulture),
targetDirectory,
transactionId,
],
null),
(
"confirm",
[LauncherSelfUpdateBootstrap.ConfirmArgument, transactionId],
null),
];
foreach ((string name, string[] arguments, int? exactExit) in attempts)
{
using Process process = StartProcess(canonicalPath, arguments, environment);
await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
string stderr = await process.StandardError.ReadToEndAsync();
if (exactExit.HasValue)
{
Assert.True(
process.ExitCode == exactExit.Value,
$"{state}/{name} exited {process.ExitCode}: {stderr}");
}
else
{
Assert.True(
process.ExitCode != 0,
$"{state}/{name} unexpectedly succeeded.");
}
}
}
private static async Task SetPlanStateAsync(string path, string state)
{
System.Text.Json.Nodes.JsonObject plan = Assert.IsType<
System.Text.Json.Nodes.JsonObject>(
System.Text.Json.Nodes.JsonNode.Parse(await File.ReadAllTextAsync(path)));
plan["state"] = state;
await File.WriteAllTextAsync(path, plan.ToJsonString());
}
private PreparedLauncher PrepareLauncherClosure(string target, string rid)
{
string fixtureDirectory = GetFixtureDirectory();