XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEToolBox

【WPF】XFE工具箱

公开
关注 0 Fork 0 Star 0
UTF-8
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text.Json;
using System.Xml.Linq;
using XFEToolBox.Client.Core.Tools;
using XFEToolBox.Client.Utilities;

namespace XFEToolBox.Client.Wpf.Test;

public static class ToolchainManagerTests
{
    [TestCase("win-x64")]
    [TestCase("win-arm64")]
    [TestCase("win-x86")]
    public static async Task FirstUseSelectsArchitectureAndReusesCacheOffline(string rid)
    {
        using var fixture = new Fixture(rid);
        var installation = await fixture.Manager.EnsureInstalledAsync();
        Ensure(installation.RuntimeIdentifier == rid && File.Exists(installation.DotNetPath), "没有安装正确架构的私有 SDK。");
        Ensure(fixture.Server.Requests == 2, "首次安装应只请求元数据和一个 ZIP。");
        fixture.Server.Offline = true;
        var cached = await fixture.NewManager().EnsureInstalledAsync();
        Ensure(cached.Root == installation.Root && fixture.Server.Requests == 2, "已缓存的组件仍依赖网络。");
        Ensure(!Directory.EnumerateDirectories(fixture.Manager.CacheRoot, ".staging-*").Any(), "安装完成后遗留下载文件。");
    }

    [Test]
    public static async Task ConcurrentInstancesDownloadOnlyOnce()
    {
        using var fixture = new Fixture();
        fixture.Server.BlockDownload = true;
        var first = fixture.Manager.EnsureInstalledAsync();
        await fixture.Server.DownloadStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
        var second = fixture.NewManager().EnsureInstalledAsync();
        Ensure(!second.IsCompleted, "第二个实例未等待安装锁。");
        fixture.Server.ReleaseDownload.TrySetResult();
        var installed = await Task.WhenAll(first, second).WaitAsync(TimeSpan.FromSeconds(10));
        Ensure(installed[0].Root == installed[1].Root && fixture.Server.Requests == 2, "并发打开重复下载了 SDK。");
    }

    [Test]
    public static async Task CancelledDownloadReleasesLockAndCanRetry()
    {
        using var fixture = new Fixture();
        fixture.Server.BlockDownload = true;
        using var cancellation = new CancellationTokenSource();
        var first = fixture.Manager.EnsureInstalledAsync(cancellationToken: cancellation.Token);
        await fixture.Server.DownloadStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
        cancellation.Cancel();
        await ThrowsAsync<OperationCanceledException>(() => first);
        Ensure(!Directory.EnumerateDirectories(fixture.Manager.CacheRoot, "sdk-*").Any(), "取消后发布了不完整组件。");
        Ensure(!Directory.EnumerateDirectories(fixture.Manager.CacheRoot, ".staging-*").Any(), "取消后未清理临时文件。");
        fixture.Server.BlockDownload = false;
        await fixture.NewManager().EnsureInstalledAsync().WaitAsync(TimeSpan.FromSeconds(10));
    }

    [Test]
    public static async Task HashMismatchIsRejectedBeforeExtractionAndCanRetry()
    {
        using var fixture = new Fixture();
        fixture.Server.WrongHash = true;
        await ThrowsAsync<InvalidDataException>(() => fixture.Manager.EnsureInstalledAsync());
        Ensure(!Directory.EnumerateDirectories(fixture.Manager.CacheRoot, "sdk-*").Any(), "哈希不匹配的下载被启用。");
        fixture.Server.WrongHash = false;
        await fixture.Manager.EnsureInstalledAsync();
    }

    [TestCase("../escaped.dll")]
    [TestCase("/absolute.dll")]
    [TestCase("C:/outside.dll")]
    [TestCase("sdk/file.dll:payload")]
    public static async Task ArchiveCannotEscapeItsStagingDirectory(string entry)
    {
        using var fixture = new Fixture(archive: CreateArchive(extraEntry: entry));
        await ThrowsAsync<InvalidDataException>(() => fixture.Manager.EnsureInstalledAsync());
        Ensure(!Directory.EnumerateDirectories(fixture.Manager.CacheRoot, "sdk-*").Any(), "不安全的 ZIP 被启用。");
        Ensure(!File.Exists(Path.Combine(fixture.Manager.CacheRoot, "escaped.dll")), "ZIP 越界写入文件。");
    }

    [Test]
    public static async Task SymbolicLinksAreRejected()
    {
        using var fixture = new Fixture(archive: CreateArchive(extraEntry: "link", symbolicLink: true));
        await ThrowsAsync<InvalidDataException>(() => fixture.Manager.EnsureInstalledAsync());
    }

    [Test]
    public static async Task IncompleteWpfRuntimeIsNotCached()
    {
        using var fixture = new Fixture(archive: CreateArchive(includeWpf: false));
        await ThrowsAsync<InvalidDataException>(() => fixture.Manager.EnsureInstalledAsync());
        Ensure(!Directory.EnumerateDirectories(fixture.Manager.CacheRoot, "sdk-*").Any(), "缺少 WPF 的 SDK 被缓存。");
    }

    [Test]
    public static async Task DamagedCacheIsReplacedWithoutUsingIt()
    {
        using var fixture = new Fixture();
        var first = await fixture.Manager.EnsureInstalledAsync();
        File.Delete(Path.Combine(first.Root, "shared/Microsoft.WindowsDesktop.App/10.0.1/PresentationFramework.dll"));
        var repaired = await fixture.NewManager().EnsureInstalledAsync();
        Ensure(repaired.Root != first.Root && fixture.Server.Requests == 4, "损坏的缓存没有重新下载。");
    }

    [Test]
    public static async Task GeneratedLauncherAndBuildUseThePrivateSdk()
    {
        using var fixture = new Fixture();
        var installed = await fixture.Manager.EnsureInstalledAsync();
        var runRoot = fixture.Manager.CreateRunDirectory();
        var outputRoot = Path.Combine(runRoot, "output");
        var project = XDocument.Parse(ToolProjectRunService.CreateProjectFile(runRoot, "Test", "XFEToolBox", [], installed, outputRoot));
        Ensure(project.Descendants("AppHostDotNetSearch").Single().Value == "AppRelative", "启动器仍会寻找全局运行时。");
        var relativeRoot = project.Descendants("AppHostRelativeDotNet").Single().Value;
        Ensure(Path.GetFullPath(Path.Combine(outputRoot, relativeRoot)) == installed.Root, "启动器私有运行时路径错误。");
        Ensure(project.Descendants("SelfContained").Single().Value == "false", "每个工具重复携带了运行时。");
        var start = new ProcessStartInfo(installed.DotNetPath);
        start.Environment["MSBuildSDKsPath"] = "invalid-sdk";
        start.Environment["DOTNET_ROOT_X64"] = "invalid-runtime";
        start.Environment["DOTNET_HOST_PATH"] = "invalid-host";
        start.Environment["DOTNET_MSBUILD_SDK_RESOLVER_SDKS_DIR"] = "invalid-resolver";
        fixture.Manager.ConfigureBuildEnvironment(start, installed);
        Ensure(!start.Environment.ContainsKey("MSBuildSDKsPath") && !start.Environment.ContainsKey("DOTNET_ROOT_X64"), "构建环境继承了系统 SDK 覆盖配置。");
        Ensure(start.Environment["DOTNET_ROOT"] == installed.Root, "构建没有绑定私有运行时。");
        Ensure(start.Environment["DOTNET_HOST_PATH"] == installed.DotNetPath
               && !start.Environment.ContainsKey("DOTNET_MSBUILD_SDK_RESOLVER_SDKS_DIR")
               && start.Environment["PATH"]!.StartsWith(installed.Root + Path.PathSeparator), "构建中的子工具仍可能调用系统 dotnet。");
        Ensure(start.Environment["NUGET_PACKAGES"]!.StartsWith(fixture.Manager.CacheRoot), "NuGet 没有使用私有缓存。");
    }

    private static byte[] CreateArchive(bool includeWpf = true, string? extraEntry = null, bool symbolicLink = false)
    {
        string[] files =
        [
            "dotnet.exe", "sdk/10.0.101/MSBuild.dll", "sdk/10.0.101/Roslyn/bincore/csc.dll",
            "sdk/10.0.101/Sdks/Microsoft.NET.Sdk/targets/Microsoft.NET.Sdk.targets",
            "sdk/10.0.101/Sdks/Microsoft.NET.Sdk.WindowsDesktop/targets/Microsoft.NET.Sdk.WindowsDesktop.targets",
            "host/fxr/10.0.1/hostfxr.dll", "shared/Microsoft.NETCore.App/10.0.1/coreclr.dll",
            "shared/Microsoft.WindowsDesktop.App/10.0.1/PresentationFramework.dll",
            "packs/Microsoft.NETCore.App.Ref/10.0.1/ref/net10.0/System.Runtime.dll",
            "packs/Microsoft.WindowsDesktop.App.Ref/10.0.1/ref/net10.0/PresentationFramework.dll"
        ];
        using var stream = new MemoryStream();
        using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true))
        {
            foreach (var file in files.Where(file => includeWpf || !file.StartsWith("shared/Microsoft.WindowsDesktop.App")))
            {
                using var entry = archive.CreateEntry(file).Open();
                entry.Write([1, 2, 3]);
            }
            if (extraEntry is not null)
            {
                var entry = archive.CreateEntry(extraEntry);
                if (symbolicLink)
                    entry.ExternalAttributes = unchecked((int)0xa1ff0000);
                using var output = entry.Open();
                output.Write([1]);
            }
        }
        return stream.ToArray();
    }

    private sealed class Fixture : IDisposable
    {
        private readonly string _root = Path.Combine(Path.GetTempPath(), "XFEToolBox.Toolchain.Tests", Guid.NewGuid().ToString("N"));
        private readonly string _rid;
        private readonly HttpClient _client;
        public FakeServer Server { get; }
        public ToolchainManager Manager { get; }

        public Fixture(string rid = "win-x64", byte[]? archive = null)
        {
            _rid = rid;
            Server = new FakeServer(archive ?? CreateArchive(), rid);
            _client = new HttpClient(Server);
            Manager = NewManager();
        }
        public ToolchainManager NewManager() => new(_root, _rid, _client);
        public void Dispose()
        {
            _client.Dispose();
            if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true);
        }
    }

    private sealed class FakeServer(byte[] archive, string rid) : HttpMessageHandler
    {
        public int Requests;
        public bool Offline;
        public bool WrongHash;
        public bool BlockDownload;
        public TaskCompletionSource DownloadStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
        public TaskCompletionSource ReleaseDownload { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);

        protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            if (Offline) throw new HttpRequestException("Offline test");
            Interlocked.Increment(ref Requests);
            if (request.RequestUri!.AbsolutePath.EndsWith("releases.json"))
            {
                var hash = WrongHash ? new string('0', 128) : Convert.ToHexString(SHA512.HashData(archive));
                var metadata = new Dictionary<string, object>
                {
                    ["latest-sdk"] = "10.0.101",
                    ["releases"] = new[] { new { sdks = new[] { new Dictionary<string, object>
                    {
                        ["version"] = "10.0.101", ["runtime-version"] = "10.0.1",
                        ["files"] = new[]
                        {
                            new { rid = "linux-x64", name = "sdk.zip", url = "https://builds.dotnet.microsoft.com/wrong.zip", hash },
                            new { rid, name = "sdk.zip", url = "https://builds.dotnet.microsoft.com/sdk.zip", hash }
                        }
                    } } } }
                };
                return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(JsonSerializer.Serialize(metadata)) };
            }
            Ensure(request.RequestUri.AbsolutePath == "/sdk.zip", "下载了错误架构的 SDK。");
            DownloadStarted.TrySetResult();
            if (BlockDownload) await ReleaseDownload.Task.WaitAsync(cancellationToken);
            return new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(archive) };
        }
    }

    private static void Ensure(bool condition, string message)
    {
        if (!condition) throw new InvalidOperationException(message);
    }

    private static async Task ThrowsAsync<T>(Func<Task> action) where T : Exception
    {
        try { await action(); }
        catch (T) { return; }
        throw new InvalidOperationException($"Expected {typeof(T).Name}.");
    }
}