feat: adapt roster sources into package resolution
This commit is contained in:
@@ -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<RosterPackage> 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<PackageRequirement> 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<PackageResolutionResult> Packages,IReadOnlyList<PackageResolutionEvidence> Evidence);
|
||||
public sealed class RosterPackagePlanUseCase(RosterAdapter rosterAdapter,PackageResolutionUseCase packageResolution)
|
||||
{
|
||||
public async Task<PackagePlanResult> ResolveAsync(RosterSelection selection,CancellationToken cancellationToken=default)
|
||||
{
|
||||
var evidence=new List<PackageResolutionEvidence>();
|
||||
if(selection.Roster is null){evidence.Add(new("PACKAGE_PLAN_BLOCKED","NO_ROSTER_AVAILABLE"));return new(false,Array.Empty<PackageResolutionResult>(),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<PackageResolutionResult>(),evidence);}
|
||||
var results=new List<PackageResolutionResult>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Launcher.Next.Core;
|
||||
|
||||
namespace Launcher.Next.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Reads a catalog descriptor only. The returned source is later copied into
|
||||
/// local immutable staging by <see cref="IPackageSynchronizer"/>; it is never
|
||||
/// an active launch root.
|
||||
/// </summary>
|
||||
public sealed class FileSystemCatalogPackageAcquirer(string catalogRoot) : IPackageCatalogPackageAcquirer
|
||||
{
|
||||
public async Task<CatalogPackage?> 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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = "<downloadList><Plugin id='P_First' version='1.0.0' channel='stable'/><Plugin id='P_Second' version='2.0.0' install='false'/></downloadList>";
|
||||
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(), "<downloadList><Plugin id='Legacy' version='1.0.0'/></downloadList>", null, Context);
|
||||
Assert.Equal(RosterSource.ProjectRosterJson, selected.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MalformedProjectRoster_DoesNotFallBackImplicitly()
|
||||
{
|
||||
Assert.ThrowsAny<Exception>(() => new RosterAdapter().Select("{", "<downloadList><Plugin id='Legacy' version='1.0.0'/></downloadList>", 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<InvalidOperationException>(() => new RosterAdapter().ParseJson(json));
|
||||
Assert.Equal("ROSTER_DUPLICATE_PACKAGE", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyXml_RejectsDtd()
|
||||
{
|
||||
const string xml = "<!DOCTYPE x [<!ENTITY e SYSTEM 'file:///not-allowed'>]><downloadList><Plugin id='&e;' version='1.0.0'/></downloadList>";
|
||||
Assert.ThrowsAny<Exception>(() => 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}]}""";
|
||||
}
|
||||
Reference in New Issue
Block a user