feat: resolve roster-bound packages through verified cache

This commit is contained in:
codex
2026-08-05 11:13:40 +03:00
parent 2298fac8cd
commit cea9d70dc3
10 changed files with 172 additions and 28 deletions
@@ -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<PackageResolutionResult> ResolveAsync(PackageRequirement requirement, CancellationToken cancellationToken)
{
var evidence = new List<PackageResolutionEvidence> { new("PACKAGE_REQUIREMENT_RECEIVED", requirement.PackageId) };
telemetry.Write("PackageResolutionStarted", new Dictionary<string, object?> { ["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<string, object?> { ["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<string, object?> { ["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<string, object?> { ["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<string, object?> { ["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);
}
+8 -1
View File
@@ -17,18 +17,25 @@ public sealed record ValidationResult(IReadOnlyList<ValidationIssue> Issues)
{ public bool IsValid => Issues.Count == 0; public static ValidationResult Success { get; } = new([]); } { 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 PackageProgress(string Stage, int Completed, int Total);
public sealed record ActivationResult(string CurrentVersion, string? PreviousVersion); 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<PackageResolutionEvidence> Evidence)
{ public bool IsResolved => Selected is not null; }
public sealed record LaunchRequest(ProductInstallation Product, string Profile, string StartupScriptPath, bool DryRun); public sealed record LaunchRequest(ProductInstallation Product, string Profile, string StartupScriptPath, bool DryRun);
public sealed record PreparedLaunch(string Executable, IReadOnlyList<string> Arguments, string WorkingDirectory, bool WouldStart); public sealed record PreparedLaunch(string Executable, IReadOnlyList<string> Arguments, string WorkingDirectory, bool WouldStart);
public interface IProductDiscovery { ProductInstallation? DiscoverPlant3D(int year); } public interface IProductDiscovery { ProductInstallation? DiscoverPlant3D(int year); }
public interface IPackageCatalogClient { Task<PackageManifest?> GetManifestAsync(string packageId, CancellationToken cancellationToken); } public interface IPackageCatalogClient { Task<PackageManifest?> GetManifestAsync(string packageId, CancellationToken cancellationToken); }
public interface IPackageCatalogPackageAcquirer { Task<CatalogPackage?> AcquireAsync(PackageRequirement requirement, CancellationToken cancellationToken); }
public interface IPackageManifestValidator { ValidationResult Validate(PackageManifest manifest, int productYear, string architecture); } public interface IPackageManifestValidator { ValidationResult Validate(PackageManifest manifest, int productYear, string architecture); }
public interface IPackageSynchronizer { Task<ActivationResult> SynchronizeAsync(PackageManifest manifest, string sourceRoot, CancellationToken cancellationToken, IProgress<PackageProgress>? progress = null); } public interface IPackageSynchronizer { Task<ActivationResult> SynchronizeAsync(PackageManifest manifest, string sourceRoot, CancellationToken cancellationToken, IProgress<PackageProgress>? progress = null); }
public interface ILocalPackageStore { Task<string?> GetCurrentVersionAsync(string packageId, CancellationToken cancellationToken); Task<string?> GetPreviousVersionAsync(string packageId, CancellationToken cancellationToken); } public interface ILocalPackageStore { Task<string?> GetCurrentVersionAsync(string packageId, CancellationToken cancellationToken); Task<string?> GetPreviousVersionAsync(string packageId, CancellationToken cancellationToken); }
public interface IPackageActivator { Task<ActivationResult> ActivateAsync(string packageId, string version, CancellationToken cancellationToken); } public interface IPackageActivator { Task<ActivationResult> ActivateAsync(string packageId, string version, CancellationToken cancellationToken); }
public interface IRollbackManager { Task<string?> RollbackAsync(string packageId, CancellationToken cancellationToken); } public interface IRollbackManager { Task<string?> RollbackAsync(string packageId, CancellationToken cancellationToken); }
public sealed record CachedPackageResolution(string PackageId, string Version, string LocalPackageRoot, bool IsOfflineFallback, string Reason); public sealed record CachedPackageResolution(string PackageId, string Version, string LocalPackageRoot, bool IsOfflineFallback, string Reason);
public interface ILastKnownGoodPackageResolver { Task<CachedPackageResolution?> ResolveAsync(string packageId, string product, int productYear, string architecture, bool allowOfflineFallback, CancellationToken cancellationToken); } public interface ILastKnownGoodPackageResolver { Task<CachedPackageResolution?> ResolveAsync(PackageRequirement requirement, CancellationToken cancellationToken); }
public interface IPackageRequirementCacheBinder { Task BindAsync(PackageRequirement requirement, string version, CancellationToken cancellationToken); }
public interface IAutodeskProcessLauncher { PreparedLaunch Prepare(LaunchRequest request); } 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 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); } public interface IStartupScriptBuilder { string BuildNetloadScript(string validatedPackageRoot, string entryAssembly); string BuildAutomationScript(ResolvedAutomationScriptPaths paths); }
+1 -1
View File
@@ -10,7 +10,7 @@ public sealed class PackageManifestValidator : IPackageManifestValidator
var issues = new List<ValidationIssue>(); var issues = new List<ValidationIssue>();
if (manifest.SchemaVersion != 1) issues.Add(new("schema", "Unsupported schemaVersion.")); 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.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.")); if (!string.Equals(manifest.Architecture, architecture, StringComparison.OrdinalIgnoreCase)) issues.Add(new("architecture", "Package architecture is incompatible."));
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var file in manifest.Files) foreach (var file in manifest.Files)
@@ -5,10 +5,12 @@ namespace Launcher.Next.Infrastructure;
public sealed class Sha256FileHasher : IFileHasher public sealed class Sha256FileHasher : IFileHasher
{ public async Task<string> Sha256Async(string path, CancellationToken cancellationToken) { await using var s = File.OpenRead(path); return Convert.ToHexString(await SHA256.HashDataAsync(s, cancellationToken)); } } { public async Task<string> 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 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 StoredManifestFileName = "package.manifest.json";
private const string StoredBindingFileName = "package.binding.json";
private string PackageRoot(string id) => Path.Combine(root, "Applications", id); private string PackageRoot(string id) => Path.Combine(root, "Applications", id);
private string PointerPath(string id) => Path.Combine(PackageRoot(id), "current.json"); private string PointerPath(string id) => Path.Combine(PackageRoot(id), "current.json");
private async Task<Pointer?> ReadPointer(string id, CancellationToken ct) private async Task<Pointer?> ReadPointer(string id, CancellationToken ct)
@@ -27,15 +29,21 @@ public sealed class LocalPackageStore(string root, IFileHasher hasher) : ILocalP
return new(version, prior?.Current); return new(version, prior?.Current);
} }
public async Task<string?> 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<string?> 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<CachedPackageResolution?> 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<CachedPackageResolution?> ResolveAsync(PackageRequirement requirement, CancellationToken ct)
{
var pointer = await ReadPointer(requirement.PackageId, ct);
if (pointer is null) return null; if (pointer is null) return null;
var current = await VerifyVersionAsync(packageId, pointer.Current, product, productYear, architecture, ct); var current = await VerifyVersionAsync(requirement, pointer.Current, ct);
if (current is not null) return new(packageId, pointer.Current, current, false, "CURRENT_VERIFIED"); if (current is not null) return new(requirement.PackageId, pointer.Current, current, false, "CURRENT_VERIFIED");
if (!allowOfflineFallback || string.IsNullOrWhiteSpace(pointer.Previous) || string.Equals(pointer.Current, pointer.Previous, StringComparison.OrdinalIgnoreCase)) return null; if (!requirement.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); var previous = await VerifyVersionAsync(requirement, pointer.Previous, ct);
return previous is null ? null : new(packageId, pointer.Previous, previous, true, "PREVIOUS_VERIFIED_AFTER_CURRENT_REJECTED"); return previous is null ? null : new(requirement.PackageId, pointer.Previous, previous, true, "PREVIOUS_VERIFIED_AFTER_CURRENT_REJECTED");
} }
public async Task<ActivationResult> SynchronizeAsync(PackageManifest manifest, string sourceRoot, CancellationToken ct, IProgress<PackageProgress>? progress = null) public async Task<ActivationResult> SynchronizeAsync(PackageManifest manifest, string sourceRoot, CancellationToken ct, IProgress<PackageProgress>? 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); } 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; } catch { if (Directory.Exists(staging)) Directory.Delete(staging, true); throw; }
} }
private async Task<string?> VerifyVersionAsync(string packageId, string version, string product, int productYear, string architecture, CancellationToken ct) private async Task<string?> VerifyVersionAsync(PackageRequirement requirement, string version, CancellationToken ct)
{ {
string versionRoot; string versionRoot;
try { versionRoot = VersionDirectory(packageId, version); } try { versionRoot = VersionDirectory(requirement.PackageId, version); }
catch { return null; } catch { return null; }
var manifestPath = Path.Combine(versionRoot, StoredManifestFileName); 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; 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<CacheBinding>(await File.ReadAllTextAsync(bindingPath, ct))!; }
catch { return null; } 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) foreach (var file in manifest.Files)
{ {
var path = PackageFilePath(versionRoot, file.Path); 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"); 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); 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);
} }
+15 -6
View File
@@ -7,9 +7,18 @@ Rollback restores the exact pointers, deletes the pilot version and startup scri
## Last-known-good resolution ## Last-known-good resolution
Each activated version retains its validated package manifest alongside its payload. Each activated version retains its validated package manifest alongside its payload.
`LocalPackageStore.ResolveAsync` revalidates the manifest, package identity/product `PackageResolutionUseCase` receives an explicit roster requirement: package ID,
compatibility, entry payload and every payload SHA-256 before returning a local product/year/architecture, channel, exact version, roster identity and offline
version. It returns current first. Only when current is invalid and the caller policy. On an online acquisition it stages locally, binds that requirement to the
explicitly permits offline fallback can it return previous. It never reads or activated version, then revalidates through `LocalPackageStore.ResolveAsync`.
executes a package from DFS, and a failed or corrupted update never becomes
last-known-good. 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`.
@@ -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}
+6 -1
View File
@@ -1,6 +1,11 @@
# Foundation scope # 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. `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.
+6 -5
View File
@@ -7,7 +7,8 @@ namespace Launcher.Next.Tests;
public sealed class FoundationTests 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); 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_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); [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()); 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 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-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 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"); 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); 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); } finally { Directory.Delete(root, true); }
@@ -37,7 +38,7 @@ public sealed class FoundationTests
[Fact] public async Task LastKnownGood_DoesNotFallback_WhenOfflinePolicyIsDisabled() [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()); 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); } finally { Directory.Delete(root, true); }
} }
[Fact] public async Task Synchronizer_RejectsReservedStoredManifestPayload() [Fact] public async Task Synchronizer_RejectsReservedStoredManifestPayload()
@@ -7,6 +7,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Launcher.Next.Core\Launcher.Next.Core.csproj" /> <ProjectReference Include="..\..\Launcher.Next.Core\Launcher.Next.Core.csproj" />
<ProjectReference Include="..\..\Launcher.Next.Application\Launcher.Next.Application.csproj" />
<ProjectReference Include="..\..\Launcher.Next.Infrastructure\Launcher.Next.Infrastructure.csproj" /> <ProjectReference Include="..\..\Launcher.Next.Infrastructure\Launcher.Next.Infrastructure.csproj" />
<ProjectReference Include="..\..\Launcher.Next.Autodesk\Launcher.Next.Autodesk.csproj" /> <ProjectReference Include="..\..\Launcher.Next.Autodesk\Launcher.Next.Autodesk.csproj" />
</ItemGroup> </ItemGroup>
@@ -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<CatalogPackage?> AcquireAsync(PackageRequirement requirement, CancellationToken cancellationToken) => Task.FromResult(package); }
private sealed class Telemetry : ILauncherTelemetry { public void Write(string eventType, IReadOnlyDictionary<string, object?>? fields = null) { } }
}