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

@ -15,10 +15,9 @@ public static class LauncherSelfUpdateBootstrap
{
public const string HelperArgument = "--acdream-self-update-helper-v1";
public const string ConfirmArgument = "--acdream-self-update-confirm-v1";
internal const string DeferredArgument = "--acdream-self-update-deferred-v1";
internal const int DeferredLeaseExitCode = 73;
internal const int UpdateLeaseBusyExitCode = 73;
private const string InternalArgumentPrefix = "--acdream-self-update-";
private static readonly TimeSpan ConfirmationTimeout = TimeSpan.FromSeconds(30);
private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(5);
public static async Task<SelfUpdateStartupResult> HandleAsync(
string[] args,
@ -33,12 +32,6 @@ public static class LauncherSelfUpdateBootstrap
Path.GetFullPath(launcherBaseDirectory));
string executable = Path.GetFullPath(currentExecutablePath);
if (args.Length > 0
&& string.Equals(args[0], DeferredArgument, StringComparison.Ordinal))
{
return new SelfUpdateStartupResult(false, 0, args[1..]);
}
if (args.Length > 0
&& string.Equals(args[0], HelperArgument, StringComparison.Ordinal))
{
@ -55,6 +48,8 @@ public static class LauncherSelfUpdateBootstrap
int exitCode = await RunHelperAsync(
manager,
baseDirectory,
executable,
parentPid,
args[2],
args[3],
@ -72,26 +67,72 @@ public static class LauncherSelfUpdateBootstrap
return new SelfUpdateStartupResult(true, 64, []);
}
if (manager.Barrier.TryAcquireSession(
out UpdateSessionBarrier.SessionLease? unexpectedSharedLease))
{
unexpectedSharedLease?.Dispose();
throw new LauncherUpdateException(
"Self-update confirmation is trusted only while its helper owns "
+ "the exclusive update lease.");
}
await manager.ConfirmAsync(
args[1],
baseDirectory,
executable,
cancellationToken)
.ConfigureAwait(false);
await FinishConfirmedCleanupAsync(
manager,
baseDirectory,
cancellationToken)
.ConfigureAwait(false);
// The helper that owns the exclusive lease observes this durable
// receipt and performs authoritative completion. A later ordinary
// startup also completes it if that helper crashes after receipt.
return new SelfUpdateStartupResult(false, 0, args[2..]);
}
if (args.Length > 0
&& args[0].StartsWith(InternalArgumentPrefix, StringComparison.Ordinal))
{
// Internal modes are an exact vocabulary. In particular, an old
// deferred-restart marker must never become an authorization to
// skip a pending recovery state.
return new SelfUpdateStartupResult(true, 64, []);
}
// Load first: an invalid/ambiguous journal must fail closed even when
// another process currently owns the update barrier.
_ = await manager.LoadPendingAsync(cancellationToken).ConfigureAwait(false);
if (!manager.Barrier.TryAcquireExclusive(
out UpdateSessionBarrier.ExclusiveLease? startupLease))
{
// A running session or another launcher is staging. Reading the
// plan is safe, but cleanup or starting a competing helper is not.
return new SelfUpdateStartupResult(false, 0, args);
if (!manager.Barrier.TryAcquireSession(
out UpdateSessionBarrier.SessionLease? sharedLease))
{
throw new LauncherUpdateException(
"Launcher startup is blocked by an active update or recovery transaction.");
}
using (sharedLease
?? throw new InvalidOperationException("Shared startup lease is missing."))
{
SelfUpdatePlan? blockedPlan = await manager.LoadPendingAsync(cancellationToken)
.ConfigureAwait(false);
if (blockedPlan is null)
{
return new SelfUpdateStartupResult(false, 0, args);
}
ValidateCanonicalStartup(blockedPlan, baseDirectory, executable);
if (blockedPlan.State != SelfUpdatePlanState.Staged)
{
throw new LauncherUpdateException(
$"Self-update state '{blockedPlan.State}' requires exclusive recovery.");
}
// A verified staged update may wait while an already-running
// session holds the shared lease. No helper is spawned, so a
// late session lease cannot create a restart loop.
return new SelfUpdateStartupResult(false, 0, args);
}
}
using (UpdateSessionBarrier.ExclusiveLease lease = startupLease
@ -108,11 +149,7 @@ public static class LauncherSelfUpdateBootstrap
return new SelfUpdateStartupResult(false, 0, args);
}
if (!PathsEqual(plan.TargetDirectory, baseDirectory))
{
throw new LauncherUpdateException(
"The pending self-update targets a different launcher directory.");
}
ValidateCanonicalStartup(plan, baseDirectory, executable);
if (plan.State == SelfUpdatePlanState.AwaitingConfirmation)
{
@ -138,13 +175,40 @@ public static class LauncherSelfUpdateBootstrap
return new SelfUpdateStartupResult(false, 0, args);
}
string expectedExecutable = ClientVersionStore.ResolveContained(
baseDirectory,
GetLauncherFileName(plan.Rid));
if (!PathsEqual(executable, expectedExecutable))
if (plan.State is SelfUpdatePlanState.Applying
or SelfUpdatePlanState.RolledBack)
{
if (plan.State == SelfUpdatePlanState.Applying)
{
plan = await manager.RecoverApplyingAsync(
baseDirectory,
cancellationToken)
.ConfigureAwait(false);
}
if (plan.State != SelfUpdatePlanState.RolledBack)
{
throw new LauncherUpdateException(
"The interrupted self-update did not produce a rollback receipt.");
}
await manager.CompleteRolledBackAsync(
plan.TransactionId,
baseDirectory,
lease,
cancellationToken)
.ConfigureAwait(false);
_ = manager.CleanupOwnedResidueUnderLease(
pending: null,
baseDirectory,
lease);
return new SelfUpdateStartupResult(false, 0, args);
}
if (plan.State != SelfUpdatePlanState.Staged)
{
throw new LauncherUpdateException(
"Self-update can start only from the published acdream-launcher executable.");
$"Self-update state '{plan.State}' cannot start a helper.");
}
string helperPath = manager.GetStagedLauncherPath(plan);
@ -173,6 +237,8 @@ public static class LauncherSelfUpdateBootstrap
private static async Task<int> RunHelperAsync(
LauncherSelfUpdateManager manager,
string helperBaseDirectory,
string currentExecutablePath,
int parentPid,
string targetDirectory,
string transactionId,
@ -182,10 +248,11 @@ public static class LauncherSelfUpdateBootstrap
SelfUpdatePlan plan = await manager.LoadPendingAsync(cancellationToken)
.ConfigureAwait(false)
?? throw new LauncherUpdateException("The helper found no pending self-update.");
if (!string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal))
if (plan.State != SelfUpdatePlanState.Staged
|| !string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal))
{
throw new LauncherUpdateException(
"The helper transaction does not match the pending self-update.");
"The helper mode does not match a staged self-update transaction.");
}
if (!PathsEqual(plan.TargetDirectory, targetDirectory))
@ -194,6 +261,15 @@ public static class LauncherSelfUpdateBootstrap
"The helper target does not match the pending self-update.");
}
string expectedHelperDirectory = manager.GetPayloadDirectory(plan.TransactionId);
string expectedHelperPath = manager.GetStagedLauncherPath(plan);
if (!PathsEqual(helperBaseDirectory, expectedHelperDirectory)
|| !PathsEqual(currentExecutablePath, expectedHelperPath))
{
throw new LauncherUpdateException(
"Self-update helper mode is trusted only from the staged launcher payload.");
}
string launcherPath = ClientVersionStore.ResolveContained(
targetDirectory,
GetLauncherFileName(plan.Rid));
@ -215,9 +291,10 @@ public static class LauncherSelfUpdateBootstrap
{
// Do not restart the canonical launcher: it would immediately see
// the same staged plan and create an unbounded helper loop.
return DeferredLeaseExitCode;
return UpdateLeaseBusyExitCode;
}
ProcessStartInfo? restoredStart = null;
using (UpdateSessionBarrier.ExclusiveLease lease = updateLease
?? throw new InvalidOperationException("Exclusive update lease is missing."))
{
@ -225,7 +302,8 @@ public static class LauncherSelfUpdateBootstrap
.ConfigureAwait(false)
?? throw new LauncherUpdateException(
"The helper found no pending self-update after acquiring the lease.");
if (!string.Equals(
if (plan.State != SelfUpdatePlanState.Staged
|| !string.Equals(
plan.TransactionId,
transactionId,
StringComparison.Ordinal)
@ -315,78 +393,59 @@ public static class LauncherSelfUpdateBootstrap
return 75;
}
var restored = new ProcessStartInfo(launcherPath)
restoredStart = new ProcessStartInfo(launcherPath)
{
UseShellExecute = false,
WorkingDirectory = Path.GetFullPath(targetDirectory),
};
restored.ArgumentList.Add(DeferredArgument);
foreach (string argument in publicArguments)
{
restored.ArgumentList.Add(argument);
restoredStart.ArgumentList.Add(argument);
}
_ = Process.Start(restored);
return 74;
}
finally
{
replacement?.Dispose();
}
}
}
private static async Task FinishConfirmedCleanupAsync(
LauncherSelfUpdateManager manager,
string targetDirectory,
CancellationToken cancellationToken)
{
DateTimeOffset deadline = DateTimeOffset.UtcNow + CleanupTimeout;
do
// Release the helper's exclusive barrier before restarting the
// restored canonical launcher. It will observe the durable RolledBack
// receipt through the ordinary startup path, re-verify it, finalize
// recovery, and continue with no privileged bypass argument.
if (restoredStart is null || Process.Start(restoredStart) is null)
{
cancellationToken.ThrowIfCancellationRequested();
if (manager.Barrier.TryAcquireExclusive(
out UpdateSessionBarrier.ExclusiveLease? lease))
{
using (UpdateSessionBarrier.ExclusiveLease acquiredLease = lease
?? throw new InvalidOperationException(
"Exclusive cleanup lease is missing."))
{
SelfUpdatePlan? pending = await manager.LoadPendingAsync(cancellationToken)
.ConfigureAwait(false);
if (pending is
{
State: SelfUpdatePlanState.AwaitingConfirmation,
}
&& manager.IsConfirmed(pending.TransactionId))
{
await manager.CompleteConfirmedAsync(
pending.TransactionId,
targetDirectory,
cancellationToken)
.ConfigureAwait(false);
pending = null;
}
if (manager.CleanupOwnedResidueUnderLease(
pending,
targetDirectory,
acquiredLease))
{
return;
}
}
}
await Task.Delay(50, cancellationToken).ConfigureAwait(false);
return 75;
}
while (DateTimeOffset.UtcNow < deadline);
return 74;
}
private static string GetLauncherFileName(string rid) =>
"acdream-launcher"
+ (rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty);
private static void ValidateCanonicalStartup(
SelfUpdatePlan plan,
string baseDirectory,
string executable)
{
if (!PathsEqual(plan.TargetDirectory, baseDirectory))
{
throw new LauncherUpdateException(
"The pending self-update targets a different launcher directory.");
}
string expectedExecutable = ClientVersionStore.ResolveContained(
baseDirectory,
GetLauncherFileName(plan.Rid));
if (!PathsEqual(executable, expectedExecutable))
{
throw new LauncherUpdateException(
"Self-update can run only from the published acdream-launcher executable.");
}
}
private static async Task WaitForParentExitAsync(
int parentPid,
CancellationToken cancellationToken)

View file

@ -483,6 +483,39 @@ public sealed class LauncherSelfUpdateManager
SafeZipExtractor.TryDeleteDirectory(GetTransactionDirectory(transactionId));
}
/// <summary>
/// Finalizes a durable rollback only after the prior owned launcher set
/// has been freshly re-verified while the caller holds the update
/// barrier. A failed self-update is abandoned rather than silently
/// re-staged, so an ordinary restart cannot enter an automatic retry
/// loop.
/// </summary>
internal async Task CompleteRolledBackAsync(
string transactionId,
string expectedTargetDirectory,
UpdateSessionBarrier.ExclusiveLease lease,
CancellationToken cancellationToken = default)
{
Barrier.RequireOwned(lease);
string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory);
SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken)
.ConfigureAwait(false)
?? throw new LauncherUpdateException("There is no rolled-back self-update.");
ValidatePlan(plan, expectedTarget);
if (!string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal)
|| plan.State != SelfUpdatePlanState.RolledBack)
{
throw new LauncherUpdateException(
"The self-update does not have the expected rollback receipt.");
}
await VerifyRestoredPriorAsync(plan, expectedTarget, cancellationToken)
.ConfigureAwait(false);
File.Delete(PendingPlanPath);
SafeZipExtractor.TryDeleteDirectory(GetTargetTransactionDirectory(plan));
SafeZipExtractor.TryDeleteDirectory(GetTransactionDirectory(transactionId));
}
public async Task<SelfUpdatePlan> RollbackAwaitingConfirmationAsync(
string expectedTargetDirectory,
CancellationToken cancellationToken = default)

View file

@ -28,6 +28,43 @@ public sealed class UpdateSessionBarrier
return new SessionLease(stream);
}
/// <summary>
/// Non-blocking shared-lease probe used only by launcher startup after an
/// exclusive probe observed contention. Success proves that no updater
/// owns the exclusive lease at that instant; permission and path failures
/// remain hard errors.
/// </summary>
public bool TryAcquireSession(out SessionLease? lease)
{
Directory.CreateDirectory(
Path.GetDirectoryName(_lockPath)
?? throw new InvalidOperationException(
"The update/session lock path has no parent directory."));
try
{
lease = new SessionLease(
new FileStream(
_lockPath,
FileMode.OpenOrCreate,
FileAccess.ReadWrite,
FileShare.ReadWrite,
bufferSize: 1,
FileOptions.None));
return true;
}
catch (IOException)
{
lease = null;
return false;
}
catch (UnauthorizedAccessException ex)
{
throw new LauncherUpdateException(
$"The update/session lease could not be opened: {ex.Message}",
ex);
}
}
public ExclusiveLease AcquireExclusive()
{
FileStream stream = Open(

View file

@ -9,7 +9,6 @@ internal enum LauncherStartupMode
VerifyPublish,
SelfUpdateHelper,
SelfUpdateConfirmation,
SelfUpdateDeferred,
}
/// <summary>
@ -19,12 +18,6 @@ internal enum LauncherStartupMode
/// </summary>
internal sealed class LauncherStartupOptions
{
// This prefix is consumed only after LauncherSelfUpdateBootstrap has
// already decided to continue after a recovered rollback. Keep it local
// so the process-level bootstrap can remain internal to Launcher.Core.
private const string DeferredSelfUpdateArgument =
"--acdream-self-update-deferred-v1";
private readonly IReadOnlyList<string> _publicArguments;
private LauncherStartupOptions(
@ -213,14 +206,6 @@ internal sealed class LauncherStartupOptions
arguments.Count >= 2 ? 2 : arguments.Count);
}
if (string.Equals(
arguments[0],
DeferredSelfUpdateArgument,
StringComparison.Ordinal))
{
return (LauncherStartupMode.SelfUpdateDeferred, 1);
}
return (LauncherStartupMode.Desktop, 0);
}