using System.Text.RegularExpressions; using System.Text.Json; using System.Text.Json.Nodes; namespace Launcher.Next.Core; public sealed record LauncherPathVariables(IReadOnlyDictionary Values) { public string? this[string name] => Values.TryGetValue(name, out var value) ? value : null; } public sealed record ResolvedLauncherPaths(IReadOnlyDictionary Values) { public string Require(string name) => Values.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value) ? value : throw new InvalidOperationException($"PATH_VARIABLE_REQUIRED:{name}"); public string? Get(string name) => Values.TryGetValue(name, out var value) ? value : null; public string LocalLauncherRoot => Require(nameof(LocalLauncherRoot)); public string AcadExecutable => Require(nameof(AcadExecutable)); public string? AutomationScriptPath => Get(nameof(AutomationScriptPath)); 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 sealed record ResolvedLegacySettingsPath { internal ResolvedLegacySettingsPath(string value) => Value=value; public string Value { get; } } public sealed record ResolvedLegacyDownloadListPath { internal ResolvedLegacyDownloadListPath(string value) => Value=value; public string Value { get; } } public sealed record ResolvedLegacyCompatibilityOverlayPath { internal ResolvedLegacyCompatibilityOverlayPath(string value) => Value=value; public string Value { get; } } public sealed record ResolvedLegacyProbeEvidenceRoot { internal ResolvedLegacyProbeEvidenceRoot(string value) => Value=value; public string Value { get; } } public sealed record ResolvedLocalPackageCacheRoot { internal ResolvedLocalPackageCacheRoot(string value)=>Value=value; public string Value{get;} } public sealed record ResolvedPackageVersionStoreRoot { internal ResolvedPackageVersionStoreRoot(string value)=>Value=value; public string Value{get;} } public sealed record ResolvedPackageStagingRoot { internal ResolvedPackageStagingRoot(string value)=>Value=value; public string Value{get;} } public sealed record ResolvedActivePackageRoot { internal ResolvedActivePackageRoot(string value)=>Value=value; public string Value{get;} } public sealed record ResolvedPackageLifecycleEvidenceRoot { internal ResolvedPackageLifecycleEvidenceRoot(string value)=>Value=value; public string Value{get;} } public sealed record ResolvedPackagePilotRoot { internal ResolvedPackagePilotRoot(string value)=>Value=value; public string Value{get;} } public sealed record ResolvedPackageCatalogRoot { internal ResolvedPackageCatalogRoot(string value)=>Value=value; public string Value{get;} } public sealed record ResolvedImmutablePackagePath { internal ResolvedImmutablePackagePath(string value)=>Value=value; public string Value{get;} } public sealed record ResolvedPackageLifecycleRoots( ResolvedPackagePilotRoot Pilot, ResolvedPackageCatalogRoot Catalog, ResolvedPackageStagingRoot Staging, ResolvedPackageVersionStoreRoot VersionStore, ResolvedActivePackageRoot Active, ResolvedPackageLifecycleEvidenceRoot Evidence); public interface IPathResolver { ResolvedLauncherPaths Resolve(LauncherPathVariables variables, IEnumerable? required = null); string ResolvePath(string value, LauncherPathVariables variables, string? constrainedRoot = null); ResolvedCatalogRoot ResolveCatalogRoot(LauncherPathVariables variables); ResolvedLegacySettingsPath ResolveLegacySettingsPath(LauncherPathVariables variables); ResolvedLegacyDownloadListPath ResolveLegacyDownloadListPath(LauncherPathVariables variables); ResolvedLegacyCompatibilityOverlayPath ResolveLegacyCompatibilityOverlayPath(LauncherPathVariables variables); ResolvedLegacyProbeEvidenceRoot ResolveLegacyProbeEvidenceRoot(LauncherPathVariables variables); ResolvedPackagePilotRoot ResolvePackagePilotRoot(string value); ResolvedPackageLifecycleRoots ResolvePackageLifecycleRoots(string value); } public sealed class PathResolver(IReadOnlyDictionary? environment = null) : IPathResolver { private static readonly Regex Token = new(@"\$\{(?[A-Za-z][A-Za-z0-9_]*)\}", RegexOptions.Compiled); private readonly IReadOnlyDictionary environment = environment ?? Environment.GetEnvironmentVariables() .Cast().ToDictionary(x => (string)x.Key, x => x.Value?.ToString(), StringComparer.OrdinalIgnoreCase); public ResolvedLauncherPaths Resolve(LauncherPathVariables variables, IEnumerable? required = null) { var resolved = new Dictionary(StringComparer.OrdinalIgnoreCase); // Explicit null values represent optional paths (for example the Apply // permit during Preview). They are retained as null rather than being // treated as an unknown variable; required values are still enforced // below. foreach (var name in variables.Values.Keys) resolved[name] = variables[name] is null ? null : ResolveVariable(name, variables, resolved, []); foreach (var name in resolved.Keys.ToArray()) if (resolved[name] is { } value && value.StartsWith("A:\\", StringComparison.OrdinalIgnoreCase)) { var corporate = ResolveVariable(LauncherPathVariableNames.CorporateDfsRoot, variables, resolved, []) ?? throw new InvalidOperationException("PATH_VARIABLE_EMPTY:CorporateDfsRoot"); resolved[name] = Path.Combine(corporate, value[3..]); } foreach (var name in required ?? []) if (!resolved.TryGetValue(name, out var value) || string.IsNullOrWhiteSpace(value)) throw new InvalidOperationException($"PATH_VARIABLE_REQUIRED:{name}"); return new(resolved); } public string ResolvePath(string value, LauncherPathVariables variables, string? constrainedRoot = null) { var resolved = Expand(value, variables, new Dictionary(StringComparer.OrdinalIgnoreCase), []); if (string.IsNullOrWhiteSpace(resolved) || !Path.IsPathFullyQualified(resolved)) throw new InvalidOperationException("PATH_ABSOLUTE_REQUIRED"); if (resolved.StartsWith("A:\\", StringComparison.OrdinalIgnoreCase)) { var corporate = Resolve(variables, [LauncherPathVariableNames.CorporateDfsRoot]).Require(LauncherPathVariableNames.CorporateDfsRoot); resolved = Path.Combine(corporate, resolved[3..]); } var full = Path.GetFullPath(resolved); if (full.StartsWith("A:\\", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("LEGACY_DRIVE_A_UNRESOLVED"); if (!string.IsNullOrWhiteSpace(constrainedRoot)) { var root = Path.GetFullPath(ResolvePath(constrainedRoot, variables)); if (!full.Equals(root, StringComparison.OrdinalIgnoreCase) && !full.StartsWith(root.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("PATH_OUTSIDE_CONSTRAINED_ROOT"); } 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); } public ResolvedLegacySettingsPath ResolveLegacySettingsPath(LauncherPathVariables variables) => new(ResolveExistingFile("LegacySettingsPath", variables)); public ResolvedLegacyDownloadListPath ResolveLegacyDownloadListPath(LauncherPathVariables variables) => new(ResolveExistingFile("LegacyDownloadListPath", variables)); public ResolvedLegacyCompatibilityOverlayPath ResolveLegacyCompatibilityOverlayPath(LauncherPathVariables variables) => new(ResolveExistingFile("LegacyCompatibilityOverlayPath", variables)); public ResolvedLegacyProbeEvidenceRoot ResolveLegacyProbeEvidenceRoot(LauncherPathVariables variables) { var value=ResolvePath(Resolve(variables,["LegacyProbeEvidenceRoot"]).Require("LegacyProbeEvidenceRoot"),variables); if(value.StartsWith(@"\\",StringComparison.Ordinal)) throw new InvalidOperationException("LEGACY_PROBE_EVIDENCE_ROOT_NOT_LOCAL"); return new(value); } public ResolvedPackagePilotRoot ResolvePackagePilotRoot(string value) { if (string.IsNullOrWhiteSpace(value)||Uri.IsWellFormedUriString(value,UriKind.Absolute)||value.Contains('*')||value.Contains('?')||value.StartsWith(@"\\")||!Path.IsPathFullyQualified(value)) throw new InvalidOperationException("PACKAGE_PILOT_ROOT_NOT_LOCAL"); var full=Path.GetFullPath(value); var info=new DirectoryInfo(full); if(info.Exists&&info.Attributes.HasFlag(FileAttributes.ReparsePoint)) throw new InvalidOperationException("PACKAGE_ROOT_REPARSE_REJECTED"); return new(full); } public ResolvedPackageLifecycleRoots ResolvePackageLifecycleRoots(string value) { var pilot=ResolvePackagePilotRoot(value); string Child(string name)=>Path.GetFullPath(Path.Combine(pilot.Value,name)); var paths=new[]{Child("catalog"),Child("staging"),Child("version-store"),Child("active"),Child("evidence")}; if(paths.Distinct(StringComparer.OrdinalIgnoreCase).Count()!=paths.Length) throw new InvalidOperationException("PACKAGE_ROOT_COLLISION"); foreach(var path in paths) if(!path.StartsWith(pilot.Value.TrimEnd(Path.DirectorySeparatorChar)+Path.DirectorySeparatorChar,StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("PACKAGE_ROOT_TOPOLOGY_INVALID"); return new(pilot,new(paths[0]),new(paths[1]),new(paths[2]),new(paths[3]),new(paths[4])); } private string ResolveExistingFile(string role, LauncherPathVariables variables) { var value=ResolvePath(Resolve(variables,[role]).Require(role),variables); if(!File.Exists(value) || Directory.Exists(value)) throw new InvalidOperationException("LEGACY_PROBE_INPUT_NOT_FILE"); return value; } private string? ResolveVariable(string name, LauncherPathVariables variables, Dictionary resolved, HashSet stack) { if (resolved.TryGetValue(name, out var cached)) return cached; if (!stack.Add(name)) throw new InvalidOperationException($"PATH_VARIABLE_CYCLE:{name}"); try { var source = variables[name] ?? (environment.TryGetValue(name, out var fromEnvironment) ? fromEnvironment : null); if (source is null) throw new InvalidOperationException($"PATH_VARIABLE_UNKNOWN:{name}"); var expanded = Token.Replace(source, match => ResolveVariable(match.Groups["name"].Value, variables, resolved, stack) ?? throw new InvalidOperationException($"PATH_VARIABLE_EMPTY:{match.Groups["name"].Value}")); expanded = expanded.Replace('/', Path.DirectorySeparatorChar); resolved[name] = expanded; return expanded; } finally { stack.Remove(name); } } private string Expand(string value, LauncherPathVariables variables, Dictionary resolved, HashSet stack) => Token.Replace(value, match => ResolveVariable(match.Groups["name"].Value, variables, resolved, stack) ?? throw new InvalidOperationException($"PATH_VARIABLE_EMPTY:{match.Groups["name"].Value}")); } public static class LauncherPathVariableNames { public const string CorporateDfsRoot = "CorporateDfsRoot"; public const string CorporateLauncherRoot = "CorporateLauncherRoot"; public const string CorporateCatalogRoot = "CorporateCatalogRoot"; public const string CorporatePackagesRoot = "CorporatePackagesRoot"; public const string CorporateProfilesRoot = "CorporateProfilesRoot"; public const string CorporatePalettesRoot = "CorporatePalettesRoot"; public const string CorporateFontsRoot = "CorporateFontsRoot"; public const string CorporateTemplatesRoot = "CorporateTemplatesRoot"; public const string CorporateScriptsRoot = "CorporateScriptsRoot"; public const string ProjectName = "ProjectName"; public const string DisciplineName = "DisciplineName"; public const string ProjectRoot = "ProjectRoot"; public const string DisciplineRoot = "DisciplineRoot"; public const string PlantProjectFile = "PlantProjectFile"; public const string ProjectRosterPath = "ProjectRosterPath"; public const string ProjectApplicationsRoot = "ProjectApplicationsRoot"; public const string ProjectPaletteOverlayRoot = "ProjectPaletteOverlayRoot"; public const string LocalLauncherRoot = "LocalLauncherRoot"; public const string LocalPackageCache = "LocalPackageCache"; public const string LocalActiveRoot = "LocalActiveRoot"; public const string LocalSessionRoot = "LocalSessionRoot"; public const string LocalEvidenceRoot = "LocalEvidenceRoot"; public const string LocalLogsRoot = "LocalLogsRoot"; public const string LocalRollbackRoot = "LocalRollbackRoot"; public const string AcadExecutable = "AcadExecutable"; public const string PRecEntryAssembly = "PRecEntryAssembly"; public const string FixtureOrthoDwg = "FixtureOrthoDwg"; public const string FixtureModelDwg = "FixtureModelDwg"; public const string AutomationScriptPath = "AutomationScriptPath"; public const string AutomationConfigPath = "AutomationConfigPath"; public const string AutomationEvidencePath = "AutomationEvidencePath"; public const string AutomationPermitPath = "AutomationPermitPath"; public const string AutomationTranscriptPath = "AutomationTranscriptPath"; } public static class LauncherConfigurationLayers { private static readonly HashSet Protected = [LauncherPathVariableNames.CorporateDfsRoot, LauncherPathVariableNames.AcadExecutable, LauncherPathVariableNames.LocalLauncherRoot]; public static PilotConfigurationLoadResult Load(string? corporateDefaultsJson, string? machineJson, string? sessionJson) { try { var merged = Parse(corporateDefaultsJson); Merge(merged, Parse(machineJson), false); Merge(merged, Parse(sessionJson), false); return PilotConfigurationParser.Parse(merged.ToJsonString()); } catch (JsonException) { return new(PilotLocalConfiguration.SafeDefault, ["PILOT_CONFIGURATION_INVALID"]); } } public static PilotConfigurationLoadResult LoadFromFiles(string corporateDefaultsPath, string machinePath, string sessionPath) { var defaults = File.Exists(corporateDefaultsPath) ? File.ReadAllText(corporateDefaultsPath) : null; var machine = File.Exists(machinePath) ? File.ReadAllText(machinePath) : null; var session = File.Exists(sessionPath) ? File.ReadAllText(sessionPath) : null; return Load(defaults, machine, session); } private static JsonObject Parse(string? json) => string.IsNullOrWhiteSpace(json) ? [] : JsonNode.Parse(json)?.AsObject() ?? []; private static void Merge(JsonObject target, JsonObject overlay, bool roster) { foreach (var entry in overlay) { if (entry.Key.Equals("PathVariables", StringComparison.OrdinalIgnoreCase) && entry.Value is JsonObject paths) { var targetPaths = target["PathVariables"] as JsonObject ?? new JsonObject(); target["PathVariables"] = targetPaths; foreach (var path in paths) if (!roster || !Protected.Contains(path.Key)) targetPaths[path.Key] = path.Value?.DeepClone(); } else target[entry.Key] = entry.Value?.DeepClone(); } } }