fix(launcher): guard orphan bake publication
This commit is contained in:
parent
3f68895120
commit
208a70ac83
14 changed files with 829 additions and 55 deletions
|
|
@ -0,0 +1,46 @@
|
|||
using AcDream.Launcher.Core.Installation;
|
||||
using AcDream.Platform;
|
||||
|
||||
namespace AcDream.Launcher.Core.Tests.Installation;
|
||||
|
||||
public sealed class BakeProcessRunnerTests
|
||||
{
|
||||
[Fact]
|
||||
public void PublicationNonceIsEnvironmentOnlyAndVisibleArgumentsStayPinned()
|
||||
{
|
||||
string nonce = Guid.Parse("01234567-89ab-cdef-0123-456789abcdef")
|
||||
.ToString("N");
|
||||
var request = new BakeProcessRequest(
|
||||
"acdream-bake",
|
||||
"retail-dats",
|
||||
"data/pak/acdream.pak",
|
||||
7,
|
||||
nonce);
|
||||
|
||||
System.Diagnostics.ProcessStartInfo startInfo =
|
||||
SystemBakeProcessRunner.CreateStartInfo(request);
|
||||
|
||||
Assert.Equal(request.Arguments, startInfo.ArgumentList);
|
||||
Assert.DoesNotContain(nonce, startInfo.ArgumentList);
|
||||
Assert.Equal(
|
||||
nonce,
|
||||
startInfo.Environment[
|
||||
BakePublicationGuardPaths.NonceEnvironmentVariable]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnguardedRequestExplicitlyRemovesInheritedAuthorization()
|
||||
{
|
||||
var request = new BakeProcessRequest(
|
||||
"acdream-bake",
|
||||
"retail-dats",
|
||||
"data/pak/acdream.pak",
|
||||
1);
|
||||
|
||||
System.Diagnostics.ProcessStartInfo startInfo =
|
||||
SystemBakeProcessRunner.CreateStartInfo(request);
|
||||
|
||||
Assert.False(startInfo.Environment.ContainsKey(
|
||||
BakePublicationGuardPaths.NonceEnvironmentVariable));
|
||||
}
|
||||
}
|
||||
|
|
@ -85,6 +85,11 @@ public sealed class LauncherInstallerTests : IDisposable
|
|||
"--progress-json",
|
||||
],
|
||||
observedRequest.Arguments);
|
||||
Assert.True(BakePublicationGuardPaths.IsValidNonce(
|
||||
observedRequest.PublicationNonce));
|
||||
Assert.DoesNotContain(
|
||||
observedRequest.PublicationNonce!,
|
||||
observedRequest.Arguments);
|
||||
Assert.Equal(LauncherInstallRecordStore.CurrentBakeToolVersion,
|
||||
result.Record.BakeToolVersion);
|
||||
Assert.Equal(new FileInfo(result.Record.PreparedAssetPath).Length,
|
||||
|
|
@ -94,6 +99,9 @@ public sealed class LauncherInstallerTests : IDisposable
|
|||
result.Record.PreparedAssetSha256);
|
||||
Assert.Contains(progress, value => value.Phase == LauncherInstallPhase.BakingMeshes);
|
||||
Assert.Equal(LauncherInstallPhase.Completed, progress[^1].Phase);
|
||||
Assert.False(File.Exists(
|
||||
BakePublicationGuardPaths.GetAuthorizationPath(
|
||||
result.Record.PreparedAssetPath)));
|
||||
|
||||
var store = new LauncherInstallRecordStore(_paths);
|
||||
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
|
||||
|
|
@ -434,6 +442,7 @@ public sealed class LauncherInstallerTests : IDisposable
|
|||
UseShellExecute = false,
|
||||
};
|
||||
startInfo.ArgumentList.Add(fixtureDll);
|
||||
startInfo.ArgumentList.Add("hold-install-lease");
|
||||
startInfo.ArgumentList.Add(
|
||||
InstallerTransactionLease.GetLockPath(store.DataDirectory));
|
||||
startInfo.ArgumentList.Add(staging);
|
||||
|
|
@ -479,6 +488,144 @@ public sealed class LauncherInstallerTests : IDisposable
|
|||
await File.ReadAllTextAsync(store.PreparedAssetPath));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("holds", 0)]
|
||||
[InlineData("late", 17)]
|
||||
public async Task OrphanBakeCanNeverPublishAfterRestartRecovery(
|
||||
string schedule,
|
||||
int expectedChildExitCode)
|
||||
{
|
||||
var store = new LauncherInstallRecordStore(_paths);
|
||||
LauncherInstallRecord old = await CreatePriorRecordAsync(store);
|
||||
string recordBefore = await File.ReadAllTextAsync(store.RecordPath);
|
||||
string control = Path.Combine(_root, "orphan-" + schedule);
|
||||
Directory.CreateDirectory(control);
|
||||
string ready = Path.Combine(control, "child-ready");
|
||||
string release = Path.Combine(control, "child-release");
|
||||
string childPid = Path.Combine(control, "child-pid");
|
||||
string childExit = Path.Combine(control, "child-exit");
|
||||
string fixtureDll = GetInstallLeaseFixturePath();
|
||||
var startInfo = new ProcessStartInfo("dotnet")
|
||||
{
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardOutput = true,
|
||||
UseShellExecute = false,
|
||||
};
|
||||
foreach (string argument in new[]
|
||||
{
|
||||
fixtureDll,
|
||||
"orphan-parent",
|
||||
store.DataDirectory,
|
||||
_dats,
|
||||
_bakeExecutable,
|
||||
schedule,
|
||||
ready,
|
||||
release,
|
||||
childPid,
|
||||
childExit,
|
||||
})
|
||||
{
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
using Process parent = Process.Start(startInfo)
|
||||
?? throw new InvalidOperationException("Could not start orphan parent.");
|
||||
int orphanPid = 0;
|
||||
try
|
||||
{
|
||||
await WaitForFileAsync(ready, parent, TimeSpan.FromSeconds(15));
|
||||
orphanPid = int.Parse(
|
||||
await File.ReadAllTextAsync(childPid),
|
||||
System.Globalization.CultureInfo.InvariantCulture);
|
||||
Assert.True(File.Exists(
|
||||
LauncherInstallRecordStore.GetBackupPath(
|
||||
store.PreparedAssetPath)));
|
||||
Assert.True(File.Exists(
|
||||
BakePublicationGuardPaths.GetAuthorizationPath(
|
||||
store.PreparedAssetPath)));
|
||||
|
||||
parent.Kill(entireProcessTree: false);
|
||||
await parent.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
|
||||
|
||||
var restarted = new LauncherInstaller(
|
||||
_paths,
|
||||
_bakeExecutable,
|
||||
recordStore: new LauncherInstallRecordStore(_paths));
|
||||
Task<InstallRecordVerification> recovery =
|
||||
restarted.LoadExistingAsync();
|
||||
InstallRecordVerification recovered;
|
||||
if (schedule == "holds")
|
||||
{
|
||||
await Task.Delay(200);
|
||||
Assert.False(recovery.IsCompleted);
|
||||
File.WriteAllText(release, "release");
|
||||
recovered = await recovery.WaitAsync(TimeSpan.FromSeconds(15));
|
||||
}
|
||||
else
|
||||
{
|
||||
recovered = await recovery.WaitAsync(TimeSpan.FromSeconds(15));
|
||||
Assert.False(File.Exists(
|
||||
BakePublicationGuardPaths.GetAuthorizationPath(
|
||||
store.PreparedAssetPath)));
|
||||
File.WriteAllText(release, "release");
|
||||
}
|
||||
|
||||
Assert.True(recovered.IsVerified);
|
||||
Assert.Equal(old, recovered.Record);
|
||||
string canonicalAfterRecovery =
|
||||
await File.ReadAllTextAsync(store.PreparedAssetPath);
|
||||
string recordAfterRecovery =
|
||||
await File.ReadAllTextAsync(store.RecordPath);
|
||||
bool backupAfterRecovery = File.Exists(
|
||||
LauncherInstallRecordStore.GetBackupPath(
|
||||
store.PreparedAssetPath));
|
||||
|
||||
await WaitForFileAsync(childExit, TimeSpan.FromSeconds(15));
|
||||
Assert.Equal(
|
||||
expectedChildExitCode,
|
||||
int.Parse(
|
||||
await File.ReadAllTextAsync(childExit),
|
||||
System.Globalization.CultureInfo.InvariantCulture));
|
||||
if (schedule == "late")
|
||||
{
|
||||
Assert.Contains(
|
||||
"no longer authorized",
|
||||
await File.ReadAllTextAsync(childExit + ".error"),
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
await Task.Delay(200);
|
||||
|
||||
Assert.Equal(
|
||||
canonicalAfterRecovery,
|
||||
await File.ReadAllTextAsync(store.PreparedAssetPath));
|
||||
Assert.Equal("previous verified package", canonicalAfterRecovery);
|
||||
Assert.Equal(recordBefore, recordAfterRecovery);
|
||||
Assert.Equal(recordAfterRecovery, await File.ReadAllTextAsync(store.RecordPath));
|
||||
Assert.Equal(
|
||||
backupAfterRecovery,
|
||||
File.Exists(LauncherInstallRecordStore.GetBackupPath(
|
||||
store.PreparedAssetPath)));
|
||||
Assert.False(backupAfterRecovery);
|
||||
Assert.False(File.Exists(
|
||||
BakePublicationGuardPaths.GetAuthorizationPath(
|
||||
store.PreparedAssetPath)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.WriteAllText(release, "release");
|
||||
if (!parent.HasExited)
|
||||
{
|
||||
parent.Kill(entireProcessTree: false);
|
||||
await parent.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
if (orphanPid != 0 && !File.Exists(childExit))
|
||||
{
|
||||
TryKill(orphanPid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(
|
||||
LauncherInstaller Installer,
|
||||
LauncherInstallRecordStore Store,
|
||||
|
|
@ -548,6 +695,33 @@ public sealed class LauncherInstallerTests : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
private static async Task WaitForFileAsync(string path, TimeSpan timeout)
|
||||
{
|
||||
using var cancellation = new CancellationTokenSource(timeout);
|
||||
while (!File.Exists(path))
|
||||
{
|
||||
await Task.Delay(25, cancellation.Token);
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryKill(int processId)
|
||||
{
|
||||
try
|
||||
{
|
||||
using Process process = Process.GetProcessById(processId);
|
||||
if (!process.HasExited)
|
||||
{
|
||||
process.Kill(entireProcessTree: true);
|
||||
process.WaitForExit(5_000);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// The orphan normally exits by itself; cleanup tolerates the
|
||||
// expected race with Process.GetProcessById.
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetInstallLeaseFixturePath()
|
||||
{
|
||||
string root = FindRepositoryRoot();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue