using System.Security.Cryptography; using System.Text.Json; using Launcher.Next.Core; namespace Launcher.Next.Infrastructure; 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, IPackageLifecycleFaultInjector? faults = null) : ILocalPackageStore, IPackageActivator, IRollbackManager, IPackageSynchronizer, IPackageMaterializer, ILastKnownGoodPackageResolver, IPackageRequirementCacheBinder { // 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 = 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"; private string PackageRoot(string id) => Path.Combine(root, "Applications", id); private string PointerPath(string id) => Path.Combine(PackageRoot(id), "current.json"); private async Task ReadPointer(string id, CancellationToken ct) { 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 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; public async Task ActivateAsync(string id, string version, CancellationToken ct) { 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 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,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(); } } 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) || !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),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),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 (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); (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; } public async Task BindAsync(PackageRequirement requirement, string version, CancellationToken ct) { var root = VersionDirectory(requirement.PackageId, version); if (!Directory.Exists(root) || !string.Equals(requirement.RequiredVersion, version, StringComparison.Ordinal)) throw new InvalidOperationException("PACKAGE_REQUIREMENT_BINDING_INVALID"); await File.WriteAllTextAsync(Path.Combine(root, StoredBindingFileName), JsonSerializer.Serialize(new CacheBinding(requirement.PackageId, requirement.Product, requirement.ProductYear, requirement.Architecture, requirement.Channel, requirement.RequiredVersion, requirement.RosterIdentity)), ct); } public async Task ResolveAsync(PackageRequirement requirement, CancellationToken ct) { 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); return previous is null ? null : new(requirement.PackageId, pointer.Previous, previous, true, "PREVIOUS_VERIFIED_AFTER_CURRENT_REJECTED"); } public async Task SynchronizeAsync(PackageManifest manifest, string sourceRoot, CancellationToken ct, IProgress? progress = null) { var materialized = await MaterializeAsync(manifest, sourceRoot, ct, progress); return await ActivateAsync(materialized.PackageId, materialized.Version, ct); } /// Copies and commits an immutable local version; never mutates activation pointers. public async Task MaterializeAsync(PackageManifest manifest, string sourceRoot, CancellationToken ct, IProgress? progress = null) { 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 = 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)) { 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) { var stagingRoot=Path.GetFullPath(Path.Combine(root,"Launcher","staging")); var directory=Path.GetFullPath(ownership.StagingDirectory); var prefix=stagingRoot.TrimEnd(Path.DirectorySeparatorChar)+Path.DirectorySeparatorChar; if (!directory.StartsWith(prefix,StringComparison.OrdinalIgnoreCase) || directory.Equals(stagingRoot,StringComparison.OrdinalIgnoreCase)) return new("OwnershipUnknown",ownership.OperationId); 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); (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) { string versionRoot; try { versionRoot = VersionDirectory(requirement.PackageId, version); } catch { return null; } var manifestPath = Path.Combine(versionRoot, StoredManifestFileName); var bindingPath = Path.Combine(versionRoot, StoredBindingFileName); if (!Directory.Exists(versionRoot) || new DirectoryInfo(versionRoot).Attributes.HasFlag(FileAttributes.ReparsePoint) || !File.Exists(manifestPath) || !File.Exists(bindingPath) || new FileInfo(manifestPath).Attributes.HasFlag(FileAttributes.ReparsePoint) || new FileInfo(bindingPath).Attributes.HasFlag(FileAttributes.ReparsePoint)) return null; PackageManifest manifest; CacheBinding binding; try { manifest = PackageManifestJson.Parse(await File.ReadAllTextAsync(manifestPath, ct)); binding = JsonSerializer.Deserialize(await File.ReadAllTextAsync(bindingPath, ct))!; } catch { return null; } if (binding is null || !Matches(requirement, binding) || !string.Equals(manifest.PackageId, requirement.PackageId, StringComparison.Ordinal) || !string.Equals(manifest.Version, version, 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) || !string.Equals(manifest.Version, requirement.RequiredVersion, StringComparison.Ordinal) || !new PackageManifestValidator().Validate(manifest, requirement.ProductYear, requirement.Architecture).IsValid) return null; foreach (var file in manifest.Files) { var path = PackageFilePath(versionRoot, file.Path); var info = new FileInfo(path); if (!info.Exists || info.Attributes.HasFlag(FileAttributes.ReparsePoint) || info.Length != file.Length || !string.Equals(await hasher.Sha256Async(path, ct), file.Sha256, StringComparison.OrdinalIgnoreCase)) return null; } return versionRoot; } 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) { var root = Path.GetFullPath(packageRoot).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; var path = Path.GetFullPath(Path.Combine(root, relativePath)); if (!path.StartsWith(root, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("PACKAGE_ROOT_ESCAPE"); return path; } private string VersionDirectory(string packageId, string version) { if (string.IsNullOrWhiteSpace(version) || !string.Equals(Path.GetFileName(version), version, StringComparison.Ordinal)) throw new InvalidOperationException("PACKAGE_VERSION_INVALID"); return PackageFilePath(Path.Combine(PackageRoot(packageId), "versions"), version); } private static bool Matches(PackageRequirement requirement, CacheBinding binding) => string.Equals(requirement.PackageId, binding.PackageId, StringComparison.Ordinal) && string.Equals(requirement.Product, binding.Product, StringComparison.Ordinal) && requirement.ProductYear == binding.ProductYear && string.Equals(requirement.Architecture, binding.Architecture, StringComparison.OrdinalIgnoreCase) && string.Equals(requirement.Channel, binding.Channel, StringComparison.OrdinalIgnoreCase) && string.Equals(requirement.RequiredVersion, binding.RequiredVersion, StringComparison.Ordinal) && string.Equals(requirement.RosterIdentity, binding.RosterIdentity, StringComparison.Ordinal); }