feat: bind activation receipts to immutable content
This commit is contained in:
@@ -17,7 +17,7 @@ public sealed record ValidationResult(IReadOnlyList<ValidationIssue> Issues)
|
|||||||
{ public bool IsValid => Issues.Count == 0; public static ValidationResult Success { get; } = new([]); }
|
{ public bool IsValid => Issues.Count == 0; public static ValidationResult Success { get; } = new([]); }
|
||||||
public sealed record PackageProgress(string Stage, int Completed, int Total);
|
public sealed record PackageProgress(string Stage, int Completed, int Total);
|
||||||
public sealed record ActivationResult(string CurrentVersion, string? PreviousVersion);
|
public sealed record ActivationResult(string CurrentVersion, string? PreviousVersion);
|
||||||
public sealed record PackageActivationReceipt(string OperationId, string PackageId, string Version, string? PreviousVersion, DateTimeOffset ActivatedUtc, long PointerGeneration = 0, string? ActiveRootIdentity = null);
|
public sealed record PackageActivationReceipt(string OperationId, string PackageId, string Version, string? PreviousVersion, DateTimeOffset ActivatedUtc, long PointerGeneration = 0, string? ActiveRootIdentity = null, int SchemaVersion = 1, string? ManifestSha256 = null, string? PayloadSetSha256 = null, string? RosterIdentity = null);
|
||||||
public sealed record PackageActivationResult(string Outcome, PackageActivationReceipt? Receipt);
|
public sealed record PackageActivationResult(string Outcome, PackageActivationReceipt? Receipt);
|
||||||
public sealed record PackageRollbackResult(string Outcome, string? CurrentVersion);
|
public sealed record PackageRollbackResult(string Outcome, string? CurrentVersion);
|
||||||
public sealed record ImmutableLocalPackage(string PackageId, string Version, string LocalPackageRoot);
|
public sealed record ImmutableLocalPackage(string PackageId, string Version, string LocalPackageRoot);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ public sealed class LocalPackageStore(string root, IFileHasher hasher) : ILocalP
|
|||||||
{
|
{
|
||||||
// Machine-process local serialization only; package roots must remain local.
|
// Machine-process local serialization only; package roots must remain local.
|
||||||
private static readonly SemaphoreSlim PointerGate = new(1, 1);
|
private static readonly SemaphoreSlim PointerGate = new(1, 1);
|
||||||
private sealed record Pointer(string Current, string? Previous, int SchemaVersion = 1, long Generation = 0, string? ActivationOperationId = null, DateTimeOffset? ActivatedAtUtc = null);
|
private sealed record Pointer(string Current, string? Previous, int SchemaVersion = 1, long Generation = 0, string? ActivationOperationId = null, DateTimeOffset? ActivatedAtUtc = null, string? ManifestSha256 = null, string? PayloadSetSha256 = null);
|
||||||
private sealed record CacheBinding(string PackageId, string Product, int ProductYear, string Architecture, string Channel, string RequiredVersion, string RosterIdentity);
|
private sealed record CacheBinding(string PackageId, string Product, int ProductYear, string Architecture, string Channel, string RequiredVersion, string RosterIdentity);
|
||||||
private const string StoredManifestFileName = "package.manifest.json";
|
private const string StoredManifestFileName = "package.manifest.json";
|
||||||
private const string StoredBindingFileName = "package.binding.json";
|
private const string StoredBindingFileName = "package.binding.json";
|
||||||
@@ -29,19 +29,19 @@ public sealed class LocalPackageStore(string root, IFileHasher hasher) : ILocalP
|
|||||||
await PointerGate.WaitAsync(ct); try
|
await PointerGate.WaitAsync(ct); try
|
||||||
{
|
{
|
||||||
var dir = VersionDirectory(id, version); if (!Directory.Exists(dir)) throw new DirectoryNotFoundException(dir);
|
var dir = VersionDirectory(id, version); if (!Directory.Exists(dir)) throw new DirectoryNotFoundException(dir);
|
||||||
Directory.CreateDirectory(PackageRoot(id)); var prior = await ReadPointer(id, ct); var operationId=Guid.NewGuid().ToString("N");
|
Directory.CreateDirectory(PackageRoot(id)); var prior = await ReadPointer(id, ct); var operationId=Guid.NewGuid().ToString("N"); var content=await ContentFingerprintAsync(id,version,ct);
|
||||||
var temp = PointerPath(id) + "." + operationId + ".tmp"; await using (var stream=new FileStream(temp,FileMode.CreateNew,FileAccess.Write,FileShare.None)) { await JsonSerializer.SerializeAsync(stream,new Pointer(version, prior?.Current,1,(prior?.Generation??0)+1,operationId,DateTimeOffset.UtcNow),cancellationToken:ct); await stream.FlushAsync(ct); }
|
var temp = PointerPath(id) + "." + operationId + ".tmp"; await using (var stream=new FileStream(temp,FileMode.CreateNew,FileAccess.Write,FileShare.None)) { await JsonSerializer.SerializeAsync(stream,new Pointer(version, prior?.Current,1,(prior?.Generation??0)+1,operationId,DateTimeOffset.UtcNow,content.Manifest,content.PayloadSet),cancellationToken:ct); await stream.FlushAsync(ct); }
|
||||||
File.Move(temp, PointerPath(id), true); var verified=await ReadPointer(id,ct); if (verified?.Current!=version || verified.ActivationOperationId!=operationId) throw new InvalidOperationException("POINTER_REVALIDATION_FAILED");
|
File.Move(temp, PointerPath(id), true); var verified=await ReadPointer(id,ct); if (verified?.Current!=version || verified.ActivationOperationId!=operationId) throw new InvalidOperationException("POINTER_REVALIDATION_FAILED");
|
||||||
return new(version, prior?.Current);
|
return new(version, prior?.Current);
|
||||||
} finally { PointerGate.Release(); }
|
} finally { PointerGate.Release(); }
|
||||||
}
|
}
|
||||||
public async Task<PackageActivationResult> ActivateImmutableAsync(ImmutableLocalPackage package, PackageRequirement requirement, CancellationToken ct)
|
public async Task<PackageActivationResult> ActivateImmutableAsync(ImmutableLocalPackage package, PackageRequirement requirement, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (!string.Equals(package.PackageId, requirement.PackageId, StringComparison.Ordinal) || !string.Equals(package.Version, requirement.RequiredVersion, StringComparison.Ordinal)) return new("Blocked", null);
|
if (!string.Equals(package.PackageId, requirement.PackageId, StringComparison.Ordinal) || !string.Equals(package.Version, requirement.RequiredVersion, StringComparison.Ordinal) || !await MatchesImmutableManifestAsync(package,requirement,ct)) return new("Blocked", null);
|
||||||
var prior = await ReadPointer(package.PackageId, ct);
|
var prior = await ReadPointer(package.PackageId, ct);
|
||||||
if (prior?.Current == package.Version) return new("AlreadyActiveExact", new(prior.ActivationOperationId ?? Guid.NewGuid().ToString("N"), package.PackageId, package.Version, prior.Previous, prior.ActivatedAtUtc ?? DateTimeOffset.UtcNow, prior.Generation, PackageRoot(package.PackageId)));
|
if (prior?.Current == package.Version) return new("AlreadyActiveExact", new(prior.ActivationOperationId ?? Guid.NewGuid().ToString("N"), package.PackageId, package.Version, prior.Previous, prior.ActivatedAtUtc ?? DateTimeOffset.UtcNow, prior.Generation, PackageRoot(package.PackageId),1,prior.ManifestSha256,prior.PayloadSetSha256,requirement.RosterIdentity));
|
||||||
var activated = await ActivateAsync(package.PackageId, package.Version, ct);
|
var activated = await ActivateAsync(package.PackageId, package.Version, ct);
|
||||||
var after=await ReadPointer(package.PackageId,ct); return new("Activated", new(after!.ActivationOperationId!, package.PackageId, activated.CurrentVersion, activated.PreviousVersion, after.ActivatedAtUtc ?? DateTimeOffset.UtcNow, after.Generation, PackageRoot(package.PackageId)));
|
var after=await ReadPointer(package.PackageId,ct); return new("Activated", new(after!.ActivationOperationId!, package.PackageId, activated.CurrentVersion, activated.PreviousVersion, after.ActivatedAtUtc ?? DateTimeOffset.UtcNow, after.Generation, PackageRoot(package.PackageId),1,after.ManifestSha256,after.PayloadSetSha256,requirement.RosterIdentity));
|
||||||
}
|
}
|
||||||
public async Task<PackageRollbackResult> RollbackAsync(PackageActivationReceipt receipt, CancellationToken ct)
|
public async Task<PackageRollbackResult> RollbackAsync(PackageActivationReceipt receipt, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -114,6 +114,10 @@ public sealed class LocalPackageStore(string root, IFileHasher hasher) : ILocalP
|
|||||||
}
|
}
|
||||||
return versionRoot;
|
return versionRoot;
|
||||||
}
|
}
|
||||||
|
private async Task<(string Manifest,string PayloadSet)> ContentFingerprintAsync(string id,string version,CancellationToken ct)
|
||||||
|
{ var dir=VersionDirectory(id,version); var manifestPath=Path.Combine(dir,StoredManifestFileName); var manifest=PackageManifestJson.Parse(await File.ReadAllTextAsync(manifestPath,ct)); var manifestHash=await hasher.Sha256Async(manifestPath,ct); var payload=string.Join("|",manifest.Files.OrderBy(x=>x.Path,StringComparer.Ordinal).Select(x=>$"{x.Path}:{x.Length}:{x.Sha256}")); return (manifestHash,Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(payload)))); }
|
||||||
|
private async Task<bool> MatchesImmutableManifestAsync(ImmutableLocalPackage package,PackageRequirement requirement,CancellationToken ct)
|
||||||
|
{ try { if (!package.LocalPackageRoot.Equals(VersionDirectory(package.PackageId,package.Version),StringComparison.OrdinalIgnoreCase)) return false; var manifest=PackageManifestJson.Parse(await File.ReadAllTextAsync(Path.Combine(package.LocalPackageRoot,StoredManifestFileName),ct)); if (!string.Equals(manifest.PackageId,requirement.PackageId,StringComparison.Ordinal)||!string.Equals(manifest.Version,requirement.RequiredVersion,StringComparison.Ordinal)||!string.Equals(manifest.Product,requirement.Product,StringComparison.Ordinal)||manifest.ProductYear!=requirement.ProductYear||!string.Equals(manifest.Architecture,requirement.Architecture,StringComparison.OrdinalIgnoreCase)||!string.Equals(manifest.RolloutChannel,requirement.Channel,StringComparison.OrdinalIgnoreCase)) return false; foreach(var f in manifest.Files){var p=PackageFilePath(package.LocalPackageRoot,f.Path); var i=new FileInfo(p); if(!i.Exists||i.Length!=f.Length||!string.Equals(await hasher.Sha256Async(p,ct),f.Sha256,StringComparison.OrdinalIgnoreCase)) return false;} return true; } catch { return false; } }
|
||||||
private static string PackageFilePath(string packageRoot, string relativePath)
|
private static string PackageFilePath(string packageRoot, string relativePath)
|
||||||
{
|
{
|
||||||
var root = Path.GetFullPath(packageRoot).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
var root = Path.GetFullPath(packageRoot).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||||
|
|||||||
@@ -12,6 +12,14 @@ public sealed class PackageLifecycleTests
|
|||||||
new("entry.dll", a.Length, Convert.ToHexString(SHA256.HashData(a))),
|
new("entry.dll", a.Length, Convert.ToHexString(SHA256.HashData(a))),
|
||||||
new("payload.dat", b.Length, Convert.ToHexString(SHA256.HashData(b)))], new("none", ""), RolloutChannel: "pilot");
|
new("payload.dat", b.Length, Convert.ToHexString(SHA256.HashData(b)))], new("none", ""), RolloutChannel: "pilot");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MutatedImmutablePayloadBlocksActivation()
|
||||||
|
{
|
||||||
|
var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N")); var source=Path.Combine(root,"catalog"); Directory.CreateDirectory(source); var bytes=new byte[]{1}; await File.WriteAllBytesAsync(Path.Combine(source,"entry.dll"),bytes); await File.WriteAllBytesAsync(Path.Combine(source,"payload.dat"),bytes); var store=new LocalPackageStore(root,new Sha256FileHasher());
|
||||||
|
try { var package=await store.MaterializeAsync(Manifest("1.0.0",bytes,bytes),source,CancellationToken.None); await File.WriteAllBytesAsync(Path.Combine(package.LocalPackageRoot,"entry.dll"),new byte[]{9}); Assert.Equal("Blocked",(await store.ActivateImmutableAsync(package,Req("1.0.0"),CancellationToken.None)).Outcome); }
|
||||||
|
finally { if(Directory.Exists(root)) Directory.Delete(root,true); }
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ActivationReceiptBindsPointerGenerationAndRejectsStaleRollback()
|
public async Task ActivationReceiptBindsPointerGenerationAndRejectsStaleRollback()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user