using System.IO.Compression;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Xml;
using XFEToolBox.Core.Tools;
using XFEToolBox.Server.Core.Exceptions;
using XFEToolBox.Server.Core.Models;
using XFEToolBox.Server.Core.Options;
using XFEToolBox.Server.Core.Utilities;
namespace XFEToolBox.Server.Core.Services;
public sealed partial class ToolPackageValidator(ToolPackageValidationOptions options)
{
private const long MaxIconBytes = 512 * 1024;
private static readonly IReadOnlyDictionary<string, string> s_iconContentTypes =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
[".png"] = "image/png",
[".jpg"] = "image/jpeg",
[".jpeg"] = "image/jpeg",
[".gif"] = "image/gif",
[".bmp"] = "image/bmp",
[".ico"] = "image/x-icon"
};
private static readonly JsonSerializerOptions s_jsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
public ToolPackageInspection Inspect(Stream packageStream)
{
ArgumentNullException.ThrowIfNull(packageStream);
if (!packageStream.CanRead || !packageStream.CanSeek)
throw new ArgumentException("工具包流必须可读并且可定位。", nameof(packageStream));
if (packageStream.Length == 0)
throw new ToolPackageValidationException("工具包不能为空。");
if (packageStream.Length > options.MaxPackageBytes)
throw new ToolPackageValidationException($"工具包超过 {options.MaxPackageBytes} 字节的限制。");
try
{
packageStream.Position = 0;
using var archive = new ZipArchive(packageStream, ZipArchiveMode.Read, leaveOpen: true);
var files = ValidateEntries(archive);
var manifestEntry = archive.Entries.SingleOrDefault(entry =>
string.Equals(NormalizePath(entry.FullName), "manifest.json", StringComparison.OrdinalIgnoreCase));
if (manifestEntry is null)
throw new ToolPackageValidationException("工具包根目录缺少 manifest.json。");
if (manifestEntry.Length > options.MaxManifestBytes)
throw new ToolPackageValidationException("manifest.json 过大。");
ToolPackageManifest manifest;
using (var manifestStream = manifestEntry.Open())
{
manifest = JsonSerializer.Deserialize<ToolPackageManifest>(manifestStream, s_jsonOptions)
?? throw new ToolPackageValidationException("manifest.json 内容为空。");
}
ValidateManifest(manifest, files);
ValidateXamlFiles(archive);
var iconDataUrl = ReadIconDataUrl(archive, manifest.Icon);
return new ToolPackageInspection
{
Manifest = manifest,
Files = files,
IconDataUrl = iconDataUrl,
ExpandedSize = archive.Entries.Sum(entry => entry.Length)
};
}
catch (ToolPackageValidationException)
{
throw;
}
catch (InvalidDataException exception)
{
throw new ToolPackageValidationException("文件不是有效的 .xfetool/ZIP 工具包。", exception);
}
catch (JsonException exception)
{
throw new ToolPackageValidationException($"manifest.json 格式错误:{exception.Message}", exception);
}
finally
{
packageStream.Position = 0;
}
}
private HashSet<string> ValidateEntries(ZipArchive archive)
{
var files = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
long expandedSize = 0;
foreach (var entry in archive.Entries)
{
var path = NormalizePath(entry.FullName);
if (string.IsNullOrEmpty(path) || path.EndsWith('/')) continue;
if (files.Count >= options.MaxFileCount)
throw new ToolPackageValidationException($"工具包文件数超过 {options.MaxFileCount} 个的限制。");
if (!IsSafeRelativePath(path))
throw new ToolPackageValidationException($"工具包包含不安全路径:{entry.FullName}");
if (IsSymbolicLink(entry))
throw new ToolPackageValidationException($"工具包不允许包含符号链接:{path}");
if (!files.Add(path))
throw new ToolPackageValidationException($"工具包包含重复路径:{path}");
expandedSize = checked(expandedSize + entry.Length);
if (expandedSize > options.MaxExpandedBytes)
throw new ToolPackageValidationException($"工具包解压后超过 {options.MaxExpandedBytes} 字节的限制。");
if (entry.CompressedLength == 0 && entry.Length > 0 ||
entry.CompressedLength > 0 && (double)entry.Length / entry.CompressedLength > options.MaxCompressionRatio)
throw new ToolPackageValidationException($"文件压缩率异常:{path}");
if (string.Equals(path, "manifest.json", StringComparison.OrdinalIgnoreCase)) continue;
var extension = Path.GetExtension(path);
if (!options.AllowedFileExtensions.Contains(extension))
throw new ToolPackageValidationException($"不允许打包 {extension} 文件:{path}");
}
return files;
}
private static void ValidateManifest(ToolPackageManifest manifest, IReadOnlySet<string> files)
{
if (manifest.PackageFormatVersion != ToolPackageManifest.CurrentPackageFormatVersion)
throw new ToolPackageValidationException($"不支持工具包格式版本 {manifest.PackageFormatVersion}。");
if (string.IsNullOrWhiteSpace(manifest.Id) || manifest.Id.Length > 64 || !ToolIdRegex().IsMatch(manifest.Id))
throw new ToolPackageValidationException("工具 ID 必须为 1-64 位小写字母、数字、点或短横线,并以字母开头。");
ValidateText(manifest.Name, "工具名称", 100);
ValidateText(manifest.Description, "工具描述", 2000);
ValidateText(manifest.Author, "作者", 100);
ValidateText(manifest.Category, "分类", 50);
ValidateIcon(manifest.Icon, files);
if (string.IsNullOrWhiteSpace(manifest.Version) || manifest.Version.Length > 64 || !SemanticVersionComparer.IsValid(manifest.Version))
throw new ToolPackageValidationException("工具版本必须是有效的 SemVer 版本,例如 1.0.0 或 1.0.0-beta.1。");
if (!string.IsNullOrWhiteSpace(manifest.MinimumHostVersion) && !SemanticVersionComparer.IsValid(manifest.MinimumHostVersion))
throw new ToolPackageValidationException("minimumHostVersion 必须是有效的 SemVer 版本。");
if (manifest.Tags is null || manifest.Tags.Length > 20 || manifest.Tags.Any(tag => string.IsNullOrWhiteSpace(tag) || tag.Length > 40))
throw new ToolPackageValidationException("标签最多 20 个,且每个标签长度为 1-40 个字符。");
ValidateNuGetPackages(manifest.NuGetPackages);
if (manifest.RequestedPermissions is null || manifest.RequestedPermissions.Length > 32 || manifest.RequestedPermissions.Any(permission => string.IsNullOrWhiteSpace(permission) || permission.Length > 64))
throw new ToolPackageValidationException("请求的权限列表不合法。");
if (manifest.Entry is null)
throw new ToolPackageValidationException("manifest 缺少 entry。");
ValidateText(manifest.Entry.ViewClass, "View 类名", 300);
RequireFile(manifest.Entry.ViewXaml, ".xaml", "View XAML", files);
RequireFile(manifest.Entry.ViewCodeBehind, ".cs", "View code-behind", files);
if (!string.IsNullOrWhiteSpace(manifest.Entry.ViewModel))
{
RequireFile(manifest.Entry.ViewModel, ".cs", "ViewModel", files);
ValidateText(manifest.Entry.ViewModelClass, "ViewModel 类名", 300);
}
else if (!string.IsNullOrWhiteSpace(manifest.Entry.ViewModelClass))
{
throw new ToolPackageValidationException("设置 viewModelClass 时也必须设置 viewModel 文件。");
}
}
private static void ValidateNuGetPackages(IReadOnlyCollection<ToolNuGetPackageReference>? packages)
{
if (packages is null || packages.Count > ToolNuGetPackageRules.MaximumPackageCount)
throw new ToolPackageValidationException($"NuGet 包最多允许 {ToolNuGetPackageRules.MaximumPackageCount} 个。");
var packageIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var package in packages)
{
if (package is null || !ToolNuGetPackageRules.IsValidPackageId(package.Id))
throw new ToolPackageValidationException("NuGet 包 ID 不合法。");
if (!ToolNuGetPackageRules.IsValidExactVersion(package.Version))
throw new ToolPackageValidationException($"NuGet 包 {package.Id} 必须使用精确版本,例如 1.2.3 或 1.2.3-beta.1。");
if (!packageIds.Add(package.Id))
throw new ToolPackageValidationException($"NuGet 包不能重复:{package.Id}。");
}
}
private void ValidateXamlFiles(ZipArchive archive)
{
foreach (var entry in archive.Entries.Where(entry =>
string.Equals(Path.GetExtension(entry.FullName), ".xaml", StringComparison.OrdinalIgnoreCase)))
{
try
{
using var stream = entry.Open();
using var reader = XmlReader.Create(stream, new XmlReaderSettings
{
DtdProcessing = DtdProcessing.Prohibit,
XmlResolver = null,
MaxCharactersInDocument = options.MaxXamlCharacters
});
while (reader.Read())
{
}
}
catch (XmlException exception)
{
throw new ToolPackageValidationException($"XAML 不是有效的 XML:{entry.FullName}。{exception.Message}", exception);
}
}
}
private static void RequireFile(string? value, string extension, string fieldName, IReadOnlySet<string> files)
{
if (string.IsNullOrWhiteSpace(value))
throw new ToolPackageValidationException($"manifest 缺少 {fieldName}。");
var path = NormalizePath(value);
if (!IsSafeRelativePath(path) || !string.Equals(Path.GetExtension(path), extension, StringComparison.OrdinalIgnoreCase))
throw new ToolPackageValidationException($"{fieldName} 路径或扩展名不合法:{value}");
if (!files.Contains(path))
throw new ToolPackageValidationException($"{fieldName} 指向的文件不存在:{value}");
}
private static void ValidateIcon(string? value, IReadOnlySet<string> files)
{
if (value is null) return;
if (string.IsNullOrWhiteSpace(value))
throw new ToolPackageValidationException("工具图标路径不能为空。");
var path = NormalizePath(value);
if (!IsSafeRelativePath(path) || !s_iconContentTypes.ContainsKey(Path.GetExtension(path)))
throw new ToolPackageValidationException($"工具图标必须是安全的 PNG、JPEG、GIF、BMP 或 ICO 文件路径:{value}");
if (!files.Contains(path))
throw new ToolPackageValidationException($"工具图标指向的文件不存在:{value}");
}
private static string? ReadIconDataUrl(ZipArchive archive, string? iconPath)
{
if (string.IsNullOrWhiteSpace(iconPath)) return null;
var normalizedPath = NormalizePath(iconPath);
var entry = archive.Entries.Single(entry =>
string.Equals(NormalizePath(entry.FullName), normalizedPath, StringComparison.OrdinalIgnoreCase));
if (entry.Length > MaxIconBytes)
throw new ToolPackageValidationException($"工具图标不能超过 {MaxIconBytes / 1024} KiB。");
using var output = new MemoryStream((int)entry.Length);
using (var input = entry.Open())
input.CopyTo(output);
var contentType = s_iconContentTypes[Path.GetExtension(normalizedPath)];
return $"data:{contentType};base64,{Convert.ToBase64String(output.ToArray())}";
}
private static void ValidateText(string? value, string fieldName, int maximumLength)
{
if (string.IsNullOrWhiteSpace(value) || value.Length > maximumLength)
throw new ToolPackageValidationException($"{fieldName} 长度必须为 1-{maximumLength} 个字符。");
}
private static string NormalizePath(string path) => path.Replace('\\', '/');
private static bool IsSafeRelativePath(string path)
{
if (path.StartsWith('/') || path.Contains(':')) return false;
var segments = path.Split('/');
return segments.All(segment => segment.Length > 0 && segment is not "." and not "..");
}
private static bool IsSymbolicLink(ZipArchiveEntry entry)
=> ((entry.ExternalAttributes >> 16) & 0xF000) == 0xA000;
[GeneratedRegex("^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$")]
private static partial Regex ToolIdRegex();
}
using System.IO.Compression;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Xml;
using XFEToolBox.Core.Tools;
using XFEToolBox.Server.Core.Exceptions;
using XFEToolBox.Server.Core.Models;
using XFEToolBox.Server.Core.Options;
using XFEToolBox.Server.Core.Utilities;
namespace XFEToolBox.Server.Core.Services;
public sealed partial class ToolPackageValidator(ToolPackageValidationOptions options)
{
private const long MaxIconBytes = 512 * 1024;
private static readonly IReadOnlyDictionary<string, string> s_iconContentTypes =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
[".png"] = "image/png",
[".jpg"] = "image/jpeg",
[".jpeg"] = "image/jpeg",
[".gif"] = "image/gif",
[".bmp"] = "image/bmp",
[".ico"] = "image/x-icon"
};
private static readonly JsonSerializerOptions s_jsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
public ToolPackageInspection Inspect(Stream packageStream)
{
ArgumentNullException.ThrowIfNull(packageStream);
if (!packageStream.CanRead || !packageStream.CanSeek)
throw new ArgumentException("工具包流必须可读并且可定位。", nameof(packageStream));
if (packageStream.Length == 0)
throw new ToolPackageValidationException("工具包不能为空。");
if (packageStream.Length > options.MaxPackageBytes)
throw new ToolPackageValidationException($"工具包超过 {options.MaxPackageBytes} 字节的限制。");
try
{
packageStream.Position = 0;
using var archive = new ZipArchive(packageStream, ZipArchiveMode.Read, leaveOpen: true);
var files = ValidateEntries(archive);
var manifestEntry = archive.Entries.SingleOrDefault(entry =>
string.Equals(NormalizePath(entry.FullName), "manifest.json", StringComparison.OrdinalIgnoreCase));
if (manifestEntry is null)
throw new ToolPackageValidationException("工具包根目录缺少 manifest.json。");
if (manifestEntry.Length > options.MaxManifestBytes)
throw new ToolPackageValidationException("manifest.json 过大。");
ToolPackageManifest manifest;
using (var manifestStream = manifestEntry.Open())
{
manifest = JsonSerializer.Deserialize<ToolPackageManifest>(manifestStream, s_jsonOptions)
?? throw new ToolPackageValidationException("manifest.json 内容为空。");
}
ValidateManifest(manifest, files);
ValidateXamlFiles(archive);
var iconDataUrl = ReadIconDataUrl(archive, manifest.Icon);
return new ToolPackageInspection
{
Manifest = manifest,
Files = files,
IconDataUrl = iconDataUrl,
ExpandedSize = archive.Entries.Sum(entry => entry.Length)
};
}
catch (ToolPackageValidationException)
{
throw;
}
catch (InvalidDataException exception)
{
throw new ToolPackageValidationException("文件不是有效的 .xfetool/ZIP 工具包。", exception);
}
catch (JsonException exception)
{
throw new ToolPackageValidationException($"manifest.json 格式错误:{exception.Message}", exception);
}
finally
{
packageStream.Position = 0;
}
}
private HashSet<string> ValidateEntries(ZipArchive archive)
{
var files = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
long expandedSize = 0;
foreach (var entry in archive.Entries)
{
var path = NormalizePath(entry.FullName);
if (string.IsNullOrEmpty(path) || path.EndsWith('/')) continue;
if (files.Count >= options.MaxFileCount)
throw new ToolPackageValidationException($"工具包文件数超过 {options.MaxFileCount} 个的限制。");
if (!IsSafeRelativePath(path))
throw new ToolPackageValidationException($"工具包包含不安全路径:{entry.FullName}");
if (IsSymbolicLink(entry))
throw new ToolPackageValidationException($"工具包不允许包含符号链接:{path}");
if (!files.Add(path))
throw new ToolPackageValidationException($"工具包包含重复路径:{path}");
expandedSize = checked(expandedSize + entry.Length);
if (expandedSize > options.MaxExpandedBytes)
throw new ToolPackageValidationException($"工具包解压后超过 {options.MaxExpandedBytes} 字节的限制。");
if (entry.CompressedLength == 0 && entry.Length > 0 ||
entry.CompressedLength > 0 && (double)entry.Length / entry.CompressedLength > options.MaxCompressionRatio)
throw new ToolPackageValidationException($"文件压缩率异常:{path}");
if (string.Equals(path, "manifest.json", StringComparison.OrdinalIgnoreCase)) continue;
var extension = Path.GetExtension(path);
if (!options.AllowedFileExtensions.Contains(extension))
throw new ToolPackageValidationException($"不允许打包 {extension} 文件:{path}");
}
return files;
}
private static void ValidateManifest(ToolPackageManifest manifest, IReadOnlySet<string> files)
{
if (manifest.PackageFormatVersion != ToolPackageManifest.CurrentPackageFormatVersion)
throw new ToolPackageValidationException($"不支持工具包格式版本 {manifest.PackageFormatVersion}。");
if (string.IsNullOrWhiteSpace(manifest.Id) || manifest.Id.Length > 64 || !ToolIdRegex().IsMatch(manifest.Id))
throw new ToolPackageValidationException("工具 ID 必须为 1-64 位小写字母、数字、点或短横线,并以字母开头。");
ValidateText(manifest.Name, "工具名称", 100);
ValidateText(manifest.Description, "工具描述", 2000);
ValidateText(manifest.Author, "作者", 100);
ValidateText(manifest.Category, "分类", 50);
ValidateIcon(manifest.Icon, files);
if (string.IsNullOrWhiteSpace(manifest.Version) || manifest.Version.Length > 64 || !SemanticVersionComparer.IsValid(manifest.Version))
throw new ToolPackageValidationException("工具版本必须是有效的 SemVer 版本,例如 1.0.0 或 1.0.0-beta.1。");
if (!string.IsNullOrWhiteSpace(manifest.MinimumHostVersion) && !SemanticVersionComparer.IsValid(manifest.MinimumHostVersion))
throw new ToolPackageValidationException("minimumHostVersion 必须是有效的 SemVer 版本。");
if (manifest.Tags is null || manifest.Tags.Length > 20 || manifest.Tags.Any(tag => string.IsNullOrWhiteSpace(tag) || tag.Length > 40))
throw new ToolPackageValidationException("标签最多 20 个,且每个标签长度为 1-40 个字符。");
ValidateNuGetPackages(manifest.NuGetPackages);
if (manifest.RequestedPermissions is null || manifest.RequestedPermissions.Length > 32 || manifest.RequestedPermissions.Any(permission => string.IsNullOrWhiteSpace(permission) || permission.Length > 64))
throw new ToolPackageValidationException("请求的权限列表不合法。");
if (manifest.Entry is null)
throw new ToolPackageValidationException("manifest 缺少 entry。");
ValidateText(manifest.Entry.ViewClass, "View 类名", 300);
RequireFile(manifest.Entry.ViewXaml, ".xaml", "View XAML", files);
RequireFile(manifest.Entry.ViewCodeBehind, ".cs", "View code-behind", files);
if (!string.IsNullOrWhiteSpace(manifest.Entry.ViewModel))
{
RequireFile(manifest.Entry.ViewModel, ".cs", "ViewModel", files);
ValidateText(manifest.Entry.ViewModelClass, "ViewModel 类名", 300);
}
else if (!string.IsNullOrWhiteSpace(manifest.Entry.ViewModelClass))
{
throw new ToolPackageValidationException("设置 viewModelClass 时也必须设置 viewModel 文件。");
}
}
private static void ValidateNuGetPackages(IReadOnlyCollection<ToolNuGetPackageReference>? packages)
{
if (packages is null || packages.Count > ToolNuGetPackageRules.MaximumPackageCount)
throw new ToolPackageValidationException($"NuGet 包最多允许 {ToolNuGetPackageRules.MaximumPackageCount} 个。");
var packageIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var package in packages)
{
if (package is null || !ToolNuGetPackageRules.IsValidPackageId(package.Id))
throw new ToolPackageValidationException("NuGet 包 ID 不合法。");
if (!ToolNuGetPackageRules.IsValidExactVersion(package.Version))
throw new ToolPackageValidationException($"NuGet 包 {package.Id} 必须使用精确版本,例如 1.2.3 或 1.2.3-beta.1。");
if (!packageIds.Add(package.Id))
throw new ToolPackageValidationException($"NuGet 包不能重复:{package.Id}。");
}
}
private void ValidateXamlFiles(ZipArchive archive)
{
foreach (var entry in archive.Entries.Where(entry =>
string.Equals(Path.GetExtension(entry.FullName), ".xaml", StringComparison.OrdinalIgnoreCase)))
{
try
{
using var stream = entry.Open();
using var reader = XmlReader.Create(stream, new XmlReaderSettings
{
DtdProcessing = DtdProcessing.Prohibit,
XmlResolver = null,
MaxCharactersInDocument = options.MaxXamlCharacters
});
while (reader.Read())
{
}
}
catch (XmlException exception)
{
throw new ToolPackageValidationException($"XAML 不是有效的 XML:{entry.FullName}。{exception.Message}", exception);
}
}
}
private static void RequireFile(string? value, string extension, string fieldName, IReadOnlySet<string> files)
{
if (string.IsNullOrWhiteSpace(value))
throw new ToolPackageValidationException($"manifest 缺少 {fieldName}。");
var path = NormalizePath(value);
if (!IsSafeRelativePath(path) || !string.Equals(Path.GetExtension(path), extension, StringComparison.OrdinalIgnoreCase))
throw new ToolPackageValidationException($"{fieldName} 路径或扩展名不合法:{value}");
if (!files.Contains(path))
throw new ToolPackageValidationException($"{fieldName} 指向的文件不存在:{value}");
}
private static void ValidateIcon(string? value, IReadOnlySet<string> files)
{
if (value is null) return;
if (string.IsNullOrWhiteSpace(value))
throw new ToolPackageValidationException("工具图标路径不能为空。");
var path = NormalizePath(value);
if (!IsSafeRelativePath(path) || !s_iconContentTypes.ContainsKey(Path.GetExtension(path)))
throw new ToolPackageValidationException($"工具图标必须是安全的 PNG、JPEG、GIF、BMP 或 ICO 文件路径:{value}");
if (!files.Contains(path))
throw new ToolPackageValidationException($"工具图标指向的文件不存在:{value}");
}
private static string? ReadIconDataUrl(ZipArchive archive, string? iconPath)
{
if (string.IsNullOrWhiteSpace(iconPath)) return null;
var normalizedPath = NormalizePath(iconPath);
var entry = archive.Entries.Single(entry =>
string.Equals(NormalizePath(entry.FullName), normalizedPath, StringComparison.OrdinalIgnoreCase));
if (entry.Length > MaxIconBytes)
throw new ToolPackageValidationException($"工具图标不能超过 {MaxIconBytes / 1024} KiB。");
using var output = new MemoryStream((int)entry.Length);
using (var input = entry.Open())
input.CopyTo(output);
var contentType = s_iconContentTypes[Path.GetExtension(normalizedPath)];
return $"data:{contentType};base64,{Convert.ToBase64String(output.ToArray())}";
}
private static void ValidateText(string? value, string fieldName, int maximumLength)
{
if (string.IsNullOrWhiteSpace(value) || value.Length > maximumLength)
throw new ToolPackageValidationException($"{fieldName} 长度必须为 1-{maximumLength} 个字符。");
}
private static string NormalizePath(string path) => path.Replace('\\', '/');
private static bool IsSafeRelativePath(string path)
{
if (path.StartsWith('/') || path.Contains(':')) return false;
var segments = path.Split('/');
return segments.All(segment => segment.Length > 0 && segment is not "." and not "..");
}
private static bool IsSymbolicLink(ZipArchiveEntry entry)
=> ((entry.ExternalAttributes >> 16) & 0xF000) == 0xA000;
[GeneratedRegex("^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$")]
private static partial Regex ToolIdRegex();
}