diff --git a/Launcher.Next.Core/Contracts.cs b/Launcher.Next.Core/Contracts.cs index 4a1647d..543a60f 100644 --- a/Launcher.Next.Core/Contracts.cs +++ b/Launcher.Next.Core/Contracts.cs @@ -17,13 +17,14 @@ 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, int SchemaVersion = 1, string? ManifestSha256 = null, string? PayloadSetSha256 = null, string? RosterIdentity = null); +public sealed record PackagePayloadBinding(string RelativePath,long Length,string Sha256); +public sealed record PackageActivationReceipt(string OperationId, string PackageId, string Version, string? PreviousVersion, DateTimeOffset ActivatedUtc, long PointerGeneration = 0, string? ActiveRootIdentity = null, int SchemaVersion = 2, string? ManifestSha256 = null, string? PayloadSetSha256 = null, string? RosterIdentity = null, IReadOnlyList? PayloadFiles = null, long PointerGenerationBefore = 0, string? EvidenceCorrelationId = 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); public sealed record PackageStagingOwnership(string OperationId, string PackageId, string Version, string StagingDirectory, DateTimeOffset CreatedUtc, string MarkerIdentity); public sealed record PackageStagingCleanupResult(string Outcome, string OperationId); -public enum PackageLifecycleFaultPoint { AfterStagingAllocated, DuringPayloadCopy, BeforeImmutableCommit, BeforePointerTempWrite, BeforePointerReplace, BeforePointerReadback, BeforeRollbackReplace, DuringOwnedStagingCleanup } +public enum PackageLifecycleFaultPoint { AfterStagingAllocated, DuringPayloadCopy, AfterPayloadCopyBeforeValidation, BeforeImmutableCommit, AfterImmutableCommitBeforeReturn, BeforePointerTempWrite, BeforePointerReplace, BeforePointerReadback, BeforeRollbackReplace, BeforeRollbackReadback, DuringOwnedStagingCleanup } public interface IPackageLifecycleFaultInjector { void ThrowIfRequested(PackageLifecycleFaultPoint point); } public sealed class NoOpPackageLifecycleFaultInjector : IPackageLifecycleFaultInjector { public void ThrowIfRequested(PackageLifecycleFaultPoint point) { } } public interface IPackageMaterializer { Task MaterializeAsync(PackageManifest manifest, string sourceRoot, CancellationToken cancellationToken, IProgress? progress = null); } diff --git a/Launcher.Next.Core/LauncherPaths.cs b/Launcher.Next.Core/LauncherPaths.cs index 52a8c51..9cfa8c5 100644 --- a/Launcher.Next.Core/LauncherPaths.cs +++ b/Launcher.Next.Core/LauncherPaths.cs @@ -37,6 +37,15 @@ public sealed record ResolvedPackageStagingRoot { internal ResolvedPackageStagin public sealed record ResolvedActivePackageRoot { internal ResolvedActivePackageRoot(string value)=>Value=value; public string Value{get;} } public sealed record ResolvedPackageLifecycleEvidenceRoot { internal ResolvedPackageLifecycleEvidenceRoot(string value)=>Value=value; public string Value{get;} } public sealed record ResolvedPackagePilotRoot { internal ResolvedPackagePilotRoot(string value)=>Value=value; public string Value{get;} } +public sealed record ResolvedPackageCatalogRoot { internal ResolvedPackageCatalogRoot(string value)=>Value=value; public string Value{get;} } +public sealed record ResolvedImmutablePackagePath { internal ResolvedImmutablePackagePath(string value)=>Value=value; public string Value{get;} } +public sealed record ResolvedPackageLifecycleRoots( + ResolvedPackagePilotRoot Pilot, + ResolvedPackageCatalogRoot Catalog, + ResolvedPackageStagingRoot Staging, + ResolvedPackageVersionStoreRoot VersionStore, + ResolvedActivePackageRoot Active, + ResolvedPackageLifecycleEvidenceRoot Evidence); public interface IPathResolver { @@ -48,6 +57,7 @@ public interface IPathResolver ResolvedLegacyCompatibilityOverlayPath ResolveLegacyCompatibilityOverlayPath(LauncherPathVariables variables); ResolvedLegacyProbeEvidenceRoot ResolveLegacyProbeEvidenceRoot(LauncherPathVariables variables); ResolvedPackagePilotRoot ResolvePackagePilotRoot(string value); + ResolvedPackageLifecycleRoots ResolvePackageLifecycleRoots(string value); } public sealed class PathResolver(IReadOnlyDictionary? environment = null) : IPathResolver @@ -117,7 +127,15 @@ public sealed class PathResolver(IReadOnlyDictionary? environme return new(value); } public ResolvedPackagePilotRoot ResolvePackagePilotRoot(string value) - { if (string.IsNullOrWhiteSpace(value)||value.Contains('*')||value.Contains('?')||value.StartsWith(@"\\")||!Path.IsPathFullyQualified(value)) throw new InvalidOperationException("PACKAGE_PILOT_ROOT_NOT_LOCAL"); var full=Path.GetFullPath(value); var info=new DirectoryInfo(full); if(info.Exists&&info.Attributes.HasFlag(FileAttributes.ReparsePoint)) throw new InvalidOperationException("PACKAGE_ROOT_REPARSE_REJECTED"); return new(full); } + { if (string.IsNullOrWhiteSpace(value)||Uri.IsWellFormedUriString(value,UriKind.Absolute)||value.Contains('*')||value.Contains('?')||value.StartsWith(@"\\")||!Path.IsPathFullyQualified(value)) throw new InvalidOperationException("PACKAGE_PILOT_ROOT_NOT_LOCAL"); var full=Path.GetFullPath(value); var info=new DirectoryInfo(full); if(info.Exists&&info.Attributes.HasFlag(FileAttributes.ReparsePoint)) throw new InvalidOperationException("PACKAGE_ROOT_REPARSE_REJECTED"); return new(full); } + public ResolvedPackageLifecycleRoots ResolvePackageLifecycleRoots(string value) + { + var pilot=ResolvePackagePilotRoot(value); string Child(string name)=>Path.GetFullPath(Path.Combine(pilot.Value,name)); + var paths=new[]{Child("catalog"),Child("staging"),Child("version-store"),Child("active"),Child("evidence")}; + if(paths.Distinct(StringComparer.OrdinalIgnoreCase).Count()!=paths.Length) throw new InvalidOperationException("PACKAGE_ROOT_COLLISION"); + foreach(var path in paths) if(!path.StartsWith(pilot.Value.TrimEnd(Path.DirectorySeparatorChar)+Path.DirectorySeparatorChar,StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("PACKAGE_ROOT_TOPOLOGY_INVALID"); + return new(pilot,new(paths[0]),new(paths[1]),new(paths[2]),new(paths[3]),new(paths[4])); + } private string ResolveExistingFile(string role, LauncherPathVariables variables) { var value=ResolvePath(Resolve(variables,[role]).Require(role),variables); diff --git a/Launcher.Next.Infrastructure/LocalPackageStore.cs b/Launcher.Next.Infrastructure/LocalPackageStore.cs index 3fb89c5..477ff58 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, IPackageL { // 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, string? ManifestSha256 = null, string? PayloadSetSha256 = null); + private sealed record Pointer(string Current, string? Previous, int SchemaVersion = 2, long Generation = 0, string? ActivationOperationId = null, DateTimeOffset? ActivatedAtUtc = null, string? ManifestSha256 = null, string? PayloadSetSha256 = null, IReadOnlyList? PayloadFiles = 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"; @@ -20,7 +20,7 @@ public sealed class LocalPackageStore(string root, IFileHasher hasher, IPackageL if (!File.Exists(PointerPath(id))) return null; await using var stream = File.OpenRead(PointerPath(id)); var pointer = await JsonSerializer.DeserializeAsync(stream, cancellationToken: ct); - return pointer is null || pointer.SchemaVersion != 1 || string.IsNullOrWhiteSpace(pointer.Current) ? null : pointer; + return pointer is null || pointer.SchemaVersion is < 1 or > 2 || 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; @@ -30,7 +30,7 @@ public sealed class LocalPackageStore(string root, IFileHasher hasher, IPackageL { 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 content=await ContentFingerprintAsync(id,version,ct); - (faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.BeforePointerTempWrite); 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); } + (faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.BeforePointerTempWrite); 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,2,(prior?.Generation??0)+1,operationId,DateTimeOffset.UtcNow,content.Manifest,content.PayloadSet,content.Files),cancellationToken:ct); await stream.FlushAsync(ct); } (faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.BeforePointerReplace); File.Move(temp, PointerPath(id), true); (faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.BeforePointerReadback); 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(); } @@ -39,16 +39,16 @@ public sealed class LocalPackageStore(string root, IFileHasher hasher, IPackageL { 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),1,prior.ManifestSha256,prior.PayloadSetSha256,requirement.RosterIdentity)); + 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),prior.SchemaVersion,prior.ManifestSha256,prior.PayloadSetSha256,requirement.RosterIdentity,prior.PayloadFiles,Math.Max(0,prior.Generation-1),prior.ActivationOperationId)); 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),1,after.ManifestSha256,after.PayloadSetSha256,requirement.RosterIdentity)); + 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),after.SchemaVersion,after.ManifestSha256,after.PayloadSetSha256,requirement.RosterIdentity,after.PayloadFiles,Math.Max(0,after.Generation-1),after.ActivationOperationId)); } public async Task RollbackAsync(PackageActivationReceipt receipt, CancellationToken ct) { var current=await GetCurrentVersionAsync(receipt.PackageId,ct); - 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); + var pointer=await ReadPointer(receipt.PackageId,ct); if (receipt.SchemaVersion!=2 || pointer is null || !string.Equals(current,receipt.Version,StringComparison.Ordinal) || pointer.ActivationOperationId!=receipt.OperationId || pointer.Generation!=receipt.PointerGeneration || !string.Equals(pointer.ManifestSha256,receipt.ManifestSha256,StringComparison.OrdinalIgnoreCase) || !string.Equals(pointer.PayloadSetSha256,receipt.PayloadSetSha256,StringComparison.OrdinalIgnoreCase) || !string.Equals(receipt.ActiveRootIdentity,PackageRoot(receipt.PackageId),StringComparison.OrdinalIgnoreCase)) return new("RollbackBlockedStateChanged",current); if (string.IsNullOrWhiteSpace(receipt.PreviousVersion)) return new("RollbackNotApplicable",current); - var restored=await ActivateAsync(receipt.PackageId,receipt.PreviousVersion,ct); + (faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.BeforeRollbackReplace); var restored=await ActivateAsync(receipt.PackageId,receipt.PreviousVersion,ct); (faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.BeforeRollbackReadback); return new("RolledBack",restored.CurrentVersion); } public async Task RollbackAsync(string id, CancellationToken ct) { var p = await ReadPointer(id, ct); return p?.Previous is null ? null : (await ActivateAsync(id, p.Previous, ct)).CurrentVersion; } @@ -63,6 +63,7 @@ public sealed class LocalPackageStore(string root, IFileHasher hasher, IPackageL var pointer = await ReadPointer(requirement.PackageId, ct); if (pointer is null) return null; var current = await VerifyVersionAsync(requirement, pointer.Current, ct); + if(current is not null && !await PointerMatchesContentAsync(pointer,requirement.PackageId,pointer.Current,ct)) current=null; if (current is not null) return new(requirement.PackageId, pointer.Current, current, false, "CURRENT_VERIFIED"); if (!requirement.AllowOfflineFallback || string.IsNullOrWhiteSpace(pointer.Previous) || string.Equals(pointer.Current, pointer.Previous, StringComparison.OrdinalIgnoreCase)) return null; var previous = await VerifyVersionAsync(requirement, pointer.Previous, ct); @@ -79,9 +80,10 @@ public sealed class LocalPackageStore(string root, IFileHasher hasher, IPackageL if (!new PackageManifestValidator().Validate(manifest, manifest.ProductYear, manifest.Architecture).IsValid) throw new InvalidOperationException("MANIFEST_INVALID"); if (manifest.Files.Any(x => string.Equals(x.Path, StoredManifestFileName, StringComparison.OrdinalIgnoreCase))) throw new InvalidOperationException("MANIFEST_RESERVED_FILE"); var operationId = Guid.NewGuid().ToString("N"); var staging = Path.Combine(root, "Launcher", "staging", operationId); Directory.CreateDirectory(staging); await File.WriteAllTextAsync(Path.Combine(staging, ".package-staging-owner.json"), JsonSerializer.Serialize(new PackageStagingOwnership(operationId, manifest.PackageId, manifest.Version, staging, DateTimeOffset.UtcNow, operationId)), ct); (faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.AfterStagingAllocated); - try { var i = 0; foreach (var f in manifest.Files) { ct.ThrowIfCancellationRequested(); (faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.DuringPayloadCopy); var source = Path.Combine(sourceRoot, f.Path); var dest = PackageFilePath(staging, f.Path); Directory.CreateDirectory(Path.GetDirectoryName(dest)!); File.Copy(source, dest, true); if (!string.Equals(await hasher.Sha256Async(dest, ct), f.Sha256, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("HASH_MISMATCH"); progress?.Report(new("copy", ++i, manifest.Files.Count)); } + try { var i = 0; foreach (var f in manifest.Files) { ct.ThrowIfCancellationRequested(); (faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.DuringPayloadCopy); var source = PackageFilePath(sourceRoot, f.Path); var sourceInfo=new FileInfo(source); if(!sourceInfo.Exists||sourceInfo.Length!=f.Length||!string.Equals(await hasher.Sha256Async(source,ct),f.Sha256,StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("SOURCE_HASH_MISMATCH"); var dest = PackageFilePath(staging, f.Path); Directory.CreateDirectory(Path.GetDirectoryName(dest)!); File.Copy(source, dest, true); if (!string.Equals(await hasher.Sha256Async(dest, ct), f.Sha256, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("HASH_MISMATCH"); progress?.Report(new("copy", ++i, manifest.Files.Count)); } + (faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.AfterPayloadCopyBeforeValidation); await File.WriteAllTextAsync(Path.Combine(staging, StoredManifestFileName), JsonSerializer.Serialize(manifest), ct); - File.Delete(Path.Combine(staging, ".package-staging-owner.json")); var versionDir = VersionDirectory(manifest.PackageId, manifest.Version); Directory.CreateDirectory(Path.GetDirectoryName(versionDir)!); (faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.BeforeImmutableCommit); if (Directory.Exists(versionDir)) Directory.Delete(staging, true); else Directory.Move(staging, versionDir); return new(manifest.PackageId, manifest.Version, versionDir); } + File.Delete(Path.Combine(staging, ".package-staging-owner.json")); var versionDir = VersionDirectory(manifest.PackageId, manifest.Version); Directory.CreateDirectory(Path.GetDirectoryName(versionDir)!); (faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.BeforeImmutableCommit); if (Directory.Exists(versionDir)) { if(!await ExistingMatchesAsync(versionDir,manifest,ct)) throw new InvalidOperationException("IMMUTABLE_PACKAGE_CONFLICT"); Directory.Delete(staging,true); } else Directory.Move(staging, versionDir); (faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.AfterImmutableCommitBeforeReturn); return new(manifest.PackageId, manifest.Version, versionDir); } catch { await CleanupOwnedStagingAsync(new PackageStagingOwnership(operationId, manifest.PackageId, manifest.Version, staging, DateTimeOffset.UtcNow, operationId), CancellationToken.None); throw; } } public async Task CleanupOwnedStagingAsync(PackageStagingOwnership ownership, CancellationToken ct) @@ -91,7 +93,7 @@ public sealed class LocalPackageStore(string root, IFileHasher hasher, IPackageL if (!Directory.Exists(directory)) return new("AlreadyAbsent",ownership.OperationId); var marker=Path.Combine(directory,".package-staging-owner.json"); if (!File.Exists(marker)) return new("OwnershipUnknown",ownership.OperationId); var stored=JsonSerializer.Deserialize(await File.ReadAllTextAsync(marker,ct)); if (stored is null || stored.OperationId!=ownership.OperationId || stored.PackageId!=ownership.PackageId || stored.Version!=ownership.Version) return new("OwnershipMismatch",ownership.OperationId); - Directory.Delete(directory,true); return new("Cleaned",ownership.OperationId); + (faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.DuringOwnedStagingCleanup); Directory.Delete(directory,true); return new("Cleaned",ownership.OperationId); } private async Task VerifyVersionAsync(PackageRequirement requirement, string version, CancellationToken ct) { @@ -114,8 +116,12 @@ public sealed class LocalPackageStore(string root, IFileHasher hasher, IPackageL } 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<(string Manifest,string PayloadSet,IReadOnlyList Files)> 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 files=manifest.Files.OrderBy(x=>x.Path,StringComparer.Ordinal).Select(x=>new PackagePayloadBinding(x.Path.Replace('\\','/'),x.Length,x.Sha256.ToUpperInvariant())).ToArray(); var payload=string.Join("|",files.Select(x=>$"{x.RelativePath}:{x.Length}:{x.Sha256}")); return (manifestHash,Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(payload))),files); } + private async Task PointerMatchesContentAsync(Pointer pointer,string id,string version,CancellationToken ct) + { if(pointer.SchemaVersion==1) return true; try { var content=await ContentFingerprintAsync(id,version,ct); return string.Equals(pointer.ManifestSha256,content.Manifest,StringComparison.OrdinalIgnoreCase)&&string.Equals(pointer.PayloadSetSha256,content.PayloadSet,StringComparison.OrdinalIgnoreCase)&&pointer.PayloadFiles is not null&&pointer.PayloadFiles.SequenceEqual(content.Files); } catch{return false;} } + private async Task ExistingMatchesAsync(string directory,PackageManifest expected,CancellationToken ct) + { try { var stored=PackageManifestJson.Parse(await File.ReadAllTextAsync(Path.Combine(directory,StoredManifestFileName),ct)); if(stored!=expected) return false; foreach(var file in expected.Files){var path=PackageFilePath(directory,file.Path);var info=new FileInfo(path);if(!info.Exists||info.Length!=file.Length||!string.Equals(await hasher.Sha256Async(path,ct),file.Sha256,StringComparison.OrdinalIgnoreCase))return false;}return true;}catch{return false;} } 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) diff --git a/tests/Launcher.Next.Tests/PackageLifecycleTests.cs b/tests/Launcher.Next.Tests/PackageLifecycleTests.cs index 9c5f2b8..e534305 100644 --- a/tests/Launcher.Next.Tests/PackageLifecycleTests.cs +++ b/tests/Launcher.Next.Tests/PackageLifecycleTests.cs @@ -1,4 +1,5 @@ using System.Security.Cryptography; +using System.Text.Json; using Launcher.Next.Core; using Launcher.Next.Infrastructure; using Xunit; @@ -12,6 +13,16 @@ public sealed class PackageLifecycleTests { var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N")); var source=Path.Combine(root,"catalog"); Directory.CreateDirectory(source); await File.WriteAllBytesAsync(Path.Combine(source,"entry.dll"),[1]); await File.WriteAllBytesAsync(Path.Combine(source,"payload.dat"),[2]); var store=new LocalPackageStore(root,new Sha256FileHasher(),new Fault(PackageLifecycleFaultPoint.AfterStagingAllocated)); try { await Assert.ThrowsAsync(()=>store.MaterializeAsync(Manifest("1.0.0",[1],[2]),source,CancellationToken.None)); Assert.Null(await store.GetCurrentVersionAsync("Lifecycle.Package",CancellationToken.None)); } finally { if(Directory.Exists(root)) Directory.Delete(root,true); } } [Fact] public void PilotRootRejectsUncAndRelativePaths() { var resolver=new PathResolver(new Dictionary()); Assert.Throws(()=>resolver.ResolvePackagePilotRoot(@"\\server\share")); Assert.Throws(()=>resolver.ResolvePackagePilotRoot("relative")); } + [Fact] public void LifecycleRootsAreTypedDistinctAndConfined() + { var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N")); var roots=new PathResolver(new Dictionary()).ResolvePackageLifecycleRoots(root); var values=new[]{roots.Catalog.Value,roots.Staging.Value,roots.VersionStore.Value,roots.Active.Value,roots.Evidence.Value}; Assert.Equal(values.Length,values.Distinct(StringComparer.OrdinalIgnoreCase).Count()); Assert.All(values,x=>Assert.StartsWith(roots.Pilot.Value,x,StringComparison.OrdinalIgnoreCase)); } + [Theory] + [InlineData(PackageLifecycleFaultPoint.AfterStagingAllocated)] + [InlineData(PackageLifecycleFaultPoint.DuringPayloadCopy)] + [InlineData(PackageLifecycleFaultPoint.AfterPayloadCopyBeforeValidation)] + [InlineData(PackageLifecycleFaultPoint.BeforeImmutableCommit)] + [InlineData(PackageLifecycleFaultPoint.AfterImmutableCommitBeforeReturn)] + public async Task DeterministicMaterializationFaultsNeverMutatePointers(PackageLifecycleFaultPoint point) + { var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N"));var source=Path.Combine(root,"catalog");Directory.CreateDirectory(source);await File.WriteAllBytesAsync(Path.Combine(source,"entry.dll"),[1]);await File.WriteAllBytesAsync(Path.Combine(source,"payload.dat"),[2]);var store=new LocalPackageStore(root,new Sha256FileHasher(),new Fault(point));try{await Assert.ThrowsAnyAsync(()=>store.MaterializeAsync(Manifest("1.0.0",[1],[2]),source,CancellationToken.None));Assert.Null(await store.GetCurrentVersionAsync("Lifecycle.Package",CancellationToken.None));Assert.Null(await store.GetPreviousVersionAsync("Lifecycle.Package",CancellationToken.None));}finally{if(Directory.Exists(root))Directory.Delete(root,true);} } private static PackageRequirement Req(string version) => new("Lifecycle.Package", "Plant3D", 2026, "x64", "pilot", version, "lifecycle-test", true); private static PackageManifest Manifest(string version, byte[] a, byte[] b) => new(1, "Lifecycle.Package", version, "Plant3D", 2026, "x64", "entry.dll", [ new("entry.dll", a.Length, Convert.ToHexString(SHA256.HashData(a))), @@ -73,6 +84,21 @@ public sealed class PackageLifecycleTests finally { if (Directory.Exists(root)) Directory.Delete(root, true); } } + [Fact] + public async Task StrictPointerBindsIndividualPayloadsAndExactLkgRejectsTampering() + { + var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N"));var source=Path.Combine(root,"catalog");Directory.CreateDirectory(source);var bytes=new byte[]{4,5};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 p=await store.MaterializeAsync(Manifest("1.0.0",bytes,bytes),source,CancellationToken.None);await store.BindAsync(Req("1.0.0"),"1.0.0",CancellationToken.None);await store.ActivateImmutableAsync(p,Req("1.0.0"),CancellationToken.None);var pointerPath=Path.Combine(root,"Applications","Lifecycle.Package","current.json");using(var doc=JsonDocument.Parse(await File.ReadAllTextAsync(pointerPath))){Assert.Equal(2,doc.RootElement.GetProperty("SchemaVersion").GetInt32());Assert.Equal(2,doc.RootElement.GetProperty("PayloadFiles").GetArrayLength());}Assert.NotNull(await store.ResolveAsync(Req("1.0.0"),CancellationToken.None));var json=await File.ReadAllTextAsync(pointerPath);json=json.Replace("PayloadSetSha256\":\"","PayloadSetSha256\":\"00",StringComparison.Ordinal);await File.WriteAllTextAsync(pointerPath,json);Assert.Null(await store.ResolveAsync(Req("1.0.0"),CancellationToken.None)); } + finally{if(Directory.Exists(root))Directory.Delete(root,true);} + } + + [Theory] + [InlineData(PackageLifecycleFaultPoint.BeforePointerTempWrite)] + [InlineData(PackageLifecycleFaultPoint.BeforePointerReplace)] + [InlineData(PackageLifecycleFaultPoint.BeforePointerReadback)] + public async Task PointerFaultsDoNotReturnFalseActivationAndReleaseLock(PackageLifecycleFaultPoint point) + { var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N"));var source=Path.Combine(root,"catalog");Directory.CreateDirectory(source);await File.WriteAllBytesAsync(Path.Combine(source,"entry.dll"),[1]);await File.WriteAllBytesAsync(Path.Combine(source,"payload.dat"),[2]);try{var package=await new LocalPackageStore(root,new Sha256FileHasher()).MaterializeAsync(Manifest("1.0.0",[1],[2]),source,CancellationToken.None);var failing=new LocalPackageStore(root,new Sha256FileHasher(),new Fault(point));await Assert.ThrowsAnyAsync(()=>failing.ActivateImmutableAsync(package,Req("1.0.0"),CancellationToken.None));var healthy=new LocalPackageStore(root,new Sha256FileHasher());var result=await healthy.ActivateImmutableAsync(package,Req("1.0.0"),CancellationToken.None);Assert.Contains(result.Outcome,new[]{"Activated","AlreadyActiveExact"});}finally{if(Directory.Exists(root))Directory.Delete(root,true);} } + [Fact] public async Task OldReceiptCannotRollbackNewerActivation() {