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);
}
}
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);
}
}