diff --git a/Launcher.Next.Application/RosterAdapters.cs b/Launcher.Next.Application/RosterAdapters.cs new file mode 100644 index 0000000..3c1a409 --- /dev/null +++ b/Launcher.Next.Application/RosterAdapters.cs @@ -0,0 +1,59 @@ +using System.Xml; +using System.Xml.Linq; +using System.Text.Json; +using Launcher.Next.Core; + +namespace Launcher.Next.Application; + +public enum RosterSource { ProjectRosterJson, LegacyDownloadListXml, CentralDefaultRoster, None } +public sealed record RosterPackage(string Id, string Version, string Channel, bool Enabled, int LoadOrder, bool AllowedForMachine = true); +public sealed record RosterContext(string Product, int ProductYear, string Architecture, bool AllowOfflineFallback, string LegacyRosterId); +public sealed record NormalizedRoster(string RosterId, string Product, int Year, string Architecture, bool AllowOfflineFallback, IReadOnlyList Packages); +public sealed record RosterSelection(RosterSource Source, NormalizedRoster? Roster, string Code); +public sealed class RosterAdapter +{ + public NormalizedRoster ParseJson(string json) + { + using var doc=JsonDocument.Parse(json); var r=doc.RootElement; + if (!r.TryGetProperty("schemaVersion",out var s)||s.GetInt32()!=1) throw new InvalidOperationException("ROSTER_SCHEMA_INVALID"); + string V(string n)=>r.TryGetProperty(n,out var x)&&!string.IsNullOrWhiteSpace(x.GetString())?x.GetString()!:throw new InvalidOperationException("ROSTER_FIELD_REQUIRED:"+n); + var packages=r.GetProperty("packages").EnumerateArray().Select(x=>new RosterPackage(x.GetProperty("id").GetString()??throw new InvalidOperationException("ROSTER_PACKAGE_ID_REQUIRED"),x.GetProperty("version").GetString()??throw new InvalidOperationException("ROSTER_PACKAGE_VERSION_REQUIRED"),x.GetProperty("channel").GetString()??throw new InvalidOperationException("ROSTER_PACKAGE_CHANNEL_REQUIRED"),!x.TryGetProperty("enabled",out var e)||e.GetBoolean(),x.TryGetProperty("loadOrder",out var o)?o.GetInt32():0)).ToArray(); + if(packages.GroupBy(x=>x.Id,StringComparer.OrdinalIgnoreCase).Any(x=>x.Count()>1)) throw new InvalidOperationException("ROSTER_DUPLICATE_PACKAGE"); + return new(V("rosterId"),V("product"),r.GetProperty("year").GetInt32(),V("architecture"),r.TryGetProperty("offlinePolicy",out var p)&&p.GetString()=="AllowVerifiedLastKnownGood",packages.OrderBy(x=>x.LoadOrder).ThenBy(x=>x.Id,StringComparer.Ordinal).ToArray()); + } + public NormalizedRoster ParseLegacyDownloadList(string xml, RosterContext context) + { + var settings=new XmlReaderSettings{DtdProcessing=DtdProcessing.Prohibit,XmlResolver=null,MaxCharactersInDocument=1_000_000}; using var reader=XmlReader.Create(new StringReader(xml),settings); var doc=XDocument.Load(reader); + var packages=doc.Descendants("Plugin").Select((x,i)=>new RosterPackage((string?)x.Attribute("id")??throw new InvalidOperationException("LEGACY_PACKAGE_ID_REQUIRED"),(string?)x.Attribute("version")??throw new InvalidOperationException("AMBIGUOUS_LEGACY_SEMANTICS"),(string?)x.Attribute("channel")??"pilot",!string.Equals((string?)x.Attribute("install"),"false",StringComparison.OrdinalIgnoreCase),i)).ToArray(); + if(packages.GroupBy(x=>x.Id,StringComparer.OrdinalIgnoreCase).Any(x=>x.Count()>1)) throw new InvalidOperationException("ROSTER_DUPLICATE_PACKAGE"); + return new(context.LegacyRosterId,context.Product,context.ProductYear,context.Architecture,context.AllowOfflineFallback,packages); + } + public RosterSelection Select(string? projectJson,string? legacyXml,string? centralJson,RosterContext context) + { if(projectJson is not null)return new(RosterSource.ProjectRosterJson,ParseJson(projectJson),"PROJECT_ROSTER_JSON"); if(legacyXml is not null)return new(RosterSource.LegacyDownloadListXml,ParseLegacyDownloadList(legacyXml,context),"LEGACY_DOWNLOAD_LIST_XML"); if(centralJson is not null)return new(RosterSource.CentralDefaultRoster,ParseJson(centralJson),"CENTRAL_DEFAULT_ROSTER"); return new(RosterSource.None,null,"NO_ROSTER_AVAILABLE"); } + public IReadOnlyList Requirements(NormalizedRoster roster)=>roster.Packages.Where(x=>x.Enabled&&x.AllowedForMachine).Select(x=>new PackageRequirement(x.Id,roster.Product,roster.Year,roster.Architecture,x.Channel,x.Version,roster.RosterId,roster.AllowOfflineFallback)).ToArray(); +} + +public sealed record PackagePlanResult(bool IsComplete,IReadOnlyList Packages,IReadOnlyList Evidence); +public sealed class RosterPackagePlanUseCase(RosterAdapter rosterAdapter,PackageResolutionUseCase packageResolution) +{ + public async Task ResolveAsync(RosterSelection selection,CancellationToken cancellationToken=default) + { + var evidence=new List(); + if(selection.Roster is null){evidence.Add(new("PACKAGE_PLAN_BLOCKED","NO_ROSTER_AVAILABLE"));return new(false,Array.Empty(),evidence);} + evidence.Add(new("ROSTER_SOURCE_SELECTED",$"{selection.Code}:{selection.Roster.RosterId}")); + evidence.Add(new("ROSTER_PARSED",selection.Roster.RosterId)); + evidence.Add(new("ROSTER_VALIDATED",selection.Roster.RosterId)); + var requirements=rosterAdapter.Requirements(selection.Roster); + if(requirements.Count==0){evidence.Add(new("PACKAGE_PLAN_BLOCKED","NO_ENABLED_PACKAGE_REQUIREMENTS"));return new(false,Array.Empty(),evidence);} + var results=new List(); + foreach(var requirement in requirements) + { + evidence.Add(new("PACKAGE_REQUIREMENT_CREATED",$"{requirement.PackageId}:{requirement.RosterIdentity}")); + var result=await packageResolution.ResolveAsync(requirement,cancellationToken); + results.Add(result);evidence.AddRange(result.Evidence); + if(!result.IsResolved){evidence.Add(new("PACKAGE_PLAN_BLOCKED",$"PACKAGE_REQUIREMENT_REJECTED:{requirement.PackageId}"));return new(false,results,evidence);} + } + evidence.Add(new("PACKAGE_PLAN_COMPLETED",selection.Roster.RosterId)); + return new(true,results,evidence); + } +} diff --git a/Launcher.Next.Infrastructure/FileSystemCatalogPackageAcquirer.cs b/Launcher.Next.Infrastructure/FileSystemCatalogPackageAcquirer.cs new file mode 100644 index 0000000..f1632da --- /dev/null +++ b/Launcher.Next.Infrastructure/FileSystemCatalogPackageAcquirer.cs @@ -0,0 +1,32 @@ +using Launcher.Next.Core; + +namespace Launcher.Next.Infrastructure; + +/// +/// Reads a catalog descriptor only. The returned source is later copied into +/// local immutable staging by ; it is never +/// an active launch root. +/// +public sealed class FileSystemCatalogPackageAcquirer(string catalogRoot) : IPackageCatalogPackageAcquirer +{ + public async Task AcquireAsync(PackageRequirement requirement, CancellationToken cancellationToken) + { + var root = Path.GetFullPath(catalogRoot); + var candidate = Path.GetFullPath(Path.Combine(root, requirement.PackageId, requirement.RequiredVersion)); + if (!IsWithin(root, candidate) || !Directory.Exists(candidate) || IsReparsePoint(candidate)) + return null; + + var manifestPath = Path.Combine(candidate, "package.manifest.json"); + if (!File.Exists(manifestPath) || IsReparsePoint(manifestPath)) + return null; + + var manifest = PackageManifestJson.Parse(await File.ReadAllTextAsync(manifestPath, cancellationToken)); + return new CatalogPackage(manifest, candidate); + } + + private static bool IsWithin(string root, string candidate) => + candidate.StartsWith(Path.TrimEndingDirectorySeparator(root) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + + private static bool IsReparsePoint(string path) => + (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0; +} diff --git a/docs/architecture/package-activation.md b/docs/architecture/package-activation.md index cf3af7f..649e7d6 100644 --- a/docs/architecture/package-activation.md +++ b/docs/architecture/package-activation.md @@ -22,3 +22,21 @@ 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`. + +## Roster-to-package plan + +`RosterAdapter` keeps source formats outside the package domain. It selects, +in order, a project `roster.json`, a legacy `download_list.xml`, then an +explicit central default; malformed project JSON is a block, not an implicit +fallback. JSON roster version 1 requires a stable roster ID, product, year, +architecture, explicit offline policy and unique package IDs. Legacy XML disables +DTD/external entities and accepts only proven `Plugin` fields +(`id`, `version`, optional `channel` and `install`). + +`RosterPackagePlanUseCase` converts enabled records into deterministic, +load-order-sorted requirements and resolves every one before launch preparation. +It emits roster/source, requirement and plan-completed/blocked evidence. The +filesystem catalog adapter returns a descriptor for acquisition only; local +immutable staging remains the only active package root. These adapters and the +controlled local catalog scenarios are verified by tests; a corporate DFS pilot +and Autodesk launch with a real roster are not runtime verified. diff --git a/tests/Launcher.Next.Tests/RosterAdaptersTests.cs b/tests/Launcher.Next.Tests/RosterAdaptersTests.cs new file mode 100644 index 0000000..119e0ff --- /dev/null +++ b/tests/Launcher.Next.Tests/RosterAdaptersTests.cs @@ -0,0 +1,86 @@ +using Launcher.Next.Application; +using Launcher.Next.Core; +using Launcher.Next.Infrastructure; +using System.Security.Cryptography; +using Xunit; + +namespace Launcher.Next.Tests; + +public sealed class RosterAdaptersTests +{ + private static readonly RosterContext Context = new("Plant3D", 2026, "x64", true, "legacy-project-a"); + + [Fact] + public void ProjectRoster_ProducesDeterministicRequirements() + { + var selected = new RosterAdapter().Select(Json(), null, null, Context); + var requirements = new RosterAdapter().Requirements(selected.Roster!); + Assert.Equal(RosterSource.ProjectRosterJson, selected.Source); + Assert.Equal(["P_A", "P_B"], requirements.Select(x => x.PackageId)); + Assert.Equal(["1.0.0", "2.0.0"], requirements.Select(x => x.RequiredVersion)); + } + + [Fact] + public void LegacyDownloadList_PreservesSourceOrderAndUsesContext() + { + const string xml = ""; + var selected = new RosterAdapter().Select(null, xml, null, Context); + Assert.Equal(RosterSource.LegacyDownloadListXml, selected.Source); + Assert.Equal("legacy-project-a", selected.Roster!.RosterId); + Assert.Equal(["P_First", "P_Second"], selected.Roster.Packages.Select(x => x.Id)); + Assert.False(selected.Roster.Packages[1].Enabled); + } + + [Fact] + public void ProjectRoster_HasPriorityOverLegacy() + { + var selected = new RosterAdapter().Select(Json(), "", null, Context); + Assert.Equal(RosterSource.ProjectRosterJson, selected.Source); + } + + [Fact] + public void MalformedProjectRoster_DoesNotFallBackImplicitly() + { + Assert.ThrowsAny(() => new RosterAdapter().Select("{", "", Json(), Context)); + } + + [Fact] + public void CentralDefault_IsUsedOnlyWhenProjectAndLegacyAreAbsent() + { + var selected = new RosterAdapter().Select(null, null, Json(), Context); + Assert.Equal(RosterSource.CentralDefaultRoster, selected.Source); + Assert.Equal("CENTRAL_DEFAULT_ROSTER", selected.Code); + } + + [Fact] + public void DuplicatePackage_IsRejected() + { + const string json = """{"schemaVersion":1,"rosterId":"project-a","product":"Plant3D","year":2026,"architecture":"x64","offlinePolicy":"AllowVerifiedLastKnownGood","packages":[{"id":"P_A","version":"1.0.0","channel":"stable"},{"id":"p_a","version":"2.0.0","channel":"stable"}]}"""; + var ex = Assert.Throws(() => new RosterAdapter().ParseJson(json)); + Assert.Equal("ROSTER_DUPLICATE_PACKAGE", ex.Message); + } + + [Fact] + public void LegacyXml_RejectsDtd() + { + const string xml = "]>"; + Assert.ThrowsAny(() => new RosterAdapter().ParseLegacyDownloadList(xml, Context)); + } + + [Fact] + public async Task FileSystemCatalog_ReturnsDescriptorOnly_ForMatchingRosterRequirement() + { + var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N")); var packageRoot=Path.Combine(root,"P_A","1.0.0"); Directory.CreateDirectory(packageRoot); + try + { + var bytes=new byte[]{1}; await File.WriteAllBytesAsync(Path.Combine(packageRoot,"entry.dll"),bytes); + var hash=Convert.ToHexString(SHA256.HashData(bytes)); + await File.WriteAllTextAsync(Path.Combine(packageRoot,"package.manifest.json"), $$"""{"schemaVersion":1,"packageId":"P_A","version":"1.0.0","product":"Plant3D","productYear":2026,"architecture":"x64","entryAssembly":"entry.dll","files":[{"path":"entry.dll","length":1,"sha256":"{{hash}}"}],"startup":{"type":"netload","command":"P_A"},"rolloutChannel":"stable"}"""); + var result=await new FileSystemCatalogPackageAcquirer(root).AcquireAsync(new("P_A","Plant3D",2026,"x64","stable","1.0.0","project-a",true),CancellationToken.None); + Assert.NotNull(result); Assert.Equal(packageRoot,result!.SourceRoot); Assert.False(result.SourceRoot.StartsWith(@"\\",StringComparison.Ordinal)); + } + finally { Directory.Delete(root,true); } + } + + private static string Json() => """{"schemaVersion":1,"rosterId":"project-a","product":"Plant3D","year":2026,"architecture":"x64","offlinePolicy":"AllowVerifiedLastKnownGood","packages":[{"id":"P_B","version":"2.0.0","channel":"stable","loadOrder":200},{"id":"P_A","version":"1.0.0","channel":"stable","loadOrder":100}]}"""; +}