diff --git a/Launcher.Next.Core/Contracts.cs b/Launcher.Next.Core/Contracts.cs index ecf916a..ae4fa1c 100644 --- a/Launcher.Next.Core/Contracts.cs +++ b/Launcher.Next.Core/Contracts.cs @@ -21,6 +21,8 @@ public sealed record PackageActivationReceipt(string OperationId, string Package 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 interface IPackageMaterializer { Task MaterializeAsync(PackageManifest manifest, string sourceRoot, CancellationToken cancellationToken, IProgress? progress = null); } public sealed record PackageRequirement(string PackageId, string Product, int ProductYear, string Architecture, string Channel, string RequiredVersion, string RosterIdentity, bool AllowOfflineFallback); public sealed record CatalogPackage(PackageManifest Manifest, string SourceRoot); diff --git a/Launcher.Next.Infrastructure/LocalPackageStore.cs b/Launcher.Next.Infrastructure/LocalPackageStore.cs index c6f430e..e8e10b2 100644 --- a/Launcher.Next.Infrastructure/LocalPackageStore.cs +++ b/Launcher.Next.Infrastructure/LocalPackageStore.cs @@ -71,11 +71,20 @@ public sealed class LocalPackageStore(string root, IFileHasher hasher) : ILocalP { 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 staging = Path.Combine(root, "Launcher", "staging", manifest.PackageId + "-" + manifest.Version + "-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(staging); + 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); try { var i = 0; foreach (var f in manifest.Files) { ct.ThrowIfCancellationRequested(); 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)); } await File.WriteAllTextAsync(Path.Combine(staging, StoredManifestFileName), JsonSerializer.Serialize(manifest), ct); - var versionDir = VersionDirectory(manifest.PackageId, manifest.Version); Directory.CreateDirectory(Path.GetDirectoryName(versionDir)!); if (Directory.Exists(versionDir)) Directory.Delete(staging, true); else Directory.Move(staging, versionDir); return new(manifest.PackageId, manifest.Version, versionDir); } - catch { if (Directory.Exists(staging)) Directory.Delete(staging, true); throw; } + File.Delete(marker); var versionDir = VersionDirectory(manifest.PackageId, manifest.Version); Directory.CreateDirectory(Path.GetDirectoryName(versionDir)!); if (Directory.Exists(versionDir)) Directory.Delete(staging, true); else Directory.Move(staging, versionDir); 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); + Directory.Delete(directory,true); return new("Cleaned",ownership.OperationId); } private async Task VerifyVersionAsync(PackageRequirement requirement, string version, CancellationToken ct) {