fix(launcher): harden Campaign LA11 gate evidence
This commit is contained in:
parent
134edabed2
commit
accd01a008
16 changed files with 1820 additions and 210 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue