using System.Buffers.Binary; using System.Security.Cryptography; using System.Text.Json; using System.Xml.Linq; namespace XFEEliteDangerous.Client; public sealed record FileEvidence(string Name, long Length, string Sha256); public sealed record PeEvidence(string Machine, string Format, bool Valid); public sealed record ProductInspection(string Directory, string? Name, string? Version, string? Executable, FileEvidence? ExecutableEvidence, PeEvidence? Pe, IReadOnlyList Dictionaries, IReadOnlyList AppConfigSections, IReadOnlyList ServiceHosts, IReadOnlyList Warnings, FileEvidence? VersionInfoEvidence = null, FileEvidence? AppConfigEvidence = null); public sealed record InstallationInspection(string Kind, DateTimeOffset InspectedAt, string Directory, bool LauncherPresent, IReadOnlyList Products, IReadOnlyList Warnings); /// Reads version, hashes and configuration evidence without launching or modifying the installation. public sealed class GameInstallationInspector { public async Task InspectAsync(string gameDirectory, CancellationToken cancellationToken = default) { var root = LocalPaths.Directory(gameDirectory); if (!Directory.Exists(root)) throw new DirectoryNotFoundException(AppText.Get("GameInstallationInspector.Text017")); var productsPath = Path.Combine(root, "Products"); var warnings = new List(); var products = new List(); if (Directory.Exists(productsPath)) foreach (var directory in Directory.EnumerateDirectories(productsPath).Order(StringComparer.OrdinalIgnoreCase)) products.Add(await InspectProductAsync(directory, cancellationToken).ConfigureAwait(false)); else warnings.Add(AppText.Get("GameInstallationInspector.Text018")); return new("elite-installation-evidence", DateTimeOffset.UtcNow, root, File.Exists(Path.Combine(root, "EDLaunch.exe")), products, warnings); } private static async Task InspectProductAsync(string directory, CancellationToken cancel) { string? name = null, version = null, executable = null; FileEvidence? evidence = null, versionEvidence = null, configEvidence = null; PeEvidence? pe = null; var warnings = new List(); var sections = new List(); var hosts = new HashSet(StringComparer.OrdinalIgnoreCase); var dictionaries = new List(); var versionPath = Path.Combine(directory, "VersionInfo.txt"); if (File.Exists(versionPath)) { versionEvidence = await InspectFileAsync(versionPath, cancel).ConfigureAwait(false); try { using var stream = OpenRead(versionPath); using var json = await JsonDocument.ParseAsync(stream, cancellationToken: cancel).ConfigureAwait(false); name = Text(json.RootElement, "name"); version = Text(json.RootElement, "Version"); executable = Text(json.RootElement, "executable"); } catch (JsonException) { warnings.Add(AppText.Get("GameInstallationInspector.Text019")); } } else warnings.Add(AppText.Get("GameInstallationInspector.Text020")); if (!string.IsNullOrWhiteSpace(executable)) { var fullExecutable = Path.GetFullPath(Path.Combine(directory, executable)); if (!IsWithin(directory, fullExecutable)) { warnings.Add(AppText.Get("GameInstallationInspector.Text021")); executable = null; } else if (File.Exists(fullExecutable)) { evidence = await InspectFileAsync(fullExecutable, cancel).ConfigureAwait(false); pe = InspectPe(fullExecutable); } else warnings.Add(AppText.Get("GameInstallationInspector.Text022")); } foreach (var file in Directory.EnumerateFiles(directory, "OodleDictionary*", SearchOption.TopDirectoryOnly).Order()) dictionaries.Add(await InspectFileAsync(file, cancel).ConfigureAwait(false)); var config = Path.Combine(directory, "AppConfig.xml"); if (File.Exists(config)) { configEvidence = await InspectFileAsync(config, cancel).ConfigureAwait(false); try { using var stream = OpenRead(config); var xml = await XDocument.LoadAsync(stream, LoadOptions.None, cancel).ConfigureAwait(false); sections.AddRange(xml.Root?.Elements().Select(x => x.Name.LocalName).Distinct() ?? []); foreach (var attribute in xml.Descendants().Attributes()) { // Export hostnames only. Never export credentials, complete URLs, query strings or arbitrary XML values. if (attribute.Name.LocalName.Contains("token", StringComparison.OrdinalIgnoreCase) || attribute.Name.LocalName.Contains("password", StringComparison.OrdinalIgnoreCase) || attribute.Name.LocalName.Contains("key", StringComparison.OrdinalIgnoreCase)) continue; if (Uri.TryCreate(attribute.Value, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https") hosts.Add(uri.Host); } } catch (System.Xml.XmlException) { warnings.Add(AppText.Get("GameInstallationInspector.Text023")); } } return new(Path.GetFileName(directory), name, version, executable, evidence, pe, dictionaries, sections, hosts.Order(StringComparer.OrdinalIgnoreCase).ToArray(), warnings, versionEvidence, configEvidence); } internal static FileStream OpenRead(string file) => new(LocalPaths.FilePath(file), FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); internal static bool IsWithin(string root, string path) => path.StartsWith(Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); internal static async Task InspectFileAsync(string file, CancellationToken cancel) { using var stream = OpenRead(file); var length = stream.Length; var hash = await SHA256.HashDataAsync(stream, cancel).ConfigureAwait(false); return new(Path.GetFileName(file), length, Convert.ToHexString(hash).ToLowerInvariant()); } private static string? Text(JsonElement element, string property) => element.TryGetProperty(property, out var item) && item.ValueKind == JsonValueKind.String ? item.GetString() : null; private static PeEvidence InspectPe(string file) { using var stream = OpenRead(file); Span dos = stackalloc byte[64]; if (stream.Read(dos) != dos.Length || dos[0] != 'M' || dos[1] != 'Z') return new("unknown", "unknown", false); var offset = BinaryPrimitives.ReadInt32LittleEndian(dos[60..]); if (offset < 64 || offset > stream.Length - 26) return new("unknown", "unknown", false); stream.Position = offset; Span header = stackalloc byte[26]; stream.ReadExactly(header); if (!header[..4].SequenceEqual("PE\0\0"u8)) return new("unknown", "unknown", false); var machine = BinaryPrimitives.ReadUInt16LittleEndian(header[4..]); var format = BinaryPrimitives.ReadUInt16LittleEndian(header[24..]); return new(machine switch { 0x8664 => "AMD64", 0x14c => "I386", 0xaa64 => "ARM64", _ => $"0x{machine:x4}" }, format switch { 0x20b => "PE32+", 0x10b => "PE32", _ => "unknown" }, true); } }