diff --git a/Launcher.Next.Application/PackageResolutionUseCase.cs b/Launcher.Next.Application/PackageResolutionUseCase.cs new file mode 100644 index 0000000..652a44b --- /dev/null +++ b/Launcher.Next.Application/PackageResolutionUseCase.cs @@ -0,0 +1,50 @@ +using Launcher.Next.Core; + +namespace Launcher.Next.Application; + +public sealed class PackageResolutionUseCase(IPackageCatalogPackageAcquirer catalog, IPackageSynchronizer synchronizer, IPackageRequirementCacheBinder binder, ILastKnownGoodPackageResolver cache, ILauncherTelemetry telemetry) +{ + public async Task ResolveAsync(PackageRequirement requirement, CancellationToken cancellationToken) + { + var evidence = new List { new("PACKAGE_REQUIREMENT_RECEIVED", requirement.PackageId) }; + telemetry.Write("PackageResolutionStarted", new Dictionary { ["packageId"] = requirement.PackageId, ["product"] = requirement.Product, ["channel"] = requirement.Channel, ["offlineAllowed"] = requirement.AllowOfflineFallback }); + try + { + var online = await catalog.AcquireAsync(requirement, cancellationToken); + if (online is not null && Matches(requirement, online.Manifest)) + { + await synchronizer.SynchronizeAsync(online.Manifest, online.SourceRoot, cancellationToken); + await binder.BindAsync(requirement, online.Manifest.Version, cancellationToken); + var selected = await cache.ResolveAsync(requirement, cancellationToken); + if (selected is not null && !selected.IsOfflineFallback) + { + evidence.Add(new("ONLINE_PACKAGE_SELECTED", online.Manifest.Version)); + telemetry.Write("PackageResolutionSelected", new Dictionary { ["code"] = "ONLINE_PACKAGE_SELECTED", ["packageId"] = selected.PackageId, ["version"] = selected.Version }); + return new(selected, true, evidence); + } + evidence.Add(new("ONLINE_PACKAGE_REJECTED", "LOCAL_REVALIDATION_FAILED")); + } + else evidence.Add(new("CATALOG_UNAVAILABLE", online is null ? "NO_CATALOG_PACKAGE" : "REQUIREMENT_MISMATCH")); + } + catch (Exception ex) when (ex is not OperationCanceledException) { evidence.Add(new("CATALOG_UNAVAILABLE", "ACQUISITION_OR_STAGING_FAILED")); } + if (!requirement.AllowOfflineFallback) + { + evidence.Add(new("OFFLINE_FALLBACK_DISABLED", "POLICY")); + evidence.Add(new("NO_SAFE_PACKAGE_AVAILABLE", "BLOCKED")); + telemetry.Write("PackageResolutionBlocked", new Dictionary { ["code"] = "OFFLINE_FALLBACK_DISABLED", ["packageId"] = requirement.PackageId }); + return new(null, false, evidence); + } + var cached = await cache.ResolveAsync(requirement, cancellationToken); + if (cached is null) + { + evidence.Add(new("NO_SAFE_PACKAGE_AVAILABLE", "CACHE_REJECTED")); + telemetry.Write("PackageResolutionBlocked", new Dictionary { ["code"] = "NO_SAFE_PACKAGE_AVAILABLE", ["packageId"] = requirement.PackageId }); + return new(null, false, evidence); + } + var code = cached.IsOfflineFallback ? "PREVIOUS_LKG_SELECTED" : "VALID_CURRENT_CACHE_SELECTED"; + evidence.Add(new(code, cached.Version)); + telemetry.Write("PackageResolutionSelected", new Dictionary { ["code"] = code, ["packageId"] = cached.PackageId, ["version"] = cached.Version }); + return new(cached, false, evidence); + } + private static bool Matches(PackageRequirement r, PackageManifest m) => string.Equals(r.PackageId, m.PackageId, StringComparison.Ordinal) && string.Equals(r.Product, m.Product, StringComparison.Ordinal) && r.ProductYear == m.ProductYear && string.Equals(r.Architecture, m.Architecture, StringComparison.OrdinalIgnoreCase) && string.Equals(r.Channel, m.RolloutChannel, StringComparison.OrdinalIgnoreCase) && string.Equals(r.RequiredVersion, m.Version, StringComparison.Ordinal); +} diff --git a/Launcher.Next.Core/Contracts.cs b/Launcher.Next.Core/Contracts.cs index b415d7e..655c4f6 100644 --- a/Launcher.Next.Core/Contracts.cs +++ b/Launcher.Next.Core/Contracts.cs @@ -17,18 +17,25 @@ 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 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); +public sealed record PackageResolutionEvidence(string Code, string Detail); +public sealed record PackageResolutionResult(CachedPackageResolution? Selected, bool Online, IReadOnlyList Evidence) +{ public bool IsResolved => Selected is not null; } public sealed record LaunchRequest(ProductInstallation Product, string Profile, string StartupScriptPath, bool DryRun); public sealed record PreparedLaunch(string Executable, IReadOnlyList Arguments, string WorkingDirectory, bool WouldStart); public interface IProductDiscovery { ProductInstallation? DiscoverPlant3D(int year); } public interface IPackageCatalogClient { Task GetManifestAsync(string packageId, CancellationToken cancellationToken); } +public interface IPackageCatalogPackageAcquirer { Task AcquireAsync(PackageRequirement requirement, CancellationToken cancellationToken); } public interface IPackageManifestValidator { ValidationResult Validate(PackageManifest manifest, int productYear, string architecture); } public interface IPackageSynchronizer { Task SynchronizeAsync(PackageManifest manifest, string sourceRoot, CancellationToken cancellationToken, IProgress? progress = null); } public interface ILocalPackageStore { Task GetCurrentVersionAsync(string packageId, CancellationToken cancellationToken); Task GetPreviousVersionAsync(string packageId, CancellationToken cancellationToken); } public interface IPackageActivator { Task ActivateAsync(string packageId, string version, CancellationToken cancellationToken); } public interface IRollbackManager { Task RollbackAsync(string packageId, CancellationToken cancellationToken); } public sealed record CachedPackageResolution(string PackageId, string Version, string LocalPackageRoot, bool IsOfflineFallback, string Reason); -public interface ILastKnownGoodPackageResolver { Task ResolveAsync(string packageId, string product, int productYear, string architecture, bool allowOfflineFallback, CancellationToken cancellationToken); } +public interface ILastKnownGoodPackageResolver { Task ResolveAsync(PackageRequirement requirement, CancellationToken cancellationToken); } +public interface IPackageRequirementCacheBinder { Task BindAsync(PackageRequirement requirement, string version, CancellationToken cancellationToken); } public interface IAutodeskProcessLauncher { PreparedLaunch Prepare(LaunchRequest request); } public sealed record ResolvedAutomationScriptPaths(string ValidatedPackageRoot, string EntryAssembly, string FixtureOrthoDwg, string FixtureModelDwg, string AutomationScriptPath, bool Diagnostic, bool Apply); public interface IStartupScriptBuilder { string BuildNetloadScript(string validatedPackageRoot, string entryAssembly); string BuildAutomationScript(ResolvedAutomationScriptPaths paths); } diff --git a/Launcher.Next.Core/ManifestValidator.cs b/Launcher.Next.Core/ManifestValidator.cs index c1284f4..347a068 100644 --- a/Launcher.Next.Core/ManifestValidator.cs +++ b/Launcher.Next.Core/ManifestValidator.cs @@ -10,7 +10,7 @@ public sealed class PackageManifestValidator : IPackageManifestValidator var issues = new List(); if (manifest.SchemaVersion != 1) issues.Add(new("schema", "Unsupported schemaVersion.")); if (string.IsNullOrWhiteSpace(manifest.PackageId) || string.IsNullOrWhiteSpace(manifest.Version)) issues.Add(new("identity", "packageId and version are required.")); - if (!string.Equals(manifest.Product, "Plant3D", StringComparison.OrdinalIgnoreCase) || manifest.ProductYear != productYear) issues.Add(new("product", "Package is incompatible with Plant 3D.")); + if (string.IsNullOrWhiteSpace(manifest.Product) || manifest.ProductYear != productYear) issues.Add(new("product", "Package product or year is incompatible.")); if (!string.Equals(manifest.Architecture, architecture, StringComparison.OrdinalIgnoreCase)) issues.Add(new("architecture", "Package architecture is incompatible.")); var paths = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var file in manifest.Files) diff --git a/Launcher.Next.Infrastructure/LocalPackageStore.cs b/Launcher.Next.Infrastructure/LocalPackageStore.cs index 45035c4..538feba 100644 --- a/Launcher.Next.Infrastructure/LocalPackageStore.cs +++ b/Launcher.Next.Infrastructure/LocalPackageStore.cs @@ -5,10 +5,12 @@ 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) : ILocalPackageStore, IPackageActivator, IRollbackManager, IPackageSynchronizer, ILastKnownGoodPackageResolver +public sealed class LocalPackageStore(string root, IFileHasher hasher) : ILocalPackageStore, IPackageActivator, IRollbackManager, IPackageSynchronizer, ILastKnownGoodPackageResolver, IPackageRequirementCacheBinder { private sealed record Pointer(string Current, string? Previous); + 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) @@ -27,15 +29,21 @@ public sealed class LocalPackageStore(string root, IFileHasher hasher) : ILocalP return new(version, prior?.Current); } 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 ResolveAsync(string packageId, string product, int productYear, string architecture, bool allowOfflineFallback, CancellationToken ct) + public async Task BindAsync(PackageRequirement requirement, string version, CancellationToken ct) { - var pointer = await ReadPointer(packageId, 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(packageId, pointer.Current, product, productYear, architecture, ct); - if (current is not null) return new(packageId, pointer.Current, current, false, "CURRENT_VERIFIED"); - if (!allowOfflineFallback || string.IsNullOrWhiteSpace(pointer.Previous) || string.Equals(pointer.Current, pointer.Previous, StringComparison.OrdinalIgnoreCase)) return null; - var previous = await VerifyVersionAsync(packageId, pointer.Previous, product, productYear, architecture, ct); - return previous is null ? null : new(packageId, pointer.Previous, previous, true, "PREVIOUS_VERIFIED_AFTER_CURRENT_REJECTED"); + var current = await VerifyVersionAsync(requirement, pointer.Current, ct); + 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) { @@ -47,17 +55,19 @@ public sealed class LocalPackageStore(string root, IFileHasher hasher) : ILocalP 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 await ActivateAsync(manifest.PackageId, manifest.Version, ct); } catch { if (Directory.Exists(staging)) Directory.Delete(staging, true); throw; } } - private async Task VerifyVersionAsync(string packageId, string version, string product, int productYear, string architecture, CancellationToken ct) + private async Task VerifyVersionAsync(PackageRequirement requirement, string version, CancellationToken ct) { string versionRoot; - try { versionRoot = VersionDirectory(packageId, version); } + try { versionRoot = VersionDirectory(requirement.PackageId, version); } catch { return null; } var manifestPath = Path.Combine(versionRoot, StoredManifestFileName); - if (!Directory.Exists(versionRoot) || new DirectoryInfo(versionRoot).Attributes.HasFlag(FileAttributes.ReparsePoint) || !File.Exists(manifestPath) || new FileInfo(manifestPath).Attributes.HasFlag(FileAttributes.ReparsePoint)) return null; + 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; - try { manifest = PackageManifestJson.Parse(await File.ReadAllTextAsync(manifestPath, ct)); } + CacheBinding binding; + try { manifest = PackageManifestJson.Parse(await File.ReadAllTextAsync(manifestPath, ct)); binding = JsonSerializer.Deserialize(await File.ReadAllTextAsync(bindingPath, ct))!; } catch { return null; } - if (!string.Equals(manifest.PackageId, packageId, StringComparison.Ordinal) || !string.Equals(manifest.Version, version, StringComparison.Ordinal) || !string.Equals(manifest.Product, product, StringComparison.Ordinal) || manifest.ProductYear != productYear || !string.Equals(manifest.Architecture, architecture, StringComparison.OrdinalIgnoreCase) || !new PackageManifestValidator().Validate(manifest, productYear, architecture).IsValid) 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); @@ -78,4 +88,5 @@ public sealed class LocalPackageStore(string root, IFileHasher hasher) : ILocalP 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); } diff --git a/docs/architecture/package-activation.md b/docs/architecture/package-activation.md index 04806ba..cf3af7f 100644 --- a/docs/architecture/package-activation.md +++ b/docs/architecture/package-activation.md @@ -7,9 +7,18 @@ Rollback restores the exact pointers, deletes the pilot version and startup scri ## Last-known-good resolution Each activated version retains its validated package manifest alongside its payload. -`LocalPackageStore.ResolveAsync` revalidates the manifest, package identity/product -compatibility, entry payload and every payload SHA-256 before returning a local -version. It returns current first. Only when current is invalid and the caller -explicitly permits offline fallback can it return previous. It never reads or -executes a package from DFS, and a failed or corrupted update never becomes -last-known-good. +`PackageResolutionUseCase` receives an explicit roster requirement: package ID, +product/year/architecture, channel, exact version, roster identity and offline +policy. On an online acquisition it stages locally, binds that requirement to the +activated version, then revalidates through `LocalPackageStore.ResolveAsync`. + +The resolver checks manifest, binding, identity/product compatibility, entry and +all payload lengths/SHA-256 before returning a local version. It returns current +first. Only when current is invalid and the requirement explicitly permits offline +fallback can it return previous. A cache entry with another roster, channel or +version is rejected. It never reads or executes a package from DFS, and a failed +or corrupted update never becomes last-known-good. + +Resolution emits stable evidence codes: `ONLINE_PACKAGE_SELECTED`, +`VALID_CURRENT_CACHE_SELECTED`, `PREVIOUS_LKG_SELECTED`, +`OFFLINE_FALLBACK_DISABLED`, and `NO_SAFE_PACKAGE_AVAILABLE`. diff --git a/docs/evidence/controlled-execution-foundation.json b/docs/evidence/controlled-execution-foundation.json index 265fb9f..3db90c1 100644 --- a/docs/evidence/controlled-execution-foundation.json +++ b/docs/evidence/controlled-execution-foundation.json @@ -1 +1 @@ -{"nextPilotModeImplemented":true,"pilotConfigurationImplemented":true,"pilotRootPolicyImplemented":true,"productDiscoveryImplemented":true,"profileDiscoveryImplemented":true,"safetyGateImplemented":true,"prepareImplemented":true,"executionPermitImplemented":true,"isolatedStoreImplemented":true,"atomicActivationImplemented":true,"rollbackImplemented":true,"startupScriptLifecycleImplemented":true,"realProcessAdapterCompiled":true,"realExecutionReleaseEnabled":false,"actualProcessStarted":false,"actualPilotRootCreated":false,"actualPackageActivated":false,"productionDeploy":false} +{"nextPilotModeImplemented":true,"pilotConfigurationImplemented":true,"pilotRootPolicyImplemented":true,"productDiscoveryImplemented":true,"profileDiscoveryImplemented":true,"safetyGateImplemented":true,"prepareImplemented":true,"executionPermitImplemented":true,"isolatedStoreImplemented":true,"atomicActivationImplemented":true,"rollbackImplemented":true,"offlineResolutionImplemented":true,"offlineResolutionVerifiedByTests":true,"productionCatalogOutageRuntimeVerified":false,"startupScriptLifecycleImplemented":true,"realProcessAdapterCompiled":true,"realExecutionReleaseEnabled":false,"actualProcessStarted":false,"actualPilotRootCreated":false,"actualPackageActivated":false,"productionDeploy":false} diff --git a/docs/foundation.md b/docs/foundation.md index d912a62..a70dc29 100644 --- a/docs/foundation.md +++ b/docs/foundation.md @@ -1,6 +1,11 @@ # Foundation scope -Launcher Next is intentionally non-deploying. Package synchronization accepts only a supplied local test source; no production UNC client is wired. Process launch preparation is dry-run only, and the WPF launch button is disabled. The next authorized slice is registry discovery validation and a non-production package catalog pilot. +Launcher Next is intentionally non-deploying. Package synchronization accepts only a supplied local test source; no production UNC client is wired. Process launch preparation is dry-run only, and the WPF launch button is disabled. + +Package selection now has a product-neutral orchestration contract: an explicit +roster requirement is resolved through catalog acquisition/local staging, or—only +with explicit policy—through a fully revalidated current/previous local cache. +This is verified by local integration tests, not by a production catalog outage. `tests/Launcher.Next.Tests/fixtures/p-rec-annot-pilot.manifest.json` is a synthetic P_Rec_Annot contract. Its all-zero hash is deliberately replaced by a hash of a test-created one-byte placeholder; it does not identify or package a real Autodesk plugin. diff --git a/tests/Launcher.Next.Tests/FoundationTests.cs b/tests/Launcher.Next.Tests/FoundationTests.cs index e2b5976..87f6407 100644 --- a/tests/Launcher.Next.Tests/FoundationTests.cs +++ b/tests/Launcher.Next.Tests/FoundationTests.cs @@ -7,7 +7,8 @@ namespace Launcher.Next.Tests; public sealed class FoundationTests { - private static PackageManifest Manifest(params PackageFile[] files) => new(1, "P_Rec_Annot", "2026.1.0", "Plant3D", 2026, "x64", "PRecAnnot.Plant3D2026.dll", files, new("netload", "PHP_REC_ANNOT")); + private static PackageManifest Manifest(params PackageFile[] files) => new(1, "P_Rec_Annot", "2026.1.0", "Plant3D", 2026, "x64", "PRecAnnot.Plant3D2026.dll", files, new("netload", "PHP_REC_ANNOT"), RolloutChannel: "pilot"); + private static PackageRequirement Requirement(string version = "2026.1.0", bool offline = true, string product = "Plant3D", int year = 2026, string architecture = "x64", string channel = "pilot", string roster = "project-a") => new("P_Rec_Annot", product, year, architecture, channel, version, roster, offline); private static PackageFile Package(string path, string hash = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") => new(path, 1, hash); [Fact] public void Manifest_ValidPackage_IsAccepted() => Assert.True(new PackageManifestValidator().Validate(Manifest(Package("PRecAnnot.Plant3D2026.dll")), 2026, "x64").IsValid); [Fact] public void Manifest_PathTraversal_IsRejected() => Assert.False(new PackageManifestValidator().Validate(Manifest(Package("../evil.dll")), 2026, "x64").IsValid); @@ -26,10 +27,10 @@ public sealed class FoundationTests var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); var store = new LocalPackageStore(root, new Sha256FileHasher()); try { - await System.IO.File.WriteAllTextAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), "version-one"); var firstHash = Convert.ToHexString(SHA256.HashData(await System.IO.File.ReadAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll")))); await store.SynchronizeAsync(Manifest(new PackageFile("PRecAnnot.Plant3D2026.dll", 11, firstHash)), source, CancellationToken.None); - await System.IO.File.WriteAllTextAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), "version-two"); var secondHash = Convert.ToHexString(SHA256.HashData(await System.IO.File.ReadAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll")))); var second = Manifest(new PackageFile("PRecAnnot.Plant3D2026.dll", 11, secondHash)) with { Version = "2026.2.0" }; await store.SynchronizeAsync(second, source, CancellationToken.None); + await System.IO.File.WriteAllTextAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), "version-one"); var firstHash = Convert.ToHexString(SHA256.HashData(await System.IO.File.ReadAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll")))); await store.SynchronizeAsync(Manifest(new PackageFile("PRecAnnot.Plant3D2026.dll", 11, firstHash)), source, CancellationToken.None); await store.BindAsync(Requirement(), "2026.1.0", CancellationToken.None); + await System.IO.File.WriteAllTextAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), "version-two"); var secondHash = Convert.ToHexString(SHA256.HashData(await System.IO.File.ReadAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll")))); var second = Manifest(new PackageFile("PRecAnnot.Plant3D2026.dll", 11, secondHash)) with { Version = "2026.2.0" }; await store.SynchronizeAsync(second, source, CancellationToken.None); await store.BindAsync(Requirement("2026.2.0"), "2026.2.0", CancellationToken.None); await System.IO.File.WriteAllTextAsync(Path.Combine(root, "Applications", "P_Rec_Annot", "versions", "2026.2.0", "PRecAnnot.Plant3D2026.dll"), "corrupt"); - var resolved = await store.ResolveAsync("P_Rec_Annot", "Plant3D", 2026, "x64", true, CancellationToken.None); + var resolved = await store.ResolveAsync(Requirement("2026.1.0"), CancellationToken.None); Assert.NotNull(resolved); Assert.Equal("2026.1.0", resolved!.Version); Assert.True(resolved.IsOfflineFallback); Assert.Equal("PREVIOUS_VERIFIED_AFTER_CURRENT_REJECTED", resolved.Reason); } finally { Directory.Delete(root, true); } @@ -37,7 +38,7 @@ public sealed class FoundationTests [Fact] public async Task LastKnownGood_DoesNotFallback_WhenOfflinePolicyIsDisabled() { var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); var bytes = new byte[] { 1 }; await System.IO.File.WriteAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), bytes); var hash = Convert.ToHexString(SHA256.HashData(bytes)); var store = new LocalPackageStore(root, new Sha256FileHasher()); - try { await store.SynchronizeAsync(Manifest(new PackageFile("PRecAnnot.Plant3D2026.dll", 1, hash)), source, CancellationToken.None); await store.SynchronizeAsync((Manifest(new PackageFile("PRecAnnot.Plant3D2026.dll", 1, hash)) with { Version = "2026.2.0" }), source, CancellationToken.None); System.IO.File.Delete(Path.Combine(root, "Applications", "P_Rec_Annot", "versions", "2026.2.0", "PRecAnnot.Plant3D2026.dll")); Assert.Null(await store.ResolveAsync("P_Rec_Annot", "Plant3D", 2026, "x64", false, CancellationToken.None)); } + try { await store.SynchronizeAsync(Manifest(new PackageFile("PRecAnnot.Plant3D2026.dll", 1, hash)), source, CancellationToken.None); await store.BindAsync(Requirement(), "2026.1.0", CancellationToken.None); await store.SynchronizeAsync((Manifest(new PackageFile("PRecAnnot.Plant3D2026.dll", 1, hash)) with { Version = "2026.2.0" }), source, CancellationToken.None); await store.BindAsync(Requirement("2026.2.0", false), "2026.2.0", CancellationToken.None); System.IO.File.Delete(Path.Combine(root, "Applications", "P_Rec_Annot", "versions", "2026.2.0", "PRecAnnot.Plant3D2026.dll")); Assert.Null(await store.ResolveAsync(Requirement("2026.1.0", false), CancellationToken.None)); } finally { Directory.Delete(root, true); } } [Fact] public async Task Synchronizer_RejectsReservedStoredManifestPayload() diff --git a/tests/Launcher.Next.Tests/Launcher.Next.Tests.csproj b/tests/Launcher.Next.Tests/Launcher.Next.Tests.csproj index 34cb930..5e2a7ee 100644 --- a/tests/Launcher.Next.Tests/Launcher.Next.Tests.csproj +++ b/tests/Launcher.Next.Tests/Launcher.Next.Tests.csproj @@ -7,6 +7,7 @@ + diff --git a/tests/Launcher.Next.Tests/PackageResolutionUseCaseTests.cs b/tests/Launcher.Next.Tests/PackageResolutionUseCaseTests.cs new file mode 100644 index 0000000..76cf3c3 --- /dev/null +++ b/tests/Launcher.Next.Tests/PackageResolutionUseCaseTests.cs @@ -0,0 +1,60 @@ +using System.Security.Cryptography; +using Launcher.Next.Application; +using Launcher.Next.Core; +using Launcher.Next.Infrastructure; +using Xunit; + +namespace Launcher.Next.Tests; + +public sealed class PackageResolutionUseCaseTests +{ + private static PackageRequirement Requirement(string version, bool offline = true, string product = "Plant3D", int year = 2026, string architecture = "x64", string channel = "pilot", string roster = "project-a") => new("P_Rec_Annot", product, year, architecture, channel, version, roster, offline); + private static PackageManifest Manifest(string version, byte[] bytes, string product = "Plant3D", int year = 2026, string architecture = "x64", string channel = "pilot") => new(1, "P_Rec_Annot", version, product, year, architecture, "PRecAnnot.Plant3D2026.dll", [new("PRecAnnot.Plant3D2026.dll", bytes.Length, Convert.ToHexString(SHA256.HashData(bytes)))], new("netload", "PHP_REC_ANNOT"), RolloutChannel: channel); + [Fact] public async Task OnlineSuccess_StagesBindsAndRecordsOnlineEvidence() + { + var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); var bytes = new byte[] { 1, 2 }; await File.WriteAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), bytes); var manifest = Manifest("1.0.0", bytes); var store = new LocalPackageStore(root, new Sha256FileHasher()); + try { var result = await UseCase(new(new CatalogPackage(manifest, source))).ResolveAsync(Requirement("1.0.0"), CancellationToken.None); Assert.True(result.IsResolved); Assert.True(result.Online); Assert.False(result.Selected!.IsOfflineFallback); Assert.Contains(result.Evidence, x => x.Code == "ONLINE_PACKAGE_SELECTED"); Assert.False(result.Selected.LocalPackageRoot.StartsWith("\\\\", StringComparison.Ordinal)); } + finally { Directory.Delete(root, true); } + PackageResolutionUseCase UseCase(FakeCatalog catalog) => new(catalog, store, store, store, new Telemetry()); + } + [Fact] public async Task ControlledOfflineRecovery_CorruptB_SelectsVerifiedAAndEvidence() + { + var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); var store = new LocalPackageStore(root, new Sha256FileHasher()); + try + { + var a = new byte[] { 1 }; await File.WriteAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), a); await store.SynchronizeAsync(Manifest("1.0.0", a), source, CancellationToken.None); await store.BindAsync(Requirement("1.0.0"), "1.0.0", CancellationToken.None); + var b = new byte[] { 2 }; await File.WriteAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), b); await store.SynchronizeAsync(Manifest("2.0.0", b), source, CancellationToken.None); await store.BindAsync(Requirement("2.0.0"), "2.0.0", CancellationToken.None); + await File.WriteAllBytesAsync(Path.Combine(root, "Applications", "P_Rec_Annot", "versions", "2.0.0", "PRecAnnot.Plant3D2026.dll"), new byte[] { 9 }); + var result = await new PackageResolutionUseCase(new FakeCatalog(null), store, store, store, new Telemetry()).ResolveAsync(Requirement("1.0.0"), CancellationToken.None); + Assert.True(result.IsResolved); Assert.False(result.Online); Assert.Equal("1.0.0", result.Selected!.Version); Assert.True(result.Selected.IsOfflineFallback); Assert.Contains(result.Evidence, x => x.Code == "PREVIOUS_LKG_SELECTED"); Assert.Contains(result.Evidence, x => x.Code == "CATALOG_UNAVAILABLE"); + } + finally { Directory.Delete(root, true); } + } + [Fact] public async Task UnavailableCatalog_WithFallbackDisabled_BlocksWithoutSelectingPrevious() + { + var store = new LocalPackageStore(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")), new Sha256FileHasher()); + var result = await new PackageResolutionUseCase(new FakeCatalog(null), store, store, store, new Telemetry()).ResolveAsync(Requirement("1.0.0", false), CancellationToken.None); + Assert.False(result.IsResolved); Assert.Contains(result.Evidence, x => x.Code == "OFFLINE_FALLBACK_DISABLED"); Assert.Contains(result.Evidence, x => x.Code == "NO_SAFE_PACKAGE_AVAILABLE"); + } + [Fact] public async Task OnlineProductMismatch_IsRejectedAndBlocked() + { + var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); var bytes = new byte[] { 1 }; await File.WriteAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), bytes); var store = new LocalPackageStore(root, new Sha256FileHasher()); + try { var result = await new PackageResolutionUseCase(new FakeCatalog(new CatalogPackage(Manifest("1.0.0", bytes, "Revit"), source)), store, store, store, new Telemetry()).ResolveAsync(Requirement("1.0.0"), CancellationToken.None); Assert.False(result.IsResolved); Assert.Contains(result.Evidence, x => x.Code == "NO_SAFE_PACKAGE_AVAILABLE"); } + finally { Directory.Delete(root, true); } + } + [Theory] + [InlineData("Revit", 2026, "x64", "pilot", "1.0.0", "project-a")] + [InlineData("Plant3D", 2025, "x64", "pilot", "1.0.0", "project-a")] + [InlineData("Plant3D", 2026, "x86", "pilot", "1.0.0", "project-a")] + [InlineData("Plant3D", 2026, "x64", "stable", "1.0.0", "project-a")] + [InlineData("Plant3D", 2026, "x64", "pilot", "2.0.0", "project-a")] + [InlineData("Plant3D", 2026, "x64", "pilot", "1.0.0", "project-b")] + public async Task CachedPackage_RejectsIncompatibleRequirement(string product, int year, string architecture, string channel, string version, string roster) + { + var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); var bytes = new byte[] { 1 }; await File.WriteAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), bytes); var store = new LocalPackageStore(root, new Sha256FileHasher()); + try { await store.SynchronizeAsync(Manifest("1.0.0", bytes), source, CancellationToken.None); await store.BindAsync(Requirement("1.0.0"), "1.0.0", CancellationToken.None); Assert.Null(await store.ResolveAsync(Requirement(version, true, product, year, architecture, channel, roster), CancellationToken.None)); } + finally { Directory.Delete(root, true); } + } + private sealed class FakeCatalog(CatalogPackage? package) : IPackageCatalogPackageAcquirer { public Task AcquireAsync(PackageRequirement requirement, CancellationToken cancellationToken) => Task.FromResult(package); } + private sealed class Telemetry : ILauncherTelemetry { public void Write(string eventType, IReadOnlyDictionary? fields = null) { } } +}