第二阶段已完成:统一插件平台已从“内置功能开关”扩展为可安装、签名校验、版本激活和回滚的插件系统。
主要成果:
- 新增独立插件 SDK:[PluginContracts.cs (line 11)](E:/jiaowu/src/Jiaowu.Plugin.Abstractions/PluginContracts.cs:11)
- 支持 RSA-PSS/SHA-256 签名插件包、Host API 兼容性和 ZIP 安全检查:[PluginPackageValidator.cs (line 14)](E:/jiaowu/src/Jiaowu.Api/Infrastructure/Plugins/PluginPackageValidator.cs:14)
- 支持暂存、待激活、重启装载、失败记录、版本回滚:[PluginPackageService.cs (line 32)](E:/jiaowu/src/Jiaowu.Api/Infrastructure/Plugins/PluginPackageService.cs:32)
- 插件可注册控制器、服务、数据库迁移、后台任务和事件处理器
- 外部插件 API 统一使用 /api/plugin-extensions/{pluginId},并受启用状态中间件控制
- 插件中心新增 ZIP/SIG 上传、状态展示、激活、回滚和删除功能:[PluginsView.vue (line 212)](E:/jiaowu/web/src/views/PluginsView.vue:212)
- 提供可运行示例插件:[SamplePlugin.cs (line 10)](E:/jiaowu/samples/Jiaowu.SamplePlugin/SamplePlugin.cs:10)
- 提供打包签名脚本:[package-plugin.ps1](E:/jiaowu/scripts/package-plugin.ps1)
- 完整开发文档:[plugin-sdk.md (line 1)](E:/jiaowu/docs/plugin-sdk.md:1)
- 系统版本已调整为 3.0.0-beta1
This commit is contained in:
1 parent
7e3f138875
commit
05683e502a
35 files changed
+11247
-24
No files matched your search
@@ -22,6 +22,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Jiaowu.Api\Jiaowu.Api.csproj" />
|
||||
<ProjectReference Include="..\..\src\Jiaowu.Plugin.Abstractions\Jiaowu.Plugin.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\samples\Jiaowu.SamplePlugin\Jiaowu.SamplePlugin.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,233 @@
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Plugins;
|
||||
using Jiaowu.Plugin.Abstractions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Jiaowu.Api.Tests;
|
||||
|
||||
public sealed class PluginPackageTests
|
||||
{
|
||||
[Fact]
|
||||
public void Signed_package_is_validated_and_tampering_is_rejected()
|
||||
{
|
||||
using var fixture = new PackageFixture();
|
||||
var package = fixture.CreatePackage();
|
||||
var signature = fixture.Sign(package);
|
||||
|
||||
var validated = fixture.Validator.Validate(package, signature);
|
||||
|
||||
Assert.Equal("sample.hello", validated.Manifest.Id);
|
||||
package[^1] ^= 0x01;
|
||||
Assert.Throws<PluginPackageException>(() =>
|
||||
fixture.Validator.Validate(package, signature));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Package_with_incompatible_host_api_is_rejected()
|
||||
{
|
||||
using var fixture = new PackageFixture();
|
||||
var package = fixture.CreatePackage(hostApiVersion: "999");
|
||||
|
||||
var exception = Assert.Throws<PluginPackageException>(() =>
|
||||
fixture.Validator.Validate(package, fixture.Sign(package)));
|
||||
|
||||
Assert.Contains("Host API", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Staged_package_can_be_marked_for_restart_activation()
|
||||
{
|
||||
using var fixture = new PackageFixture();
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseSqlite("Data Source=:memory:")
|
||||
.Options;
|
||||
await using var db = new AppDbContext(options);
|
||||
await db.Database.OpenConnectionAsync();
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
var service = new PluginPackageService(
|
||||
db,
|
||||
new BuiltInPluginCatalog(),
|
||||
fixture.Validator,
|
||||
fixture.Options,
|
||||
fixture.Environment);
|
||||
var package = fixture.CreatePackage();
|
||||
|
||||
var staged = await service.StageAsync(
|
||||
package,
|
||||
fixture.Sign(package),
|
||||
"admin-user");
|
||||
var pending = await service.RequestActivationAsync(staged.Id);
|
||||
|
||||
Assert.NotNull(pending);
|
||||
Assert.Equal(PluginPackageStatus.PendingActivation, pending.Status);
|
||||
Assert.Single(Directory.GetFiles(
|
||||
Path.Combine(fixture.Root, "active"),
|
||||
"*.json"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Plugin_migration_is_transactionally_recorded_once()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseSqlite("Data Source=:memory:")
|
||||
.Options;
|
||||
await using var db = new AppDbContext(options);
|
||||
await db.Database.OpenConnectionAsync();
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
var runner = new PluginMigrationRunner(
|
||||
db,
|
||||
[new TestMigration()],
|
||||
NullLogger<PluginMigrationRunner>.Instance);
|
||||
|
||||
await runner.ApplyAsync();
|
||||
await runner.ApplyAsync();
|
||||
|
||||
Assert.Single(await db.PluginMigrationHistory.ToListAsync());
|
||||
var tableExists = await db.Database.SqlQueryRaw<int>(
|
||||
"SELECT COUNT(*) AS Value FROM sqlite_master WHERE type = 'table' AND name = 'PluginTestData'")
|
||||
.SingleAsync();
|
||||
Assert.Equal(1, tableExists);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Activated_signed_package_is_loaded_on_next_startup()
|
||||
{
|
||||
using var fixture = new PackageFixture();
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseSqlite("Data Source=:memory:")
|
||||
.Options;
|
||||
await using var db = new AppDbContext(options);
|
||||
await db.Database.OpenConnectionAsync();
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
var service = new PluginPackageService(
|
||||
db,
|
||||
new BuiltInPluginCatalog(),
|
||||
fixture.Validator,
|
||||
fixture.Options,
|
||||
fixture.Environment);
|
||||
var package = fixture.CreatePackage(useRealAssembly: true);
|
||||
var staged = await service.StageAsync(package, fixture.Sign(package), "admin");
|
||||
await service.RequestActivationAsync(staged.Id);
|
||||
var services = new ServiceCollection();
|
||||
|
||||
var result = PluginStartupLoader.LoadAndConfigure(
|
||||
services,
|
||||
new ConfigurationBuilder().Build(),
|
||||
fixture.Environment,
|
||||
fixture.Options);
|
||||
|
||||
var loaded = Assert.Single(result.State.Plugins);
|
||||
Assert.True(loaded.Loaded, loaded.Error);
|
||||
Assert.Equal("sample.hello", loaded.PluginId);
|
||||
Assert.Single(result.Assemblies);
|
||||
}
|
||||
|
||||
private sealed class TestMigration : IPluginDatabaseMigration
|
||||
{
|
||||
public string PluginId => "sample.hello";
|
||||
public string MigrationId => "001";
|
||||
public IReadOnlyList<string> GetStatements(PluginDatabaseProvider provider) =>
|
||||
["CREATE TABLE PluginTestData (Id TEXT NOT NULL PRIMARY KEY);"];
|
||||
}
|
||||
|
||||
private sealed class PackageFixture : IDisposable
|
||||
{
|
||||
private readonly RSA rsa = RSA.Create(3072);
|
||||
|
||||
public PackageFixture()
|
||||
{
|
||||
Root = Path.Combine(Path.GetTempPath(), $"jiaowu-plugin-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(Root);
|
||||
var publicKeyPath = Path.Combine(Root, "publisher.pem");
|
||||
File.WriteAllText(publicKeyPath, rsa.ExportRSAPublicKeyPem());
|
||||
Options = new PluginPlatformOptions
|
||||
{
|
||||
StoragePath = Root,
|
||||
TrustedPublishers =
|
||||
[
|
||||
new TrustedPluginPublisher
|
||||
{
|
||||
Id = "school-it",
|
||||
PublicKeyPath = publicKeyPath
|
||||
}
|
||||
]
|
||||
};
|
||||
Environment = new TestEnvironment { ContentRootPath = Root };
|
||||
Validator = new PluginPackageValidator(Options, Environment);
|
||||
}
|
||||
|
||||
public string Root { get; }
|
||||
public PluginPlatformOptions Options { get; }
|
||||
public TestEnvironment Environment { get; }
|
||||
public PluginPackageValidator Validator { get; }
|
||||
|
||||
public byte[] CreatePackage(
|
||||
string hostApiVersion = PluginHostContract.ApiVersion,
|
||||
bool useRealAssembly = false)
|
||||
{
|
||||
var manifest = new PluginManifest
|
||||
{
|
||||
Id = "sample.hello",
|
||||
Name = "示例插件",
|
||||
Description = "签名包测试",
|
||||
Version = "1.0.0",
|
||||
PublisherId = "school-it",
|
||||
HostApiVersion = hostApiVersion,
|
||||
BackendAssembly = "backend/Sample.dll",
|
||||
ModuleType = useRealAssembly
|
||||
? "Jiaowu.SamplePlugin.SamplePluginModule"
|
||||
: "Sample.PluginModule",
|
||||
Capabilities = ["sample.read"]
|
||||
};
|
||||
using var buffer = new MemoryStream();
|
||||
using (var archive = new ZipArchive(buffer, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
var manifestEntry = archive.CreateEntry("plugin.json");
|
||||
using (var writer = new StreamWriter(manifestEntry.Open()))
|
||||
writer.Write(JsonSerializer.Serialize(manifest));
|
||||
var assemblyEntry = archive.CreateEntry("backend/Sample.dll");
|
||||
using var assembly = assemblyEntry.Open();
|
||||
if (useRealAssembly)
|
||||
{
|
||||
var bytes = File.ReadAllBytes(
|
||||
typeof(Jiaowu.SamplePlugin.SamplePluginModule).Assembly.Location);
|
||||
assembly.Write(bytes);
|
||||
}
|
||||
else
|
||||
{
|
||||
assembly.Write([0x4D, 0x5A, 0x00, 0x00]);
|
||||
}
|
||||
}
|
||||
return buffer.ToArray();
|
||||
}
|
||||
|
||||
public byte[] Sign(byte[] package) => rsa.SignData(
|
||||
package,
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pss);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
rsa.Dispose();
|
||||
if (Directory.Exists(Root)) Directory.Delete(Root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestEnvironment : IHostEnvironment
|
||||
{
|
||||
public string EnvironmentName { get; set; } = Environments.Development;
|
||||
public string ApplicationName { get; set; } = "Jiaowu.Api.Tests";
|
||||
public string ContentRootPath { get; set; } = "";
|
||||
public IFileProvider ContentRootFileProvider { get; set; } =
|
||||
new NullFileProvider();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user