plugin_package_test.go 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. package service
  2. import (
  3. "archive/zip"
  4. "bytes"
  5. "context"
  6. "crypto/ed25519"
  7. "crypto/rand"
  8. "crypto/sha256"
  9. "encoding/base64"
  10. "encoding/hex"
  11. "encoding/json"
  12. "os"
  13. "path/filepath"
  14. "runtime"
  15. "testing"
  16. "github.com/Wei-Shaw/sub2api/internal/config"
  17. pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1"
  18. "github.com/stretchr/testify/assert"
  19. "github.com/stretchr/testify/require"
  20. )
  21. func TestPluginPackageInstallerInstallUnsignedDevelopmentPackage(t *testing.T) {
  22. cfg := testPluginConfig(t.TempDir(), true)
  23. installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179", BuildType: "release"})
  24. archive := buildTestPluginArchive(t, nil, "")
  25. installation, err := installer.Install(context.Background(), bytes.NewReader(archive), nil)
  26. require.NoError(t, err)
  27. assert.Equal(t, PluginStateDisabled, installation.State)
  28. assert.Equal(t, PluginSignatureUnsigned, installation.SignatureStatus)
  29. assert.FileExists(t, installation.ArtifactPath)
  30. info, statErr := os.Stat(installation.BinaryPath)
  31. require.NoError(t, statErr)
  32. if runtime.GOOS != "windows" {
  33. assert.NotZero(t, info.Mode()&0o100)
  34. }
  35. assert.Contains(t, installation.InstallPath, filepath.Join("installed", "com.example.openai-transport"))
  36. }
  37. func TestPluginPackageInstallerAllowsRepeatedIdenticalUpload(t *testing.T) {
  38. cfg := testPluginConfig(t.TempDir(), true)
  39. installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179"})
  40. archive := buildTestPluginArchive(t, nil, "")
  41. first, err := installer.Install(context.Background(), bytes.NewReader(archive), nil)
  42. require.NoError(t, err)
  43. second, err := installer.Install(context.Background(), bytes.NewReader(archive), nil)
  44. require.NoError(t, err)
  45. assert.NotEqual(t, first.InstallPath, second.InstallPath)
  46. assert.NotEqual(t, first.ArtifactPath, second.ArtifactPath)
  47. assert.FileExists(t, first.BinaryPath)
  48. assert.FileExists(t, second.BinaryPath)
  49. }
  50. func TestPluginPackageInstallerRejectsUnsignedPackageByDefault(t *testing.T) {
  51. cfg := testPluginConfig(t.TempDir(), false)
  52. installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179"})
  53. _, err := installer.Install(context.Background(), bytes.NewReader(buildTestPluginArchive(t, nil, "")), nil)
  54. require.Error(t, err)
  55. assert.Contains(t, err.Error(), "不允许安装未签名插件")
  56. }
  57. func TestPluginPackageInstallerVerifiesTrustedSignature(t *testing.T) {
  58. publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
  59. require.NoError(t, err)
  60. cfg := testPluginConfig(t.TempDir(), false)
  61. cfg.Plugins.TrustedPublishers["local-test"] = base64.StdEncoding.EncodeToString(publicKey)
  62. installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179"})
  63. installation, installErr := installer.Install(
  64. context.Background(),
  65. bytes.NewReader(buildTestPluginArchive(t, privateKey, "local-test")),
  66. nil,
  67. )
  68. require.NoError(t, installErr)
  69. assert.Equal(t, PluginSignatureTrusted, installation.SignatureStatus)
  70. }
  71. func TestBuiltInOpenAITransportPublisherDoesNotRequireConfiguration(t *testing.T) {
  72. cfg := testPluginConfig(t.TempDir(), false)
  73. cfg.Plugins.TrustedPublishers[builtInOpenAITransportPublisherKeyID] = "不能覆盖内置公钥"
  74. encodedKey := trustedPluginPublisherKey(cfg, builtInOpenAITransportPublisherKeyID, builtInOpenAITransportPluginID)
  75. publicKey, err := base64.StdEncoding.DecodeString(encodedKey)
  76. require.NoError(t, err)
  77. assert.Len(t, publicKey, ed25519.PublicKeySize)
  78. assert.Equal(t, builtInOpenAITransportPublisherKeyBase64, encodedKey)
  79. assert.Empty(t, trustedPluginPublisherKey(cfg, builtInOpenAITransportPublisherKeyID, "com.example.other-plugin"))
  80. }
  81. func TestPluginPackageInstallerRejectsPathTraversal(t *testing.T) {
  82. cfg := testPluginConfig(t.TempDir(), true)
  83. installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179"})
  84. archive := buildTestPluginArchiveWithExtra(t, nil, "", "../escape", []byte("escape"))
  85. _, err := installer.Install(context.Background(), bytes.NewReader(archive), nil)
  86. require.Error(t, err)
  87. assert.Contains(t, err.Error(), "不安全路径")
  88. }
  89. func TestPluginPackageInstallerKeepsHostVersionMismatchDisabled(t *testing.T) {
  90. cfg := testPluginConfig(t.TempDir(), true)
  91. installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.2.0"})
  92. installation, err := installer.Install(context.Background(), bytes.NewReader(buildTestPluginArchive(t, nil, "")), nil)
  93. require.NoError(t, err)
  94. assert.Equal(t, PluginStateIncompatible, installation.State)
  95. assert.False(t, installation.Compatibility.Compatible)
  96. }
  97. func TestPluginPackageInstallerRejectsHashMismatch(t *testing.T) {
  98. cfg := testPluginConfig(t.TempDir(), true)
  99. installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179"})
  100. manifest := testPluginManifest(map[string][]byte{
  101. "bin/plugin": []byte("binary"),
  102. "ui/index.html": []byte("<html></html>"),
  103. })
  104. manifest.Files["bin/plugin"] = string(bytes.Repeat([]byte("0"), 64))
  105. _, err := installer.Install(context.Background(), bytes.NewReader(buildPluginArchive(t, manifest, nil, "", nil)), nil)
  106. require.Error(t, err)
  107. assert.Contains(t, err.Error(), "哈希不匹配")
  108. }
  109. func TestPluginPackageInstallerEnforcesActualExtractionLimit(t *testing.T) {
  110. archiveData := buildTestPluginArchive(t, nil, "")
  111. archive, err := zip.NewReader(bytes.NewReader(archiveData), int64(len(archiveData)))
  112. require.NoError(t, err)
  113. cfg := testPluginConfig(t.TempDir(), true)
  114. cfg.Plugins.MaxUncompressedBytes = 8
  115. installer := NewPluginPackageInstaller(cfg, PluginHostInfo{Version: "0.1.179"})
  116. err = installer.extractArchive(context.Background(), archive, testPluginManifest(nil), t.TempDir())
  117. require.ErrorContains(t, err, "实际解压体积超过限制")
  118. }
  119. func testPluginConfig(root string, allowUnsigned bool) *config.Config {
  120. return &config.Config{Plugins: config.PluginConfig{
  121. DataDir: root,
  122. AllowUnsigned: allowUnsigned,
  123. TrustedPublishers: map[string]string{},
  124. MaxUploadBytes: 64 * 1024 * 1024,
  125. MaxUncompressedBytes: 128 * 1024 * 1024,
  126. StartTimeoutSeconds: 5,
  127. }}
  128. }
  129. func testPluginManifest(files map[string][]byte) PluginManifest {
  130. if files == nil {
  131. files = map[string][]byte{
  132. "bin/plugin": []byte("binary"),
  133. "ui/index.html": []byte("<html></html>"),
  134. }
  135. }
  136. hashes := make(map[string]string, len(files))
  137. for path, data := range files {
  138. digest := sha256.Sum256(data)
  139. hashes[path] = hex.EncodeToString(digest[:])
  140. }
  141. return PluginManifest{
  142. SchemaVersion: 1,
  143. ID: "com.example.openai-transport",
  144. Name: "测试 OpenAI Transport",
  145. Version: "0.1.0",
  146. Requires: PluginRequirements{
  147. Sub2API: ">=0.1.170 <0.2.0",
  148. RecommendedSub2APIVersion: "0.1.179",
  149. TestedSub2APIVersions: []string{"0.1.179"},
  150. PluginProtocol: pluginv1.ProtocolVersion,
  151. TransportAPI: pluginv1.TransportAPIVersion,
  152. UIBridge: pluginv1.UIBridgeVersion,
  153. },
  154. Capabilities: []PluginCapability{{
  155. ID: PluginCapabilityOpenAIOAuthOutbound,
  156. Platform: PlatformOpenAI,
  157. AccountType: AccountTypeOAuth,
  158. }},
  159. Runtimes: map[string]PluginRuntime{
  160. PluginManifest{}.RuntimeKey(): {Path: "bin/plugin"},
  161. },
  162. UI: PluginUIManifest{Entrypoint: "ui/index.html"},
  163. Files: hashes,
  164. }
  165. }
  166. func buildTestPluginArchive(t *testing.T, privateKey ed25519.PrivateKey, keyID string) []byte {
  167. return buildPluginArchive(t, testPluginManifest(nil), privateKey, keyID, nil)
  168. }
  169. func buildTestPluginArchiveWithExtra(t *testing.T, privateKey ed25519.PrivateKey, keyID, path string, data []byte) []byte {
  170. return buildPluginArchive(t, testPluginManifest(nil), privateKey, keyID, map[string][]byte{path: data})
  171. }
  172. func buildPluginArchive(
  173. t *testing.T,
  174. manifest PluginManifest,
  175. privateKey ed25519.PrivateKey,
  176. keyID string,
  177. extra map[string][]byte,
  178. ) []byte {
  179. t.Helper()
  180. manifestRaw, err := json.Marshal(manifest)
  181. require.NoError(t, err)
  182. var buffer bytes.Buffer
  183. writer := zip.NewWriter(&buffer)
  184. writeZipEntry(t, writer, pluginManifestFilename, manifestRaw)
  185. for path := range manifest.Files {
  186. data := []byte("binary")
  187. if path == "ui/index.html" {
  188. data = []byte("<html></html>")
  189. }
  190. writeZipEntry(t, writer, path, data)
  191. }
  192. for path, data := range extra {
  193. writeZipEntry(t, writer, path, data)
  194. }
  195. if len(privateKey) > 0 {
  196. signatureRaw, marshalErr := json.Marshal(PluginSignature{
  197. Algorithm: "ed25519",
  198. KeyID: keyID,
  199. Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, manifestRaw)),
  200. })
  201. require.NoError(t, marshalErr)
  202. writeZipEntry(t, writer, pluginSignatureFilename, signatureRaw)
  203. }
  204. require.NoError(t, writer.Close())
  205. return buffer.Bytes()
  206. }
  207. func writeZipEntry(t *testing.T, writer *zip.Writer, path string, data []byte) {
  208. t.Helper()
  209. entry, err := writer.Create(path)
  210. require.NoError(t, err)
  211. _, err = entry.Write(data)
  212. require.NoError(t, err)
  213. }