acdream/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs

786 lines
29 KiB
C#

using System.Text.Json;
using System.Text.Json.Serialization;
using AcDream.Platform;
namespace AcDream.Launcher.Core.Updates;
public enum SelfUpdatePlanState
{
Staged,
Applying,
AwaitingConfirmation,
}
public sealed record SelfUpdateApplyEntry(string Path, bool HadOriginal);
public sealed record SelfUpdatePlan(
int SchemaVersion,
string TransactionId,
SelfUpdatePlanState State,
string Version,
string Rid,
string TargetDirectory,
string ArchiveSha256,
long ArchiveSize,
IReadOnlyList<InstalledFileRecord> Files,
IReadOnlyList<SelfUpdateApplyEntry>? Apply)
{
public const int CurrentSchemaVersion = 1;
}
public sealed record SelfUpdateStageResult(
LauncherVersion Version,
string PendingPlanPath,
string Status);
/// <summary>
/// Durable self-update transaction owner. It stages a verified launcher ZIP;
/// a separately copied helper performs move-only replacement after the parent
/// exits and can replay rollback after a crash at any file boundary.
/// </summary>
public sealed class LauncherSelfUpdateManager
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = false,
WriteIndented = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
MaxDepth = 32,
Converters = { new JsonStringEnumConverter<SelfUpdatePlanState>(
JsonNamingPolicy.CamelCase,
allowIntegerValues: false) },
};
private readonly VerifiedArtifactDownloader _downloader;
private readonly SafeZipExtractor _extractor;
public LauncherSelfUpdateManager(
ApplicationPathSet paths,
HttpClient httpClient,
SafeZipExtractor? extractor = null)
{
ArgumentNullException.ThrowIfNull(paths);
RootDirectory = Path.Combine(
Path.GetFullPath(paths.DataDirectory),
"launcher-update");
TransactionsDirectory = Path.Combine(RootDirectory, "transactions");
PendingPlanPath = Path.Combine(RootDirectory, "pending.json");
Barrier = new UpdateSessionBarrier(paths.DataDirectory);
_downloader = new VerifiedArtifactDownloader(
httpClient ?? throw new ArgumentNullException(nameof(httpClient)));
_extractor = extractor ?? new SafeZipExtractor();
}
public string RootDirectory { get; }
public string TransactionsDirectory { get; }
public string PendingPlanPath { get; }
public UpdateSessionBarrier Barrier { get; }
public async Task<SelfUpdateStageResult> StageAsync(
ReleaseManifest manifest,
string rid,
string targetDirectory,
IProgress<ArtifactDownloadProgress>? progress = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(manifest);
ReleaseArtifact artifact = manifest.RequireLauncher(rid);
return await StageAsync(
manifest.Version,
rid,
artifact,
targetDirectory,
progress,
cancellationToken)
.ConfigureAwait(false);
}
internal async Task<SelfUpdateStageResult> StageAsync(
LauncherVersion version,
string rid,
ReleaseArtifact artifact,
string targetDirectory,
IProgress<ArtifactDownloadProgress>? progress,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(version);
ArgumentNullException.ThrowIfNull(artifact);
if (!LauncherRuntimeIdentity.IsValidRid(rid))
{
throw new ArgumentException("RID is invalid.", nameof(rid));
}
string target = NormalizeTargetDirectory(targetDirectory);
Directory.CreateDirectory(RootDirectory);
Directory.CreateDirectory(TransactionsDirectory);
SelfUpdatePlan? existing = await LoadPendingAsync(cancellationToken)
.ConfigureAwait(false);
if (existing is not null)
{
throw new LauncherUpdateException(
$"Launcher self-update {existing.Version} is already {existing.State}. "
+ "Restart the launcher to finish it before staging another.");
}
string transactionId = Guid.NewGuid().ToString("N");
string transactionDirectory = GetTransactionDirectory(transactionId);
string payloadDirectory = GetPayloadDirectory(transactionId);
string archivePath = Path.Combine(transactionDirectory, "launcher.zip");
Directory.CreateDirectory(transactionDirectory);
try
{
_ = await _downloader.DownloadAsync(
artifact,
archivePath,
progress,
cancellationToken)
.ConfigureAwait(false);
IReadOnlyList<ExtractedFileRecord> extracted = await _extractor.ExtractAsync(
archivePath,
payloadDirectory,
cancellationToken)
.ConfigureAwait(false);
ClientVersionStore.ValidateRequiredExecutables(
extracted,
rid,
launcherPayload: true);
var plan = new SelfUpdatePlan(
SelfUpdatePlan.CurrentSchemaVersion,
transactionId,
SelfUpdatePlanState.Staged,
version.Value,
rid,
target,
artifact.Sha256.ToLowerInvariant(),
artifact.Size,
extracted.Select(file => new InstalledFileRecord(
file.Path,
file.Sha256,
file.Size,
file.UnixMode))
.OrderBy(file => file.Path, StringComparer.Ordinal)
.ToArray(),
null);
ValidatePlan(plan, target);
await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false);
VerifiedArtifactDownloader.TryDelete(archivePath);
return new SelfUpdateStageResult(
version,
PendingPlanPath,
$"Launcher {version} is staged and will be applied on next start.");
}
catch
{
if (!File.Exists(PendingPlanPath))
{
SafeZipExtractor.TryDeleteDirectory(transactionDirectory);
}
throw;
}
}
public async Task<SelfUpdatePlan?> LoadPendingAsync(
CancellationToken cancellationToken = default)
{
if (!File.Exists(PendingPlanPath))
{
CleanupOwnedResidue(keepTransactionId: null);
return null;
}
try
{
byte[] bytes = await File.ReadAllBytesAsync(PendingPlanPath, cancellationToken)
.ConfigureAwait(false);
SelfUpdatePlan? plan = ClientVersionStore.ParseStrict<SelfUpdatePlan>(
bytes,
SerializerOptions);
if (plan is null)
{
throw new LauncherUpdateException("The self-update plan is empty.");
}
ValidatePlan(plan, plan.TargetDirectory);
CleanupOwnedResidue(plan.TransactionId);
return plan;
}
catch (OperationCanceledException)
{
throw;
}
catch (LauncherUpdateException)
{
throw;
}
catch (Exception ex) when (ex is IOException
or UnauthorizedAccessException
or JsonException
or FormatException
or NotSupportedException)
{
throw new LauncherUpdateException(
$"The pending launcher self-update is invalid: {ex.Message}",
ex);
}
}
public async Task<SelfUpdatePlan> ApplyPendingAsync(
string expectedTargetDirectory,
CancellationToken cancellationToken = default)
{
string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory);
SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken)
.ConfigureAwait(false)
?? throw new LauncherUpdateException("There is no staged launcher self-update.");
ValidatePlan(plan, expectedTarget);
if (plan.State == SelfUpdatePlanState.AwaitingConfirmation)
{
return plan;
}
if (plan.State == SelfUpdatePlanState.Applying)
{
plan = await RollbackApplyingAsync(plan, cancellationToken)
.ConfigureAwait(false);
}
await VerifyPayloadAsync(plan, cancellationToken).ConfigureAwait(false);
var apply = new List<SelfUpdateApplyEntry>(plan.Files.Count);
foreach (InstalledFileRecord file in plan.Files)
{
string targetPath = ClientVersionStore.ResolveContained(
expectedTarget,
file.Path);
if (Directory.Exists(targetPath))
{
throw new LauncherUpdateException(
$"Self-update target '{file.Path}' is unexpectedly a directory.");
}
apply.Add(new SelfUpdateApplyEntry(file.Path, File.Exists(targetPath)));
}
plan = plan with
{
State = SelfUpdatePlanState.Applying,
Apply = apply,
};
await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false);
string payload = GetPayloadDirectory(plan.TransactionId);
string backup = GetBackupDirectory(plan.TransactionId);
try
{
foreach (SelfUpdateApplyEntry entry in plan.Apply)
{
cancellationToken.ThrowIfCancellationRequested();
string stagedPath = ClientVersionStore.ResolveContained(payload, entry.Path);
string targetPath = ClientVersionStore.ResolveContained(expectedTarget, entry.Path);
string backupPath = ClientVersionStore.ResolveContained(backup, entry.Path);
EnsureSafeParent(expectedTarget, targetPath);
Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!);
if (entry.HadOriginal)
{
File.Move(targetPath, backupPath);
}
File.Move(stagedPath, targetPath);
InstalledFileRecord file = plan.Files.Single(candidate =>
string.Equals(candidate.Path, entry.Path, StringComparison.Ordinal));
if (OperatingSystem.IsLinux() && file.UnixMode != 0)
{
File.SetUnixFileMode(targetPath, (UnixFileMode)file.UnixMode);
}
}
plan = plan with { State = SelfUpdatePlanState.AwaitingConfirmation };
await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false);
return plan;
}
catch
{
await RollbackApplyingAsync(plan, CancellationToken.None)
.ConfigureAwait(false);
throw;
}
}
public async Task<SelfUpdatePlan> RecoverApplyingAsync(
string expectedTargetDirectory,
CancellationToken cancellationToken = default)
{
string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory);
SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken)
.ConfigureAwait(false)
?? throw new LauncherUpdateException("There is no pending self-update.");
ValidatePlan(plan, expectedTarget);
return plan.State == SelfUpdatePlanState.Applying
? await RollbackApplyingAsync(plan, cancellationToken).ConfigureAwait(false)
: plan;
}
public async Task ConfirmAsync(
string transactionId,
string expectedTargetDirectory,
string currentExecutablePath,
CancellationToken cancellationToken = default)
{
SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken)
.ConfigureAwait(false)
?? throw new LauncherUpdateException("There is no self-update to confirm.");
string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory);
ValidatePlan(plan, expectedTarget);
if (!string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal)
|| plan.State != SelfUpdatePlanState.AwaitingConfirmation)
{
throw new LauncherUpdateException(
"The running launcher does not match the pending confirmation plan.");
}
string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal)
? ".exe"
: string.Empty;
string expectedExecutable = ClientVersionStore.ResolveContained(
expectedTarget,
"acdream-launcher" + suffix);
if (!PathsEqual(expectedExecutable, currentExecutablePath))
{
throw new LauncherUpdateException(
"Only the newly installed launcher executable may confirm self-update.");
}
await VerifyAppliedTargetsAsync(plan, expectedTarget, cancellationToken)
.ConfigureAwait(false);
string confirmationPath = GetConfirmationPath(transactionId);
await AtomicJsonFile.WriteBytesAsync(
confirmationPath,
"confirmed"u8.ToArray(),
cancellationToken)
.ConfigureAwait(false);
}
public bool IsConfirmed(string transactionId) =>
File.Exists(GetConfirmationPath(transactionId));
public async Task CompleteConfirmedAsync(
string transactionId,
string expectedTargetDirectory,
CancellationToken cancellationToken = default)
{
SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken)
.ConfigureAwait(false)
?? throw new LauncherUpdateException("There is no self-update to complete.");
ValidatePlan(plan, NormalizeTargetDirectory(expectedTargetDirectory));
if (!string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal)
|| plan.State != SelfUpdatePlanState.AwaitingConfirmation
|| !IsConfirmed(transactionId))
{
throw new LauncherUpdateException("The self-update is not confirmed.");
}
File.Delete(PendingPlanPath);
SafeZipExtractor.TryDeleteDirectory(GetTransactionDirectory(transactionId));
}
public async Task<SelfUpdatePlan> RollbackAwaitingConfirmationAsync(
string expectedTargetDirectory,
CancellationToken cancellationToken = default)
{
string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory);
SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken)
.ConfigureAwait(false)
?? throw new LauncherUpdateException("There is no self-update to roll back.");
ValidatePlan(plan, expectedTarget);
if (plan.State != SelfUpdatePlanState.AwaitingConfirmation)
{
throw new LauncherUpdateException(
"The pending self-update is not awaiting confirmation.");
}
plan = plan with { State = SelfUpdatePlanState.Applying };
await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false);
return await RollbackApplyingAsync(plan, cancellationToken)
.ConfigureAwait(false);
}
public string GetTransactionDirectory(string transactionId)
{
RequireTransactionId(transactionId);
return Path.Combine(TransactionsDirectory, transactionId);
}
public string GetPayloadDirectory(string transactionId) =>
Path.Combine(GetTransactionDirectory(transactionId), "payload");
public string GetBackupDirectory(string transactionId) =>
Path.Combine(GetTransactionDirectory(transactionId), "backup");
public string GetConfirmationPath(string transactionId) =>
Path.Combine(GetTransactionDirectory(transactionId), "confirmed");
public string GetHelperPath(string transactionId)
{
string suffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty;
return Path.Combine(
GetTransactionDirectory(transactionId),
"acdream-self-update-helper" + suffix);
}
private async Task<SelfUpdatePlan> RollbackApplyingAsync(
SelfUpdatePlan plan,
CancellationToken cancellationToken)
{
if (plan.State != SelfUpdatePlanState.Applying || plan.Apply is null)
{
throw new LauncherUpdateException("The self-update rollback journal is missing.");
}
string payload = GetPayloadDirectory(plan.TransactionId);
string backup = GetBackupDirectory(plan.TransactionId);
foreach (SelfUpdateApplyEntry entry in plan.Apply.Reverse())
{
cancellationToken.ThrowIfCancellationRequested();
string stagedPath = ClientVersionStore.ResolveContained(payload, entry.Path);
string targetPath = ClientVersionStore.ResolveContained(
plan.TargetDirectory,
entry.Path);
string backupPath = ClientVersionStore.ResolveContained(backup, entry.Path);
if (!File.Exists(stagedPath) && File.Exists(targetPath))
{
Directory.CreateDirectory(Path.GetDirectoryName(stagedPath)!);
File.Move(targetPath, stagedPath);
}
if (entry.HadOriginal && File.Exists(backupPath))
{
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
File.Move(backupPath, targetPath);
}
else if (!entry.HadOriginal && File.Exists(targetPath))
{
File.Delete(targetPath);
}
}
SafeZipExtractor.TryDeleteDirectory(backup);
plan = plan with
{
State = SelfUpdatePlanState.Staged,
Apply = null,
};
await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false);
await VerifyPayloadAsync(plan, cancellationToken).ConfigureAwait(false);
return plan;
}
private async Task VerifyPayloadAsync(
SelfUpdatePlan plan,
CancellationToken cancellationToken)
{
string payload = GetPayloadDirectory(plan.TransactionId);
if (!Directory.Exists(payload))
{
throw new LauncherUpdateException("The staged launcher payload is missing.");
}
ClientVersionStore.RejectReparseTree(payload);
string[] actual = Directory.EnumerateFiles(
payload,
"*",
SearchOption.AllDirectories)
.Select(path => Path.GetRelativePath(payload, path).Replace('\\', '/'))
.OrderBy(path => path, StringComparer.Ordinal)
.ToArray();
string[] expected = plan.Files
.Select(file => file.Path)
.OrderBy(path => path, StringComparer.Ordinal)
.ToArray();
if (!actual.SequenceEqual(expected, StringComparer.Ordinal))
{
throw new LauncherUpdateException(
"The staged launcher contains missing or unrecorded files.");
}
foreach (InstalledFileRecord file in plan.Files)
{
cancellationToken.ThrowIfCancellationRequested();
string path = ClientVersionStore.ResolveContained(payload, file.Path);
var info = new FileInfo(path);
if (!info.Exists || info.Length != file.Size)
{
throw new LauncherUpdateException(
$"Staged launcher file '{file.Path}' size is corrupt.");
}
string sha256 = await Integrity.FileIntegrity.ComputeSha256HexAsync(
path,
cancellationToken)
.ConfigureAwait(false);
if (!string.Equals(sha256, file.Sha256, StringComparison.OrdinalIgnoreCase))
{
throw new LauncherUpdateException(
$"Staged launcher file '{file.Path}' SHA-256 is corrupt.");
}
}
}
private static async Task VerifyAppliedTargetsAsync(
SelfUpdatePlan plan,
string targetDirectory,
CancellationToken cancellationToken)
{
foreach (InstalledFileRecord file in plan.Files)
{
cancellationToken.ThrowIfCancellationRequested();
string path = ClientVersionStore.ResolveContained(targetDirectory, file.Path);
EnsureSafeParent(targetDirectory, path);
var info = new FileInfo(path);
if (!info.Exists
|| (info.Attributes & FileAttributes.ReparsePoint) != 0
|| info.Length != file.Size)
{
throw new LauncherUpdateException(
$"Applied launcher file '{file.Path}' is missing, linked, or corrupt.");
}
string sha256 = await Integrity.FileIntegrity.ComputeSha256HexAsync(
path,
cancellationToken)
.ConfigureAwait(false);
if (!string.Equals(sha256, file.Sha256, StringComparison.OrdinalIgnoreCase))
{
throw new LauncherUpdateException(
$"Applied launcher file '{file.Path}' SHA-256 is corrupt.");
}
if (OperatingSystem.IsLinux()
&& ((int)File.GetUnixFileMode(path) & 0x1FF) != file.UnixMode)
{
throw new LauncherUpdateException(
$"Applied launcher file '{file.Path}' mode is corrupt.");
}
}
}
private async Task WritePlanAsync(
SelfUpdatePlan plan,
CancellationToken cancellationToken)
{
ValidatePlan(plan, plan.TargetDirectory);
await AtomicJsonFile.WriteAsync(
PendingPlanPath,
plan,
SerializerOptions,
cancellationToken)
.ConfigureAwait(false);
}
private void ValidatePlan(SelfUpdatePlan plan, string expectedTargetDirectory)
{
if (plan.SchemaVersion != SelfUpdatePlan.CurrentSchemaVersion)
{
throw new LauncherUpdateException(
$"Self-update schema version {plan.SchemaVersion} is not supported.");
}
RequireTransactionId(plan.TransactionId);
if (!LauncherVersion.TryParse(plan.Version, out _)
|| !LauncherRuntimeIdentity.IsValidRid(plan.Rid)
|| !ReleaseManifestClient.IsSha256(plan.ArchiveSha256)
|| plan.ArchiveSize <= 0
|| plan.ArchiveSize > ReleaseManifestClient.MaximumArtifactBytes)
{
throw new LauncherUpdateException("The self-update plan metadata is invalid.");
}
string target = NormalizeTargetDirectory(plan.TargetDirectory);
if (!PathsEqual(target, expectedTargetDirectory))
{
throw new LauncherUpdateException(
"The self-update target does not match the running launcher directory.");
}
if (plan.Files is null || plan.Files.Count == 0)
{
throw new LauncherUpdateException("The self-update file list is empty.");
}
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
string? prior = null;
foreach (InstalledFileRecord file in plan.Files)
{
if (!ClientVersionStore.IsNormalizedRelative(file.Path)
|| !paths.Add(file.Path)
|| !ReleaseManifestClient.IsSha256(file.Sha256)
|| file.Size < 0
|| file.UnixMode is < 0 or > 0x1FF
|| (prior is not null
&& string.Compare(prior, file.Path, StringComparison.Ordinal) >= 0))
{
throw new LauncherUpdateException(
"The self-update file list is invalid, duplicated, or unsorted.");
}
prior = file.Path;
}
string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal)
? ".exe"
: string.Empty;
if (!paths.Contains("acdream-launcher" + suffix))
{
throw new LauncherUpdateException(
"The self-update plan lacks the launcher root executable.");
}
if (plan.State == SelfUpdatePlanState.Staged && plan.Apply is not null
|| plan.State != SelfUpdatePlanState.Staged && plan.Apply is null)
{
throw new LauncherUpdateException(
"The self-update apply journal does not match its state.");
}
if (plan.Apply is not null)
{
if (plan.Apply.Count != plan.Files.Count
|| !plan.Apply.Select(entry => entry.Path)
.SequenceEqual(plan.Files.Select(file => file.Path), StringComparer.Ordinal))
{
throw new LauncherUpdateException(
"The self-update apply journal does not match the file list.");
}
}
string transactionDirectory = GetTransactionDirectory(plan.TransactionId);
if (!IsContained(TransactionsDirectory, transactionDirectory))
{
throw new LauncherUpdateException("The self-update transaction path escaped.");
}
}
private static string NormalizeTargetDirectory(string targetDirectory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(targetDirectory);
if (!Path.IsPathFullyQualified(targetDirectory))
{
throw new LauncherUpdateException(
"The self-update target directory must be absolute.");
}
string target = Path.TrimEndingDirectorySeparator(Path.GetFullPath(targetDirectory));
if (!Directory.Exists(target)
|| (File.GetAttributes(target) & FileAttributes.ReparsePoint) != 0)
{
throw new LauncherUpdateException(
"The self-update target directory is missing or is a reparse point.");
}
return target;
}
private static void EnsureSafeParent(string root, string filePath)
{
string? parent = Path.GetDirectoryName(filePath);
if (parent is null)
{
throw new LauncherUpdateException("A self-update target has no parent.");
}
Directory.CreateDirectory(parent);
for (var directory = new DirectoryInfo(parent);
directory is not null && IsContained(root, directory.FullName);
directory = directory.Parent)
{
if ((directory.Attributes & FileAttributes.ReparsePoint) != 0)
{
throw new LauncherUpdateException(
$"Self-update target parent '{directory.FullName}' is a reparse point.");
}
if (PathsEqual(directory.FullName, root))
{
break;
}
}
}
private static bool IsContained(string root, string path)
{
string fullRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root));
string fullPath = Path.GetFullPath(path);
return PathsEqual(fullRoot, fullPath)
|| fullPath.StartsWith(
fullRoot + Path.DirectorySeparatorChar,
OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal);
}
private static bool PathsEqual(string left, string right) =>
string.Equals(
Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)),
Path.TrimEndingDirectorySeparator(Path.GetFullPath(right)),
OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal);
private static void RequireTransactionId(string transactionId)
{
if (transactionId.Length != 32
|| !Guid.TryParseExact(transactionId, "N", out Guid parsed)
|| !string.Equals(parsed.ToString("N"), transactionId, StringComparison.Ordinal))
{
throw new LauncherUpdateException("The self-update transaction id is invalid.");
}
}
private void CleanupOwnedResidue(string? keepTransactionId)
{
if (Directory.Exists(TransactionsDirectory))
{
foreach (string directory in Directory.EnumerateDirectories(
TransactionsDirectory,
"*",
SearchOption.TopDirectoryOnly))
{
string name = Path.GetFileName(directory);
if (name.Length == 32
&& Guid.TryParseExact(name, "N", out Guid transaction)
&& string.Equals(
transaction.ToString("N"),
name,
StringComparison.Ordinal)
&& !string.Equals(name, keepTransactionId, StringComparison.Ordinal))
{
SafeZipExtractor.TryDeleteDirectory(directory);
}
}
}
if (!Directory.Exists(RootDirectory))
{
return;
}
foreach (string temporary in Directory.EnumerateFiles(
RootDirectory,
".pending.json.*.tmp",
SearchOption.TopDirectoryOnly))
{
string name = Path.GetFileName(temporary);
string prefix = ".pending.json.";
string transaction = name[prefix.Length..^".tmp".Length];
if (Guid.TryParseExact(transaction, "N", out _))
{
VerifiedArtifactDownloader.TryDelete(temporary);
}
}
}
}