181 lines
8.9 KiB
C#
181 lines
8.9 KiB
C#
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<PayloadFile> 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<PayloadManifest>(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<string, PayloadFile>(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<string> 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.");
|
|
}
|
|
}
|
|
}
|
|
}
|