package service import ( "archive/zip" "bytes" "context" "crypto/ed25519" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "os" "path/filepath" "testing" "github.com/Wei-Shaw/sub2api/internal/config" pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestPluginPackageInstallerInstallUnsignedDevelopmentPackage(t *testing.T) { cfg := testPluginConfig(t.TempDir(), true) installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179", BuildType: "release"}) archive := buildTestPluginArchive(t, nil, "") installation, err := installer.Install(context.Background(), bytes.NewReader(archive), nil) require.NoError(t, err) assert.Equal(t, PluginStateDisabled, installation.State) assert.Equal(t, PluginSignatureUnsigned, installation.SignatureStatus) assert.FileExists(t, installation.ArtifactPath) info, statErr := os.Stat(installation.BinaryPath) require.NoError(t, statErr) assert.NotZero(t, info.Mode()&0o100) assert.Contains(t, installation.InstallPath, filepath.Join("installed", "com.example.openai-transport")) } func TestPluginPackageInstallerAllowsRepeatedIdenticalUpload(t *testing.T) { cfg := testPluginConfig(t.TempDir(), true) installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179"}) archive := buildTestPluginArchive(t, nil, "") first, err := installer.Install(context.Background(), bytes.NewReader(archive), nil) require.NoError(t, err) second, err := installer.Install(context.Background(), bytes.NewReader(archive), nil) require.NoError(t, err) assert.NotEqual(t, first.InstallPath, second.InstallPath) assert.NotEqual(t, first.ArtifactPath, second.ArtifactPath) assert.FileExists(t, first.BinaryPath) assert.FileExists(t, second.BinaryPath) } func TestPluginPackageInstallerRejectsUnsignedPackageByDefault(t *testing.T) { cfg := testPluginConfig(t.TempDir(), false) installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179"}) _, err := installer.Install(context.Background(), bytes.NewReader(buildTestPluginArchive(t, nil, "")), nil) require.Error(t, err) assert.Contains(t, err.Error(), "不允许安装未签名插件") } func TestPluginPackageInstallerVerifiesTrustedSignature(t *testing.T) { publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) require.NoError(t, err) cfg := testPluginConfig(t.TempDir(), false) cfg.Plugins.TrustedPublishers["local-test"] = base64.StdEncoding.EncodeToString(publicKey) installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179"}) installation, installErr := installer.Install( context.Background(), bytes.NewReader(buildTestPluginArchive(t, privateKey, "local-test")), nil, ) require.NoError(t, installErr) assert.Equal(t, PluginSignatureTrusted, installation.SignatureStatus) } func TestBuiltInOpenAITransportPublisherDoesNotRequireConfiguration(t *testing.T) { cfg := testPluginConfig(t.TempDir(), false) cfg.Plugins.TrustedPublishers[builtInOpenAITransportPublisherKeyID] = "不能覆盖内置公钥" encodedKey := trustedPluginPublisherKey(cfg, builtInOpenAITransportPublisherKeyID, builtInOpenAITransportPluginID) publicKey, err := base64.StdEncoding.DecodeString(encodedKey) require.NoError(t, err) assert.Len(t, publicKey, ed25519.PublicKeySize) assert.Equal(t, builtInOpenAITransportPublisherKeyBase64, encodedKey) assert.Empty(t, trustedPluginPublisherKey(cfg, builtInOpenAITransportPublisherKeyID, "com.example.other-plugin")) } func TestPluginPackageInstallerRejectsPathTraversal(t *testing.T) { cfg := testPluginConfig(t.TempDir(), true) installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179"}) archive := buildTestPluginArchiveWithExtra(t, nil, "", "../escape", []byte("escape")) _, err := installer.Install(context.Background(), bytes.NewReader(archive), nil) require.Error(t, err) assert.Contains(t, err.Error(), "不安全路径") } func TestPluginPackageInstallerKeepsHostVersionMismatchDisabled(t *testing.T) { cfg := testPluginConfig(t.TempDir(), true) installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.2.0"}) installation, err := installer.Install(context.Background(), bytes.NewReader(buildTestPluginArchive(t, nil, "")), nil) require.NoError(t, err) assert.Equal(t, PluginStateIncompatible, installation.State) assert.False(t, installation.Compatibility.Compatible) } func TestPluginPackageInstallerRejectsHashMismatch(t *testing.T) { cfg := testPluginConfig(t.TempDir(), true) installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179"}) manifest := testPluginManifest(map[string][]byte{ "bin/plugin": []byte("binary"), "ui/index.html": []byte(""), }) manifest.Files["bin/plugin"] = string(bytes.Repeat([]byte("0"), 64)) _, err := installer.Install(context.Background(), bytes.NewReader(buildPluginArchive(t, manifest, nil, "", nil)), nil) require.Error(t, err) assert.Contains(t, err.Error(), "哈希不匹配") } func TestPluginPackageInstallerEnforcesActualExtractionLimit(t *testing.T) { archiveData := buildTestPluginArchive(t, nil, "") archive, err := zip.NewReader(bytes.NewReader(archiveData), int64(len(archiveData))) require.NoError(t, err) cfg := testPluginConfig(t.TempDir(), true) cfg.Plugins.MaxUncompressedBytes = 8 installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179"}) err = installer.extractArchive(context.Background(), archive, testPluginManifest(nil), t.TempDir()) require.ErrorContains(t, err, "实际解压体积超过限制") } func testPluginConfig(root string, allowUnsigned bool) *config.Config { return &config.Config{Plugins: config.PluginConfig{ DataDir: root, AllowUnsigned: allowUnsigned, TrustedPublishers: map[string]string{}, MaxUploadBytes: 64 * 1024 * 1024, MaxUncompressedBytes: 128 * 1024 * 1024, StartTimeoutSeconds: 5, }} } func testPluginManifest(files map[string][]byte) PluginManifest { if files == nil { files = map[string][]byte{ "bin/plugin": []byte("binary"), "ui/index.html": []byte(""), } } hashes := make(map[string]string, len(files)) for path, data := range files { digest := sha256.Sum256(data) hashes[path] = hex.EncodeToString(digest[:]) } return PluginManifest{ SchemaVersion: 1, ID: "com.example.openai-transport", Name: "测试 OpenAI Transport", Version: "0.1.0", Requires: PluginRequirements{ Sub2API: ">=0.1.170 <0.2.0", RecommendedSub2APIVersion: "0.1.179", TestedSub2APIVersions: []string{"0.1.179"}, PluginProtocol: pluginv1.ProtocolVersion, TransportAPI: pluginv1.TransportAPIVersion, UIBridge: pluginv1.UIBridgeVersion, }, Capabilities: []PluginCapability{{ ID: PluginCapabilityOpenAIOAuthOutbound, Platform: PlatformOpenAI, AccountType: AccountTypeOAuth, }}, Runtimes: map[string]PluginRuntime{ PluginManifest{}.RuntimeKey(): {Path: "bin/plugin"}, }, UI: PluginUIManifest{Entrypoint: "ui/index.html"}, Files: hashes, } } func buildTestPluginArchive(t *testing.T, privateKey ed25519.PrivateKey, keyID string) []byte { return buildPluginArchive(t, testPluginManifest(nil), privateKey, keyID, nil) } func buildTestPluginArchiveWithExtra(t *testing.T, privateKey ed25519.PrivateKey, keyID, path string, data []byte) []byte { return buildPluginArchive(t, testPluginManifest(nil), privateKey, keyID, map[string][]byte{path: data}) } func buildPluginArchive( t *testing.T, manifest PluginManifest, privateKey ed25519.PrivateKey, keyID string, extra map[string][]byte, ) []byte { t.Helper() manifestRaw, err := json.Marshal(manifest) require.NoError(t, err) var buffer bytes.Buffer writer := zip.NewWriter(&buffer) writeZipEntry(t, writer, pluginManifestFilename, manifestRaw) for path := range manifest.Files { data := []byte("binary") if path == "ui/index.html" { data = []byte("") } writeZipEntry(t, writer, path, data) } for path, data := range extra { writeZipEntry(t, writer, path, data) } if len(privateKey) > 0 { signatureRaw, marshalErr := json.Marshal(PluginSignature{ Algorithm: "ed25519", KeyID: keyID, Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, manifestRaw)), }) require.NoError(t, marshalErr) writeZipEntry(t, writer, pluginSignatureFilename, signatureRaw) } require.NoError(t, writer.Close()) return buffer.Bytes() } func writeZipEntry(t *testing.T, writer *zip.Writer, path string, data []byte) { t.Helper() entry, err := writer.Create(path) require.NoError(t, err) _, err = entry.Write(data) require.NoError(t, err) }