feat(launcher): implement verified atomic updates
This commit is contained in:
parent
2198a0cc8e
commit
2d2a5b5046
34 changed files with 6755 additions and 61 deletions
|
|
@ -0,0 +1,279 @@
|
|||
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",
|
||||
"target & $(literal)-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_root))
|
||||
{
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartupWithoutPlanReclaimsOnlyExactOwnedResidue()
|
||||
{
|
||||
using var harness = new Harness(_root);
|
||||
string orphan = harness.Manager.GetTransactionDirectory(Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(orphan);
|
||||
await File.WriteAllTextAsync(Path.Combine(orphan, "partial"), "partial");
|
||||
Directory.CreateDirectory(harness.Manager.RootDirectory);
|
||||
string temporary = Path.Combine(
|
||||
harness.Manager.RootDirectory,
|
||||
$".pending.json.{Guid.NewGuid():N}.tmp");
|
||||
string unrelated = Path.Combine(harness.Manager.RootDirectory, "pending.user.tmp");
|
||||
await File.WriteAllTextAsync(temporary, "partial");
|
||||
await File.WriteAllTextAsync(unrelated, "preserve");
|
||||
|
||||
Assert.Null(await harness.Manager.LoadPendingAsync());
|
||||
|
||||
Assert.False(Directory.Exists(orphan));
|
||||
Assert.False(File.Exists(temporary));
|
||||
Assert.True(File.Exists(unrelated));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifiedStageIsDurableAndDoesNotTouchRunningTarget()
|
||||
{
|
||||
using var harness = new Harness(_root);
|
||||
|
||||
SelfUpdateStageResult result = await harness.StageAsync();
|
||||
SelfUpdatePlan plan = Assert.IsType<SelfUpdatePlan>(
|
||||
await harness.Manager.LoadPendingAsync());
|
||||
|
||||
Assert.Equal("2.0.0", result.Version.Value);
|
||||
Assert.Equal(SelfUpdatePlanState.Staged, plan.State);
|
||||
Assert.Null(plan.Apply);
|
||||
Assert.Equal("old-launcher", await File.ReadAllTextAsync(harness.LauncherPath));
|
||||
Assert.Equal("new-launcher", await File.ReadAllTextAsync(
|
||||
Path.Combine(harness.Manager.GetPayloadDirectory(plan.TransactionId), harness.LauncherName)));
|
||||
string pendingJson = await File.ReadAllTextAsync(result.PendingPlanPath);
|
||||
Assert.Contains("\"state\": \"staged\"", pendingJson, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("\"state\": \"Staged\"", pendingJson, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("cmd", pendingJson,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyConfirmAndCompletionUseMoveJournalAndRemoveTransaction()
|
||||
{
|
||||
using var harness = new Harness(_root);
|
||||
_ = await harness.StageAsync();
|
||||
|
||||
SelfUpdatePlan applied = await harness.Manager.ApplyPendingAsync(harness.Target);
|
||||
|
||||
Assert.Equal(SelfUpdatePlanState.AwaitingConfirmation, applied.State);
|
||||
Assert.NotNull(applied.Apply);
|
||||
Assert.Equal("new-launcher", await File.ReadAllTextAsync(harness.LauncherPath));
|
||||
Assert.Equal("support-new-launcher", await File.ReadAllTextAsync(harness.SupportPath));
|
||||
await harness.Manager.ConfirmAsync(
|
||||
applied.TransactionId,
|
||||
harness.Target,
|
||||
harness.LauncherPath);
|
||||
Assert.True(harness.Manager.IsConfirmed(applied.TransactionId));
|
||||
|
||||
await harness.Manager.CompleteConfirmedAsync(applied.TransactionId, harness.Target);
|
||||
|
||||
Assert.False(File.Exists(harness.Manager.PendingPlanPath));
|
||||
Assert.False(Directory.Exists(
|
||||
harness.Manager.GetTransactionDirectory(applied.TransactionId)));
|
||||
Assert.Equal("new-launcher", await File.ReadAllTextAsync(harness.LauncherPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AwaitingConfirmationRollbackRestoresEveryOldFileAndCanRetry()
|
||||
{
|
||||
using var harness = new Harness(_root);
|
||||
_ = await harness.StageAsync();
|
||||
SelfUpdatePlan applied = await harness.Manager.ApplyPendingAsync(harness.Target);
|
||||
|
||||
SelfUpdatePlan rolledBack = await harness.Manager
|
||||
.RollbackAwaitingConfirmationAsync(harness.Target);
|
||||
|
||||
Assert.Equal(SelfUpdatePlanState.Staged, rolledBack.State);
|
||||
Assert.Null(rolledBack.Apply);
|
||||
Assert.Equal("old-launcher", await File.ReadAllTextAsync(harness.LauncherPath));
|
||||
Assert.Equal("old-support", await File.ReadAllTextAsync(harness.SupportPath));
|
||||
SelfUpdatePlan retried = await harness.Manager.ApplyPendingAsync(harness.Target);
|
||||
Assert.Equal(SelfUpdatePlanState.AwaitingConfirmation, retried.State);
|
||||
Assert.Equal("new-launcher", await File.ReadAllTextAsync(harness.LauncherPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CrashDuringApplyingReplaysReverseJournalToStagedState()
|
||||
{
|
||||
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
|
||||
{
|
||||
State = SelfUpdatePlanState.Applying,
|
||||
Apply = apply,
|
||||
};
|
||||
await File.WriteAllTextAsync(
|
||||
harness.Manager.PendingPlanPath,
|
||||
JsonSerializer.Serialize(applying, PlanOptions));
|
||||
|
||||
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);
|
||||
|
||||
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.LauncherName)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CorruptPayloadWrongTargetAndUnknownPlanFieldFailClosed()
|
||||
{
|
||||
using var harness = new Harness(_root);
|
||||
_ = await harness.StageAsync();
|
||||
SelfUpdatePlan staged = Assert.IsType<SelfUpdatePlan>(
|
||||
await harness.Manager.LoadPendingAsync());
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(
|
||||
harness.Manager.GetPayloadDirectory(staged.TransactionId),
|
||||
harness.LauncherName),
|
||||
"bad-payload!");
|
||||
|
||||
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
|
||||
harness.Manager.ApplyPendingAsync(harness.Target));
|
||||
Assert.Equal("old-launcher", await File.ReadAllTextAsync(harness.LauncherPath));
|
||||
string wrongTarget = Path.Combine(_root, "other-target");
|
||||
Directory.CreateDirectory(wrongTarget);
|
||||
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
|
||||
harness.Manager.ApplyPendingAsync(wrongTarget));
|
||||
|
||||
string json = await File.ReadAllTextAsync(harness.Manager.PendingPlanPath);
|
||||
await File.WriteAllTextAsync(
|
||||
harness.Manager.PendingPlanPath,
|
||||
json.TrimEnd().TrimEnd('}') + ",\"unknown\":true}");
|
||||
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
|
||||
harness.Manager.LoadPendingAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BootstrapConfirmationAndOrdinaryStartupDoNotUseShellParsing()
|
||||
{
|
||||
using var harness = new Harness(_root);
|
||||
SelfUpdateStartupResult ordinary = await LauncherSelfUpdateBootstrap.HandleAsync(
|
||||
["--literal", "argument with spaces & metacharacters"],
|
||||
harness.Manager,
|
||||
harness.Target,
|
||||
harness.LauncherPath);
|
||||
Assert.False(ordinary.ShouldExit);
|
||||
Assert.Equal(["--literal", "argument with spaces & metacharacters"],
|
||||
ordinary.RemainingArguments);
|
||||
|
||||
_ = await harness.StageAsync();
|
||||
SelfUpdatePlan applied = await harness.Manager.ApplyPendingAsync(harness.Target);
|
||||
SelfUpdateStartupResult confirmation = await LauncherSelfUpdateBootstrap.HandleAsync(
|
||||
[LauncherSelfUpdateBootstrap.ConfirmArgument, applied.TransactionId],
|
||||
harness.Manager,
|
||||
harness.Target,
|
||||
harness.LauncherPath);
|
||||
|
||||
Assert.False(confirmation.ShouldExit);
|
||||
Assert.Empty(confirmation.RemainingArguments);
|
||||
Assert.True(harness.Manager.IsConfirmed(applied.TransactionId));
|
||||
await harness.Manager.CompleteConfirmedAsync(applied.TransactionId, harness.Target);
|
||||
}
|
||||
|
||||
private sealed class Harness : IDisposable
|
||||
{
|
||||
private readonly LocalHttpFixture _server = new();
|
||||
private readonly HttpClient _http = new();
|
||||
private readonly byte[] _archive;
|
||||
private readonly ReleaseArtifact _artifact;
|
||||
|
||||
public Harness(string root)
|
||||
{
|
||||
Target = Path.Combine(root, "published launcher");
|
||||
Directory.CreateDirectory(Target);
|
||||
Rid = LauncherRuntimeIdentity.DetectRid();
|
||||
LauncherName = "acdream-launcher"
|
||||
+ (Rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty);
|
||||
LauncherPath = Path.Combine(Target, LauncherName);
|
||||
SupportPath = Path.Combine(Target, "support.dat");
|
||||
File.WriteAllText(LauncherPath, "old-launcher");
|
||||
File.WriteAllText(SupportPath, "old-support");
|
||||
_archive = UpdateTestData.LauncherZip(Rid, "new-launcher");
|
||||
_server.Add("launcher.zip", _archive);
|
||||
_artifact = new ReleaseArtifact(
|
||||
_server.UriFor("launcher.zip"),
|
||||
UpdateTestData.Sha256(_archive),
|
||||
_archive.LongLength);
|
||||
Manager = new LauncherSelfUpdateManager(UpdateTestData.Paths(root), _http);
|
||||
}
|
||||
|
||||
public string Target { get; }
|
||||
|
||||
public string Rid { get; }
|
||||
|
||||
public string LauncherName { get; }
|
||||
|
||||
public string LauncherPath { get; }
|
||||
|
||||
public string SupportPath { get; }
|
||||
|
||||
public LauncherSelfUpdateManager Manager { get; }
|
||||
|
||||
public Task<SelfUpdateStageResult> StageAsync() => Manager.StageAsync(
|
||||
LauncherVersion.Parse("2.0.0"),
|
||||
Rid,
|
||||
_artifact,
|
||||
Target,
|
||||
progress: null,
|
||||
CancellationToken.None);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_http.Dispose();
|
||||
_server.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue