From 95beb0f63234604a58e4d5615d1987c1fd4979e2 Mon Sep 17 00:00:00 2001 From: codex Date: Thu, 6 Aug 2026 09:29:00 +0300 Subject: [PATCH] feat: bind activation receipts to immutable content --- Launcher.Next.Core/Contracts.cs | 2 +- .../LocalPackageStore.cs | 16 ++++++++++------ .../Launcher.Next.Tests/PackageLifecycleTests.cs | 8 ++++++++ 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/Launcher.Next.Core/Contracts.cs b/Launcher.Next.Core/Contracts.cs index 2146ec7..20baa3f 100644 --- a/Launcher.Next.Core/Contracts.cs +++ b/Launcher.Next.Core/Contracts.cs @@ -17,7 +17,7 @@ public sealed record ValidationResult(IReadOnlyList Issues) { 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 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 PackageRollbackResult(string Outcome, string? CurrentVersion); public sealed record ImmutableLocalPackage(string PackageId, string Version, string LocalPackageRoot); diff --git a/Launcher.Next.Infrastructure/LocalPackageStore.cs b/Launcher.Next.Infrastructure/LocalPackageStore.cs index 7b9687b..5859ff6 100644 --- a/Launcher.Next.Infrastructure/LocalPackageStore.cs +++ b/Launcher.Next.Infrastructure/LocalPackageStore.cs @@ -9,7 +9,7 @@ public sealed class LocalPackageStore(string root, IFileHasher hasher) : ILocalP { // Machine-process local serialization only; package roots must remain local. 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 const string StoredManifestFileName = "package.manifest.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 { 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"); - 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); } + 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,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"); return new(version, prior?.Current); } finally { PointerGate.Release(); } } public async Task 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); - 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 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 RollbackAsync(PackageActivationReceipt receipt, CancellationToken ct) { @@ -114,6 +114,10 @@ public sealed class LocalPackageStore(string root, IFileHasher hasher) : ILocalP } 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 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) { var root = Path.GetFullPath(packageRoot).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; diff --git a/tests/Launcher.Next.Tests/PackageLifecycleTests.cs b/tests/Launcher.Next.Tests/PackageLifecycleTests.cs index 016b1e9..4d00ff3 100644 --- a/tests/Launcher.Next.Tests/PackageLifecycleTests.cs +++ b/tests/Launcher.Next.Tests/PackageLifecycleTests.cs @@ -12,6 +12,14 @@ public sealed class PackageLifecycleTests new("entry.dll", a.Length, Convert.ToHexString(SHA256.HashData(a))), 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] public async Task ActivationReceiptBindsPointerGenerationAndRejectsStaleRollback() {