using System.IO.Compression; using System.Security.Cryptography; using System.Text.Json; namespace MsPrivate.Launcher; internal sealed class PayloadManifest { public int Schema { get; set; } public string Version { get; set; } = ""; public string EntryPoint { get; set; } = ""; public List Files { get; set; } = []; } internal sealed class PayloadFile { public string Path { get; set; } = ""; public long Length { get; set; } public string Sha256 { get; set; } = ""; } internal static class PayloadInstaller { internal const string ManifestName = "payload.manifest.json"; internal static PayloadManifest ReadAndValidateArchive(byte[] archiveBytes) { using var memory = new MemoryStream(archiveBytes, writable: false); using var archive = new ZipArchive(memory, ZipArchiveMode.Read, leaveOpen: false); var manifestEntry = archive.GetEntry(ManifestName) ?? throw new InvalidDataException("Embedded payload manifest is missing."); PayloadManifest manifest; using (var stream = manifestEntry.Open()) { manifest = JsonSerializer.Deserialize(stream, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? throw new InvalidDataException("Embedded payload manifest is invalid."); } if (manifest.Schema != 1 || string.IsNullOrWhiteSpace(manifest.Version)) throw new InvalidDataException("Unsupported payload manifest."); var entryPoint = NormalizeRelativePath(manifest.EntryPoint); var declared = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var file in manifest.Files) { var relative = NormalizeRelativePath(file.Path); if (!declared.TryAdd(relative, file)) throw new InvalidDataException($"Duplicate payload path: {relative}"); if (file.Length < 0 || file.Sha256.Length != 64 || file.Sha256.Any(c => !Uri.IsHexDigit(c))) throw new InvalidDataException($"Invalid payload metadata: {relative}"); } if (!declared.ContainsKey(entryPoint)) throw new InvalidDataException("Payload entry point is not declared."); var actualEntries = archive.Entries.Where(e => !string.IsNullOrEmpty(e.Name)).ToArray(); if (actualEntries.Length != declared.Count + 1) throw new InvalidDataException("Payload archive contains undeclared entries."); foreach (var entry in actualEntries) { var relative = NormalizeRelativePath(entry.FullName); if (relative.Equals(ManifestName, StringComparison.OrdinalIgnoreCase)) continue; if (!declared.TryGetValue(relative, out var expected)) throw new InvalidDataException($"Undeclared payload entry: {relative}"); using var stream = entry.Open(); var hash = Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); if (entry.Length != expected.Length || !hash.Equals(expected.Sha256, StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException($"Embedded payload verification failed: {relative}"); } return manifest; } internal static void EnsureInstalled(byte[] archiveBytes, string installDirectory) { var manifest = ReadAndValidateArchive(archiveBytes); var destination = Path.GetFullPath(installDirectory); if (IsValidInstallation(destination, manifest)) return; var parent = Directory.GetParent(destination)?.FullName ?? throw new InvalidDataException("Invalid runtime destination."); Directory.CreateDirectory(parent); var staging = Path.Combine(parent, ".staging-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(staging); try { using var memory = new MemoryStream(archiveBytes, writable: false); using var archive = new ZipArchive(memory, ZipArchiveMode.Read, leaveOpen: false); foreach (var file in manifest.Files) { var relative = NormalizeRelativePath(file.Path); var entry = archive.GetEntry(relative) ?? throw new InvalidDataException($"Payload entry disappeared: {relative}"); var target = ResolveBelow(staging, relative); Directory.CreateDirectory(Path.GetDirectoryName(target)!); using var input = entry.Open(); using var output = new FileStream(target, FileMode.CreateNew, FileAccess.Write, FileShare.None); input.CopyTo(output); output.Flush(flushToDisk: true); } File.WriteAllText(Path.Combine(staging, ManifestName), JsonSerializer.Serialize(manifest)); if (!IsValidInstallation(staging, manifest)) throw new InvalidDataException("Extracted payload verification failed."); if (Directory.Exists(destination)) DeleteOrdinaryDirectory(destination); Directory.Move(staging, destination); } finally { if (Directory.Exists(staging)) DeleteOrdinaryDirectory(staging); } } internal static bool IsValidInstallation(string directory, PayloadManifest manifest) { if (!Directory.Exists(directory) || IsReparsePoint(directory)) return false; var expectedPaths = manifest.Files .Select(file => NormalizeRelativePath(file.Path)) .Append(ManifestName) .ToHashSet(StringComparer.OrdinalIgnoreCase); foreach (var path in EnumerateOrdinaryFiles(directory)) { var relative = Path.GetRelativePath(directory, path).Replace('\\', '/'); if (!expectedPaths.Remove(relative)) return false; } if (expectedPaths.Count != 0) return false; var installedManifest = Path.Combine(directory, ManifestName); if (!File.ReadAllText(installedManifest).Equals(JsonSerializer.Serialize(manifest), StringComparison.Ordinal)) return false; foreach (var file in manifest.Files) { var path = ResolveBelow(directory, NormalizeRelativePath(file.Path)); if (!File.Exists(path) || (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) return false; var info = new FileInfo(path); if (info.Length != file.Length) return false; using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); var hash = Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); if (!hash.Equals(file.Sha256, StringComparison.OrdinalIgnoreCase)) return false; } return true; } internal static string NormalizeRelativePath(string value) { if (string.IsNullOrWhiteSpace(value) || Path.IsPathRooted(value)) throw new InvalidDataException("Payload path must be relative."); var normalized = value.Replace('\\', '/').Trim('/'); if (normalized.Length == 0 || normalized.Contains(':')) throw new InvalidDataException("Invalid payload path."); var segments = normalized.Split('/'); if (segments.Any(s => s is "" or "." or "..")) throw new InvalidDataException("Payload path traversal was refused."); return string.Join('/', segments); } internal static string ResolveBelow(string root, string relative) { var basePath = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; var target = Path.GetFullPath(Path.Combine(basePath, relative.Replace('/', Path.DirectorySeparatorChar))); if (!target.StartsWith(basePath, StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("Payload escaped its runtime directory."); return target; } internal static bool IsReparsePoint(string path) => (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0; internal static void DeleteOrdinaryDirectory(string path) { _ = EnumerateOrdinaryFiles(path).ToArray(); Directory.Delete(path, recursive: true); } private static IEnumerable EnumerateOrdinaryFiles(string directory) { var root = new DirectoryInfo(directory); if (!root.Exists) yield break; if ((root.Attributes & FileAttributes.ReparsePoint) != 0) throw new InvalidDataException("Refusing to follow a runtime reparse point."); foreach (var entry in root.EnumerateFileSystemInfos()) { if ((entry.Attributes & FileAttributes.ReparsePoint) != 0) throw new InvalidDataException("Refusing to follow a runtime reparse point."); if (entry is DirectoryInfo child) { foreach (var file in EnumerateOrdinaryFiles(child.FullName)) yield return file; } else if (entry is FileInfo file) { yield return file.FullName; } else { throw new InvalidDataException("Unsupported runtime entry type."); } } } }