fix(launcher): verify self-update rollback sources
This commit is contained in:
parent
1955ca8ab5
commit
09d84387a8
6 changed files with 949 additions and 103 deletions
|
|
@ -1,4 +1,5 @@
|
|||
using AcDream.Launcher.Core.Updates;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace AcDream.Launcher.Core.Tests.Updates;
|
||||
|
||||
|
|
@ -103,8 +104,16 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable
|
|||
SelfUpdatePlan rolledBack = await harness.Manager
|
||||
.RollbackAwaitingConfirmationAsync(harness.Target);
|
||||
|
||||
Assert.Equal(SelfUpdatePlanState.Staged, rolledBack.State);
|
||||
Assert.Null(rolledBack.Apply);
|
||||
Assert.Equal(SelfUpdatePlanState.RolledBack, rolledBack.State);
|
||||
Assert.All(rolledBack.Apply!, entry =>
|
||||
{
|
||||
if (entry.HadOriginal)
|
||||
{
|
||||
Assert.Matches("^[0-9a-f]{64}$", entry.PriorSha256!);
|
||||
Assert.NotNull(entry.PriorSize);
|
||||
Assert.NotNull(entry.PriorUnixMode);
|
||||
}
|
||||
});
|
||||
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);
|
||||
|
|
@ -150,7 +159,7 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable
|
|||
SelfUpdatePlan rolledBack = await harness.Manager
|
||||
.RollbackAwaitingConfirmationAsync(harness.Target);
|
||||
|
||||
Assert.Equal(SelfUpdatePlanState.Staged, rolledBack.State);
|
||||
Assert.Equal(SelfUpdatePlanState.RolledBack, 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));
|
||||
|
|
@ -170,7 +179,7 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyFailpointAfterCanonicalAtomicReplaceRollsBackToStagedState()
|
||||
public async Task ApplyFailpointAfterCanonicalAtomicReplaceLeavesVerifiedRollbackReceipt()
|
||||
{
|
||||
using var harness = new Harness(_root);
|
||||
_ = await harness.StageAsync();
|
||||
|
|
@ -193,7 +202,8 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable
|
|||
await harness.Manager.LoadPendingAsync());
|
||||
|
||||
Assert.Equal("failpoint", failure.Message);
|
||||
Assert.Equal(SelfUpdatePlanState.Staged, recovered.State);
|
||||
Assert.Equal(SelfUpdatePlanState.RolledBack, recovered.State);
|
||||
await harness.Manager.VerifyRestoredPriorAsync(harness.Target);
|
||||
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(
|
||||
|
|
@ -201,6 +211,41 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable
|
|||
harness.LauncherName)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConditionalPriorIntegrityFieldsAreStrictAndFailClosed()
|
||||
{
|
||||
using var harness = new Harness(_root);
|
||||
_ = await harness.StageAsync();
|
||||
SelfUpdatePlan applied = await harness.Manager.ApplyPendingAsync(harness.Target);
|
||||
SelfUpdateApplyEntry canonical = Assert.Single(
|
||||
applied.Apply!,
|
||||
entry => entry.Path == harness.LauncherName);
|
||||
Assert.True(canonical.HadOriginal);
|
||||
Assert.Matches("^[0-9a-f]{64}$", canonical.PriorSha256!);
|
||||
Assert.NotNull(canonical.PriorSize);
|
||||
Assert.NotNull(canonical.PriorUnixMode);
|
||||
Assert.Matches("^[0-9a-f]{64}$", canonical.ReplacementSha256!);
|
||||
|
||||
JsonObject document = Assert.IsType<JsonObject>(JsonNode.Parse(
|
||||
await File.ReadAllTextAsync(harness.Manager.PendingPlanPath)));
|
||||
JsonArray apply = Assert.IsType<JsonArray>(document["apply"]);
|
||||
JsonObject canonicalNode = Assert.IsType<JsonObject>(apply.Single(node =>
|
||||
string.Equals(
|
||||
node?["path"]?.GetValue<string>(),
|
||||
harness.LauncherName,
|
||||
StringComparison.Ordinal)));
|
||||
canonicalNode["priorSha256"] = null;
|
||||
await File.WriteAllTextAsync(
|
||||
harness.Manager.PendingPlanPath,
|
||||
document.ToJsonString());
|
||||
|
||||
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
|
||||
harness.Manager.LoadPendingAsync());
|
||||
Assert.Equal("new-launcher", await File.ReadAllTextAsync(harness.LauncherPath));
|
||||
Assert.True(Directory.Exists(
|
||||
harness.Manager.GetTargetTransactionDirectory(applied)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CorruptPayloadWrongTargetAndUnknownPlanFieldFailClosed()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -125,6 +125,116 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CorruptBackupAfterCanonicalCrashNeverLaunchesAndPreservesEvidence()
|
||||
{
|
||||
CrashedUpdate crashed = await PrepareKilledAfterCanonicalReplaceAsync();
|
||||
string backupPath = Path.Combine(
|
||||
crashed.Manager.GetTargetTransactionDirectory(crashed.Plan),
|
||||
"backup",
|
||||
crashed.Prepared.CanonicalName);
|
||||
Assert.True(File.Exists(backupPath));
|
||||
await File.WriteAllTextAsync(backupPath, "tampered rollback backup");
|
||||
string tamperedHash = await FileIntegrity.ComputeSha256HexAsync(backupPath);
|
||||
string launched = Path.Combine(_root, "corrupt-backup-launched");
|
||||
string helperPidPath = Path.Combine(_root, "corrupt-backup-helper.pid");
|
||||
|
||||
using Process canonical = StartProcess(
|
||||
crashed.Prepared.CanonicalPath,
|
||||
["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.False(File.Exists(launched));
|
||||
SelfUpdatePlan preserved = Assert.IsType<SelfUpdatePlan>(
|
||||
await crashed.Manager.LoadPendingAsync());
|
||||
Assert.Equal(SelfUpdatePlanState.Applying, preserved.State);
|
||||
Assert.Equal(crashed.Plan.TransactionId, preserved.TransactionId);
|
||||
Assert.True(Directory.Exists(
|
||||
crashed.Manager.GetTargetTransactionDirectory(crashed.Plan)));
|
||||
Assert.Equal(tamperedHash, await FileIntegrity.ComputeSha256HexAsync(backupPath));
|
||||
Assert.Equal(
|
||||
crashed.Prepared.NewCanonicalHash,
|
||||
await FileIntegrity.ComputeSha256HexAsync(crashed.Prepared.CanonicalPath));
|
||||
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
|
||||
crashed.Manager.VerifyRestoredPriorAsync(crashed.Target));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackupJunctionOrSymlinkAfterCanonicalCrashCannotMutateOutsideOrLaunch()
|
||||
{
|
||||
CrashedUpdate crashed = await PrepareKilledAfterCanonicalReplaceAsync();
|
||||
string swap = crashed.Manager.GetTargetTransactionDirectory(crashed.Plan);
|
||||
string backup = Path.Combine(swap, "backup");
|
||||
string preservedBackup = Path.Combine(_root, "preserved-backup");
|
||||
string outside = Path.Combine(_root, "outside-backup");
|
||||
Directory.Move(backup, preservedBackup);
|
||||
CopyDirectory(preservedBackup, outside);
|
||||
string outsideCanonical = Path.Combine(
|
||||
outside,
|
||||
crashed.Prepared.CanonicalName);
|
||||
string outsideHash = await FileIntegrity.ComputeSha256HexAsync(outsideCanonical);
|
||||
CreateDirectoryLink(backup, outside);
|
||||
string launched = Path.Combine(_root, "reparse-backup-launched");
|
||||
string helperPidPath = Path.Combine(_root, "reparse-backup-helper.pid");
|
||||
|
||||
try
|
||||
{
|
||||
using Process canonical = StartProcess(
|
||||
crashed.Prepared.CanonicalPath,
|
||||
["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.False(File.Exists(launched));
|
||||
Assert.True(File.Exists(outsideCanonical));
|
||||
Assert.Equal(
|
||||
outsideHash,
|
||||
await FileIntegrity.ComputeSha256HexAsync(outsideCanonical));
|
||||
Assert.True(
|
||||
(File.GetAttributes(backup) & FileAttributes.ReparsePoint) != 0);
|
||||
SelfUpdatePlan preserved = Assert.IsType<SelfUpdatePlan>(
|
||||
await crashed.Manager.LoadPendingAsync());
|
||||
Assert.Equal(SelfUpdatePlanState.Applying, preserved.State);
|
||||
Assert.Equal(
|
||||
crashed.Prepared.NewCanonicalHash,
|
||||
await FileIntegrity.ComputeSha256HexAsync(
|
||||
crashed.Prepared.CanonicalPath));
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if ((File.GetAttributes(backup) & FileAttributes.ReparsePoint) != 0)
|
||||
{
|
||||
Directory.Delete(backup);
|
||||
}
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
// The assertion above reports an unexpected missing link.
|
||||
}
|
||||
catch (DirectoryNotFoundException)
|
||||
{
|
||||
// The assertion above reports an unexpected missing link.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentStartupCannotDeleteAVisibleSlowStageTransaction()
|
||||
{
|
||||
|
|
@ -313,6 +423,109 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
|
|||
UpdateTestData.Sha256(newCanonical));
|
||||
}
|
||||
|
||||
private async Task<CrashedUpdate> PrepareKilledAfterCanonicalReplaceAsync()
|
||||
{
|
||||
string data = Path.Combine(_root, "data");
|
||||
string target = Path.Combine(_root, "launcher");
|
||||
string ready = Path.Combine(_root, "crash.ready");
|
||||
Directory.CreateDirectory(_root);
|
||||
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());
|
||||
using Process crash = StartFixture(
|
||||
["crash-self-update", data, target, ready, prepared.CanonicalName]);
|
||||
await WaitForFileAsync(ready, crash, TimeSpan.FromSeconds(20));
|
||||
crash.Kill(entireProcessTree: true);
|
||||
await crash.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
|
||||
Assert.Equal(SelfUpdatePlanState.Applying,
|
||||
Assert.IsType<SelfUpdatePlan>(await manager.LoadPendingAsync()).State);
|
||||
return new CrashedUpdate(data, target, manager, plan, prepared);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BootstrapEnvironment(
|
||||
CrashedUpdate crashed,
|
||||
string helperPidPath) => new()
|
||||
{
|
||||
[DataEnvironment] = crashed.Data,
|
||||
[TargetEnvironment] = crashed.Target,
|
||||
[HelperPidEnvironment] = helperPidPath,
|
||||
};
|
||||
|
||||
private static void CopyDirectory(string source, string destination)
|
||||
{
|
||||
Directory.CreateDirectory(destination);
|
||||
foreach (string directory in Directory.EnumerateDirectories(
|
||||
source,
|
||||
"*",
|
||||
SearchOption.AllDirectories))
|
||||
{
|
||||
Directory.CreateDirectory(Path.Combine(
|
||||
destination,
|
||||
Path.GetRelativePath(source, directory)));
|
||||
}
|
||||
|
||||
foreach (string file in Directory.EnumerateFiles(
|
||||
source,
|
||||
"*",
|
||||
SearchOption.AllDirectories))
|
||||
{
|
||||
string target = Path.Combine(destination, Path.GetRelativePath(source, file));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
|
||||
File.Copy(file, target);
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
File.SetUnixFileMode(target, File.GetUnixFileMode(file));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void CreateDirectoryLink(string link, string target)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
Directory.CreateSymbolicLink(link, target);
|
||||
return;
|
||||
}
|
||||
|
||||
var start = new ProcessStartInfo("cmd.exe")
|
||||
{
|
||||
UseShellExecute = false,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardOutput = true,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
start.ArgumentList.Add("/d");
|
||||
start.ArgumentList.Add("/c");
|
||||
start.ArgumentList.Add("mklink");
|
||||
start.ArgumentList.Add("/J");
|
||||
start.ArgumentList.Add(link);
|
||||
start.ArgumentList.Add(target);
|
||||
using Process process = Process.Start(start)
|
||||
?? throw new InvalidOperationException("Could not create the test junction.");
|
||||
process.WaitForExit();
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Could not create the test junction: "
|
||||
+ process.StandardError.ReadToEnd()
|
||||
+ process.StandardOutput.ReadToEnd());
|
||||
}
|
||||
}
|
||||
|
||||
private static Process StartFixture(
|
||||
IReadOnlyList<string> arguments,
|
||||
IReadOnlyDictionary<string, string>? environment = null) =>
|
||||
|
|
@ -450,4 +663,11 @@ public sealed class LauncherSelfUpdateProcessTests : IDisposable
|
|||
string CanonicalPath,
|
||||
byte[] NewArchive,
|
||||
string NewCanonicalHash);
|
||||
|
||||
private sealed record CrashedUpdate(
|
||||
string Data,
|
||||
string Target,
|
||||
LauncherSelfUpdateManager Manager,
|
||||
SelfUpdatePlan Plan,
|
||||
PreparedLauncher Prepared);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue