acdream/src/AcDream.Launcher.Core/Installation/BakePublicationGuardContract.cs

122 lines
3.7 KiB
C#

using AcDream.Platform;
namespace AcDream.Launcher.Core.Installation;
/// <summary>
/// Launcher half of the environment-only Bake publication guard. Paths are
/// derived from the canonical output path, while a durable GUID nonce grants
/// one child permission to promote its already-validated adjacent staging
/// file. Every token mutation happens while the stable publication lock is
/// held.
/// </summary>
internal static class BakePublicationGuardContract
{
private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(50);
internal static async ValueTask<PublicationLease> AcquireAsync(
string outputPath,
CancellationToken cancellationToken = default,
Action? contentionObserved = null)
{
string lockPath = BakePublicationGuardPaths.GetPublishLockPath(
outputPath);
Directory.CreateDirectory(
Path.GetDirectoryName(lockPath)
?? throw new InvalidOperationException(
"The bake publication lock has no parent directory."));
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
return new PublicationLease(new FileStream(
lockPath,
FileMode.OpenOrCreate,
FileAccess.ReadWrite,
FileShare.None,
bufferSize: 1,
options: FileOptions.None));
}
catch (IOException)
{
contentionObserved?.Invoke();
await Task.Delay(RetryDelay, cancellationToken)
.ConfigureAwait(false);
}
}
}
internal static void Authorize(
string outputPath,
string nonce,
PublicationLease lease)
{
ArgumentNullException.ThrowIfNull(lease);
if (!BakePublicationGuardPaths.IsValidNonce(nonce))
{
throw new ArgumentException(
"The bake publication nonce must be a lowercase GUID in N format.",
nameof(nonce));
}
string authorizationPath =
BakePublicationGuardPaths.GetAuthorizationPath(outputPath);
using var stream = new FileStream(
authorizationPath,
FileMode.Create,
FileAccess.Write,
FileShare.None,
bufferSize: 4096,
options: FileOptions.WriteThrough);
using var writer = new StreamWriter(stream, leaveOpen: true);
writer.Write(nonce);
writer.Flush();
stream.Flush(flushToDisk: true);
}
internal static void Invalidate(
string outputPath,
PublicationLease lease,
string? onlyIfNonceMatches = null)
{
ArgumentNullException.ThrowIfNull(lease);
string authorizationPath =
BakePublicationGuardPaths.GetAuthorizationPath(outputPath);
if (!File.Exists(authorizationPath))
{
return;
}
if (onlyIfNonceMatches is not null)
{
string current = File.ReadAllText(authorizationPath);
if (!string.Equals(
current,
onlyIfNonceMatches,
StringComparison.Ordinal))
{
return;
}
}
File.Delete(authorizationPath);
}
internal sealed class PublicationLease : IAsyncDisposable
{
private readonly FileStream _stream;
internal PublicationLease(FileStream stream)
{
_stream = stream;
}
public ValueTask DisposeAsync()
{
_stream.Dispose();
return ValueTask.CompletedTask;
}
}
}