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

XFEWorkflow

【WinUI】XFE基于windows的工作流

公开
关注 0 Fork 0 Star 0
UTF-8
using System.IO.Compression;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Reflection.PortableExecutable;

namespace XFEWorkflow.Abstractions;

public static partial class PluginPackage
{
    public const long MaximumPackageBytes = 100L * 1024 * 1024;
    public const long MaximumExpandedBytes = 512L * 1024 * 1024;
    public const int ChunkBytes = 1024 * 1024;
    [GeneratedRegex("^[a-z0-9]+([.-][a-z0-9]+)*$")] private static partial Regex IdentifierRegex();
    public static bool IsIdentifier(string value) => IdentifierRegex().IsMatch(value) && value.Length <= 100;
    public static PluginManifest Inspect(string path)
    {
        if (new FileInfo(path).Length > MaximumPackageBytes) throw new InvalidDataException("插件包超过 100 MiB");
        using var archive = ZipFile.OpenRead(path);
        if (archive.Entries.Count > 10_000) throw new InvalidDataException("插件包文件过多");
        long expanded = 0;
        HashSet<string> names = new(StringComparer.OrdinalIgnoreCase);
        foreach (var entry in archive.Entries)
        {
            ValidateRelativePath(entry.FullName.TrimEnd('/'));
            if (!names.Add(entry.FullName.Replace('\\', '/'))) throw new InvalidDataException("插件包包含重复路径");
            if (((entry.ExternalAttributes >> 16) & 0xF000) == 0xA000 || (entry.ExternalAttributes & 0x400) != 0)
                throw new InvalidDataException("插件包不能包含链接");
            expanded = checked(expanded + entry.Length);
            if (expanded > MaximumExpandedBytes) throw new InvalidDataException("插件包解压后超过 512 MiB");
            if (!entry.FullName.EndsWith('/'))
            {
                using var content = entry.Open(); var buffer = new byte[81920]; long read = 0; int count;
                while ((count = content.Read(buffer)) > 0)
                { read += count; if (read > entry.Length) throw new InvalidDataException("插件包文件大小不匹配"); }
                if (read != entry.Length) throw new InvalidDataException("插件包文件不完整");
            }
        }
        var manifestEntry = archive.GetEntry("plugin.json") ?? throw new InvalidDataException("插件包缺少 plugin.json");
        if (manifestEntry.Length > 1024 * 1024) throw new InvalidDataException("插件清单过大");
        using var stream = manifestEntry.Open();
        var manifest = JsonSerializer.Deserialize<PluginManifest>(stream, WorkflowJson.Options) ?? throw new InvalidDataException("插件清单无效");
        Validate(manifest);
        foreach (var assembly in new[] { manifest.Assembly }.Concat(manifest.Pages.Select(x => x.Assembly)).Distinct(StringComparer.OrdinalIgnoreCase))
        {
            var entryAssembly = archive.GetEntry(assembly.Replace('\\', '/')) ?? throw new InvalidDataException("找不到插件入口程序集:" + assembly);
            using var input = entryAssembly.Open(); using var bytes = new MemoryStream();
            input.CopyTo(bytes); bytes.Position = 0;
            try { using var image = new PEReader(bytes); if (!image.HasMetadata) throw new BadImageFormatException(); _ = image.GetMetadata().Length; }
            catch (BadImageFormatException ex) { throw new InvalidDataException("插件入口程序集已损坏或不是 .NET 程序集:" + assembly, ex); }
        }
        return manifest;
    }
    public static void Validate(PluginManifest manifest)
    {
        if (manifest.SchemaVersion != 1 || manifest.SdkMajorVersion != 1) throw new InvalidDataException("插件 SDK 或清单版本不兼容");
        if (!SemanticVersion.TryParse(manifest.SdkMinimumVersion, out var minimum) || !SemanticVersion.TryParse(manifest.SdkMaximumVersionExclusive, out var maximum)
            || minimum.CompareTo(SemanticVersion.Parse(WorkflowSdk.CurrentVersion)) > 0 || maximum.CompareTo(SemanticVersion.Parse(WorkflowSdk.CurrentVersion)) <= 0)
            throw new InvalidDataException("插件要求的 SDK 兼容范围不包含 " + WorkflowSdk.CurrentVersion);
        if (!IsIdentifier(manifest.Id) || !SemanticVersion.TryParse(manifest.Version, out _)) throw new InvalidDataException("插件 ID 或版本无效");
        if (string.IsNullOrWhiteSpace(manifest.Name) || string.IsNullOrWhiteSpace(manifest.EntryType)) throw new InvalidDataException("插件名称或入口类型为空");
        ValidateRelativePath(manifest.Assembly);
        if (!manifest.Assembly.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("插件入口必须是 DLL");
        HashSet<string> ids = [];
        foreach (var capability in manifest.Capabilities)
        {
            if (!capability.Id.StartsWith(manifest.Id + ".", StringComparison.Ordinal) || !ids.Add(capability.Id))
                throw new InvalidDataException("能力 ID 必须属于插件命名空间且不能重复");
            if (capability.PluginId != manifest.Id) throw new InvalidDataException("能力所属插件不匹配");
            if (capability.Inputs.Select(x => x.Name).Distinct().Count() != capability.Inputs.Count ||
                capability.Outputs.Select(x => x.Name).Distinct().Count() != capability.Outputs.Count)
                throw new InvalidDataException("插件端口名称重复");
        }
        foreach (var dependency in manifest.Dependencies)
            if (!IsIdentifier(dependency.Id) || dependency.Id == manifest.Id ||
                !SemanticVersion.TryParse(dependency.MinimumVersion, out _) ||
                (dependency.MaximumVersionExclusive is not null && !SemanticVersion.TryParse(dependency.MaximumVersionExclusive, out _)) ||
                (dependency.ExactVersion is not null && !SemanticVersion.TryParse(dependency.ExactVersion, out _)))
                throw new InvalidDataException("插件依赖声明无效");
        foreach (var page in manifest.Pages)
        {
            if (!IsIdentifier(page.Id) || !page.Id.StartsWith(manifest.Id + ".", StringComparison.Ordinal) || !ids.Add(page.Id) || page.PluginId != manifest.Id)
                throw new InvalidDataException("页面 ID 必须属于插件命名空间且不能重复");
            if (string.IsNullOrWhiteSpace(page.Name) || string.IsNullOrWhiteSpace(page.EntryType) || page.IconGlyph.Length > 2)
                throw new InvalidDataException("插件页面名称、入口或图标无效");
            ValidateRelativePath(page.Assembly);
            if (!page.Assembly.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("插件页面入口必须是 DLL");
        }
    }
    public static void ValidateRelativePath(string path)
    {
        if (string.IsNullOrWhiteSpace(path) || Path.IsPathRooted(path) || path.Contains(':') ||
            path.Replace('\\', '/').Split('/').Any(part => part is ".." or "." or ""))
            throw new InvalidDataException("插件包包含不安全的路径");
    }
    public static void Extract(string package, string destination)
    {
        _ = Inspect(package);
        Directory.CreateDirectory(destination);
        var root = Path.GetFullPath(destination) + Path.DirectorySeparatorChar;
        using var archive = ZipFile.OpenRead(package);
        long total = 0;
        foreach (var entry in archive.Entries)
        {
            var target = Path.GetFullPath(Path.Combine(root, entry.FullName.Replace('/', Path.DirectorySeparatorChar)));
            if (!target.StartsWith(root, StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("解压路径越界");
            if (entry.FullName.EndsWith('/')) { Directory.CreateDirectory(target); continue; }
            Directory.CreateDirectory(Path.GetDirectoryName(target)!);
            using var input = entry.Open();
            using var output = new FileStream(target, FileMode.CreateNew, FileAccess.Write);
            byte[] buffer = new byte[81920];
            long written = 0;
            int count;
            while ((count = input.Read(buffer)) != 0)
            {
                total = checked(total + count);
                written += count;
                if (total > MaximumExpandedBytes || written > entry.Length) throw new InvalidDataException("解压大小超过限制");
                output.Write(buffer, 0, count);
            }
        }
    }
}

public sealed record SemanticVersion(int Major, int Minor, int Patch, string? Prerelease) : IComparable<SemanticVersion>
{
    public static bool TryParse(string value, out SemanticVersion version)
    {
        version = new(0, 0, 0, null);
        if (!Regex.IsMatch(value, "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\\.[0-9A-Za-z-]+)*)?(\\+[0-9A-Za-z-]+(\\.[0-9A-Za-z-]+)*)?$")) return false;
        var parts = value.Split('+', 2)[0].Split('-', 2);
        var numbers = parts[0].Split('.');
        if (numbers.Length != 3 || numbers.Any(x => !Regex.IsMatch(x, "^(0|[1-9][0-9]*)$")) || !int.TryParse(numbers[0], out var major) || !int.TryParse(numbers[1], out var minor) ||
            !int.TryParse(numbers[2], out var patch) || major < 0 || minor < 0 || patch < 0) return false;
        if (parts.Length == 2 && (parts[1].Length == 0 || !Regex.IsMatch(parts[1], "^[0-9A-Za-z.-]+$"))) return false;
        if (parts.Length == 2 && parts[1].Split('.').Any(x => x.Length > 1 && x.All(char.IsDigit) && x[0] == '0')) return false;
        version = new(major, minor, patch, parts.Length == 2 ? parts[1] : null);
        return true;
    }
    public static SemanticVersion Parse(string value) => TryParse(value, out var version) ? version : throw new FormatException("无效的版本号");
    public int CompareTo(SemanticVersion? other)
    {
        if (other is null) return 1;
        var n = Major.CompareTo(other.Major); if (n != 0) return n;
        n = Minor.CompareTo(other.Minor); if (n != 0) return n;
        n = Patch.CompareTo(other.Patch); if (n != 0) return n;
        if (Prerelease is null) return other.Prerelease is null ? 0 : 1;
        if (other.Prerelease is null) return -1;
        var a = Prerelease.Split('.'); var b = other.Prerelease.Split('.');
        for (var i = 0; i < Math.Min(a.Length, b.Length); i++)
        {
            if (int.TryParse(a[i], out var x) && int.TryParse(b[i], out var y)) n = x.CompareTo(y);
            else if (int.TryParse(a[i], out _)) n = -1;
            else if (int.TryParse(b[i], out _)) n = 1;
            else n = string.CompareOrdinal(a[i], b[i]);
            if (n != 0) return n;
        }
        return a.Length.CompareTo(b.Length);
    }
}