diff --git a/Launcher.Next.Core/Contracts.cs b/Launcher.Next.Core/Contracts.cs index ae4fa1c..2146ec7 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); +public sealed record PackageActivationReceipt(string OperationId, string PackageId, string Version, string? PreviousVersion, DateTimeOffset ActivatedUtc, long PointerGeneration = 0, string? ActiveRootIdentity = 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 9d911ed..7b9687b 100644 --- a/Launcher.Next.Infrastructure/LocalPackageStore.cs +++ b/Launcher.Next.Infrastructure/LocalPackageStore.cs @@ -7,7 +7,9 @@ public sealed class Sha256FileHasher : IFileHasher { public async Task Sha256Async(string path, CancellationToken cancellationToken) { await using var s = File.OpenRead(path); return Convert.ToHexString(await SHA256.HashDataAsync(s, cancellationToken)); } } public sealed class LocalPackageStore(string root, IFileHasher hasher) : ILocalPackageStore, IPackageActivator, IRollbackManager, IPackageSynchronizer, IPackageMaterializer, ILastKnownGoodPackageResolver, IPackageRequirementCacheBinder { - private sealed record Pointer(string Current, string? Previous); + // 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 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"; @@ -17,29 +19,34 @@ public sealed class LocalPackageStore(string root, IFileHasher hasher) : ILocalP { if (!File.Exists(PointerPath(id))) return null; await using var stream = File.OpenRead(PointerPath(id)); - return await JsonSerializer.DeserializeAsync(stream, cancellationToken: ct); + var pointer = await JsonSerializer.DeserializeAsync(stream, cancellationToken: ct); + return pointer is null || pointer.SchemaVersion != 1 || string.IsNullOrWhiteSpace(pointer.Current) ? null : pointer; } public async Task GetCurrentVersionAsync(string id, CancellationToken ct) => (await ReadPointer(id, ct))?.Current; public async Task GetPreviousVersionAsync(string id, CancellationToken ct) => (await ReadPointer(id, ct))?.Previous; public async Task ActivateAsync(string id, string version, CancellationToken ct) { - var dir = VersionDirectory(id, version); if (!Directory.Exists(dir)) throw new DirectoryNotFoundException(dir); - Directory.CreateDirectory(PackageRoot(id)); var prior = await ReadPointer(id, ct); - var temp = PointerPath(id) + ".tmp"; await File.WriteAllTextAsync(temp, JsonSerializer.Serialize(new Pointer(version, prior?.Current)), ct); File.Move(temp, PointerPath(id), true); - return new(version, prior?.Current); + 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); } + 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); var prior = await ReadPointer(package.PackageId, ct); - if (prior?.Current == package.Version) return new("AlreadyActiveExact", new(Guid.NewGuid().ToString("N"), package.PackageId, package.Version, prior.Previous, DateTimeOffset.UtcNow)); + 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))); var activated = await ActivateAsync(package.PackageId, package.Version, ct); - return new("Activated", new(Guid.NewGuid().ToString("N"), package.PackageId, activated.CurrentVersion, activated.PreviousVersion, DateTimeOffset.UtcNow)); + 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))); } public async Task RollbackAsync(PackageActivationReceipt receipt, CancellationToken ct) { var current=await GetCurrentVersionAsync(receipt.PackageId,ct); - if (!string.Equals(current,receipt.Version,StringComparison.Ordinal)) return new("RollbackBlockedStateChanged",current); + var pointer=await ReadPointer(receipt.PackageId,ct); if (pointer is null || !string.Equals(current,receipt.Version,StringComparison.Ordinal) || pointer.ActivationOperationId!=receipt.OperationId || pointer.Generation!=receipt.PointerGeneration) return new("RollbackBlockedStateChanged",current); if (string.IsNullOrWhiteSpace(receipt.PreviousVersion)) return new("RollbackNotApplicable",current); var restored=await ActivateAsync(receipt.PackageId,receipt.PreviousVersion,ct); return new("RolledBack",restored.CurrentVersion); diff --git a/tests/Launcher.Next.Tests/PackageLifecycleTests.cs b/tests/Launcher.Next.Tests/PackageLifecycleTests.cs index 39830a7..016b1e9 100644 --- a/tests/Launcher.Next.Tests/PackageLifecycleTests.cs +++ b/tests/Launcher.Next.Tests/PackageLifecycleTests.cs @@ -12,6 +12,22 @@ 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 ActivationReceiptBindsPointerGenerationAndRejectsStaleRollback() + { + var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N")); var source=Path.Combine(root,"catalog"); Directory.CreateDirectory(source); var bytes=new byte[]{7}; 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 a=await store.MaterializeAsync(Manifest("1.0.0",bytes,bytes),source,CancellationToken.None); await store.ActivateImmutableAsync(a,Req("1.0.0"),CancellationToken.None); var b=await store.MaterializeAsync(Manifest("2.0.0",bytes,bytes),source,CancellationToken.None); var receipt= (await store.ActivateImmutableAsync(b,Req("2.0.0"),CancellationToken.None)).Receipt!; Assert.True(receipt.PointerGeneration>0); var c=await store.MaterializeAsync(Manifest("3.0.0",bytes,bytes),source,CancellationToken.None); await store.ActivateImmutableAsync(c,Req("3.0.0"),CancellationToken.None); Assert.Equal("RollbackBlockedStateChanged",(await store.RollbackAsync(receipt,CancellationToken.None)).Outcome); } + finally { if(Directory.Exists(root)) Directory.Delete(root,true); } + } + + [Fact] + public async Task OwnedCleanupNeverDeletesForeignSibling() + { + var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N")); var staging=Path.Combine(root,"Launcher","staging"); var owned=Path.Combine(staging,"owned"); var foreign=Path.Combine(staging,"foreign"); Directory.CreateDirectory(owned); Directory.CreateDirectory(foreign); var ownership=new PackageStagingOwnership("owned","P","1",owned,DateTimeOffset.UtcNow,"owned"); await File.WriteAllTextAsync(Path.Combine(owned,".package-staging-owner.json"),System.Text.Json.JsonSerializer.Serialize(ownership)); var store=new LocalPackageStore(root,new Sha256FileHasher()); + try { Assert.Equal("Cleaned",(await store.CleanupOwnedStagingAsync(ownership,CancellationToken.None)).Outcome); Assert.True(Directory.Exists(foreign)); Assert.Equal("AlreadyAbsent",(await store.CleanupOwnedStagingAsync(ownership,CancellationToken.None)).Outcome); } + finally { if(Directory.Exists(root)) Directory.Delete(root,true); } + } + [Fact] public async Task MaterializeDoesNotCreatePointersAndReturnsImmutableVersion() {