From d6ed0ac59252226da5dffa7da10901dff6fb62e5 Mon Sep 17 00:00:00 2001 From: codex Date: Wed, 5 Aug 2026 14:57:16 +0300 Subject: [PATCH] feat: enforce resolved catalog path roles --- Launcher.Next.Core/LauncherPaths.cs | 19 +++++++++++++++++++ .../FileSystemCatalogPackageAcquirer.cs | 4 ++-- .../RosterAdaptersTests.cs | 5 +++-- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/Launcher.Next.Core/LauncherPaths.cs b/Launcher.Next.Core/LauncherPaths.cs index 5d2f529..5fcce57 100644 --- a/Launcher.Next.Core/LauncherPaths.cs +++ b/Launcher.Next.Core/LauncherPaths.cs @@ -20,10 +20,19 @@ public sealed record ResolvedLauncherPaths(IReadOnlyDictionary public string? FixtureOrthoDwg => Get(nameof(FixtureOrthoDwg)); } +/// Catalog source role resolved by . Raw +/// filesystem strings are intentionally not accepted by catalog acquisition. +public sealed record ResolvedCatalogRoot +{ + internal ResolvedCatalogRoot(string value) => Value = value; + public string Value { get; } +} + public interface IPathResolver { ResolvedLauncherPaths Resolve(LauncherPathVariables variables, IEnumerable? required = null); string ResolvePath(string value, LauncherPathVariables variables, string? constrainedRoot = null); + ResolvedCatalogRoot ResolveCatalogRoot(LauncherPathVariables variables); } public sealed class PathResolver(IReadOnlyDictionary? environment = null) : IPathResolver @@ -74,6 +83,16 @@ public sealed class PathResolver(IReadOnlyDictionary? environme return full; } + public ResolvedCatalogRoot ResolveCatalogRoot(LauncherPathVariables variables) + { + var resolved = Resolve(variables, [LauncherPathVariableNames.CorporateCatalogRoot, LauncherPathVariableNames.CorporateDfsRoot]); + var catalog = ResolvePath(resolved.Require(LauncherPathVariableNames.CorporateCatalogRoot), variables); + var corporate = ResolvePath(resolved.Require(LauncherPathVariableNames.CorporateDfsRoot), variables); + if (!catalog.StartsWith(corporate.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("PATH_ROLE_COLLISION"); + return new ResolvedCatalogRoot(catalog); + } + private string? ResolveVariable(string name, LauncherPathVariables variables, Dictionary resolved, HashSet stack) { if (resolved.TryGetValue(name, out var cached)) return cached; diff --git a/Launcher.Next.Infrastructure/FileSystemCatalogPackageAcquirer.cs b/Launcher.Next.Infrastructure/FileSystemCatalogPackageAcquirer.cs index 493b4c1..bda6553 100644 --- a/Launcher.Next.Infrastructure/FileSystemCatalogPackageAcquirer.cs +++ b/Launcher.Next.Infrastructure/FileSystemCatalogPackageAcquirer.cs @@ -7,14 +7,14 @@ namespace Launcher.Next.Infrastructure; /// local immutable staging by ; it is never /// an active launch root. /// -public sealed class FileSystemCatalogPackageAcquirer(string catalogRoot) : IPackageCatalogPackageAcquirer +public sealed class FileSystemCatalogPackageAcquirer(ResolvedCatalogRoot catalogRoot) : IPackageCatalogPackageAcquirer { private const int MaxFiles = 256; private const long MaxBytes = 512L * 1024 * 1024; public async Task AcquireAsync(PackageRequirement requirement, CancellationToken cancellationToken) { - var root = Path.GetFullPath(catalogRoot); + var root = catalogRoot.Value; var candidate = Path.GetFullPath(Path.Combine(root, requirement.PackageId, requirement.RequiredVersion)); if (!Directory.Exists(root) || IsReparsePoint(root) || !IsWithin(root, candidate) || !Directory.Exists(candidate) || IsReparsePoint(candidate)) return null; diff --git a/tests/Launcher.Next.Tests/RosterAdaptersTests.cs b/tests/Launcher.Next.Tests/RosterAdaptersTests.cs index d872b64..129e760 100644 --- a/tests/Launcher.Next.Tests/RosterAdaptersTests.cs +++ b/tests/Launcher.Next.Tests/RosterAdaptersTests.cs @@ -76,7 +76,7 @@ public sealed class RosterAdaptersTests 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); + var result=await new FileSystemCatalogPackageAcquirer(CatalogRoot(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); } @@ -89,10 +89,11 @@ public sealed class RosterAdaptersTests try { 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":"../outside.dll","length":1,"sha256":"00"}],"startup":{"type":"netload","command":"P_A"},"rolloutChannel":"stable"}"""); - Assert.Null(await new FileSystemCatalogPackageAcquirer(root).AcquireAsync(new("P_A","Plant3D",2026,"x64","stable","1.0.0","project-a",true),CancellationToken.None)); + Assert.Null(await new FileSystemCatalogPackageAcquirer(CatalogRoot(root)).AcquireAsync(new("P_A","Plant3D",2026,"x64","stable","1.0.0","project-a",true),CancellationToken.None)); } 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}]}"""; + private static ResolvedCatalogRoot CatalogRoot(string root) => new PathResolver().ResolveCatalogRoot(new(new Dictionary { [LauncherPathVariableNames.CorporateDfsRoot] = Path.GetDirectoryName(root)!, [LauncherPathVariableNames.CorporateCatalogRoot] = root })); }