fix(payment): 支付插件 - 修复跨平台构建与运行时缓存 (#402)

This commit is contained in:
MasterChiefCN
2026-09-03 11:05:29 +08:00
committed by GitHub
parent f8e87bcc4c
commit 367a0933cf
18 changed files with 795 additions and 33 deletions

View File

@@ -7,16 +7,23 @@ package payment
// transaction; the process only translates a payment provider protocol.
import (
"bytes"
"context"
"debug/elf"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path"
"path/filepath"
"runtime"
"strings"
"syscall"
"time"
)
@@ -36,7 +43,7 @@ func NewRPCProvider(descriptor Descriptor, packageDir, entry string) (*RPCProvid
if strings.TrimSpace(descriptor.ID) == "" || strings.TrimSpace(descriptor.PluginID) == "" {
return nil, errors.New("payment plugin descriptor requires id and plugin id")
}
if filepath.IsAbs(entry) || filepath.Clean(entry) != entry || !strings.HasPrefix(entry, "backend/") {
if filepath.IsAbs(entry) || path.Clean(entry) != entry || !strings.HasPrefix(entry, "backend/") {
return nil, errors.New("payment plugin entry must be relative to backend/")
}
if strings.TrimSpace(packageDir) == "" {
@@ -69,11 +76,18 @@ func (p *RPCProvider) call(ctx context.Context, request rpcRequest, output any)
if p == nil || strings.TrimSpace(p.command) == "" {
return errors.New("payment plugin runtime is unavailable")
}
if _, err := exec.LookPath(p.command); err != nil {
return fmt.Errorf("payment plugin executable unavailable: %w", err)
info, err := os.Stat(p.command)
if err != nil {
return p.startError(err)
}
if _, err := filepath.Abs(p.command); err != nil {
return err
if info.IsDir() {
return p.startError(fmt.Errorf("payment plugin executable is a directory: %w", syscall.EACCES))
}
if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 {
return p.startError(fmt.Errorf("payment plugin executable has no execute bit: %w", syscall.EACCES))
}
if err := validatePaymentExecutablePlatform(p.command); err != nil {
return p.startError(err)
}
ctx, cancel := context.WithTimeout(ctx, pluginRPCTimeout)
defer cancel()
@@ -94,10 +108,10 @@ func (p *RPCProvider) call(ctx context.Context, request rpcRequest, output any)
return err
}
if err := cmd.Start(); err != nil {
return &ProviderError{Code: "plugin_start_failed", Message: "支付插件启动失败", Temporary: true, Cause: err}
return p.startError(err)
}
data, readErr := io.ReadAll(io.LimitReader(stdout, pluginRPCMaxOutput+1))
errData, _ := io.ReadAll(io.LimitReader(stderr, 64<<10))
_, _ = io.ReadAll(io.LimitReader(stderr, 64<<10))
waitErr := cmd.Wait()
if readErr != nil {
return &ProviderError{Code: "plugin_read_failed", Message: "读取支付插件响应失败", Temporary: true, Cause: readErr}
@@ -109,15 +123,11 @@ func (p *RPCProvider) call(ctx context.Context, request rpcRequest, output any)
return &ProviderError{Code: "plugin_timeout", Message: "支付插件执行超时", Temporary: true, Cause: ctx.Err()}
}
if waitErr != nil {
message := strings.TrimSpace(string(errData))
if message == "" {
message = "支付插件进程异常退出"
}
return &ProviderError{Code: "plugin_process_failed", Message: message, Temporary: true, Cause: waitErr}
return &ProviderError{Code: "plugin_process_failed", Message: "支付插件进程异常退出", Temporary: true, Cause: waitErr}
}
var response rpcResponse
if err := json.Unmarshal(data, &response); err != nil {
return fmt.Errorf("支付插件响应不是有效 JSON:%w", err)
return &ProviderError{Code: "plugin_invalid_response", Message: "支付插件返回了无效响应", Temporary: true, Cause: err}
}
if !response.OK {
message := response.Message
@@ -138,6 +148,90 @@ func (p *RPCProvider) call(ctx context.Context, request rpcRequest, output any)
return nil
}
func (p *RPCProvider) startError(cause error) error {
providerErr := classifyPaymentPluginStartError(cause)
log.Printf(
"payment plugin executable failed: provider=%q plugin=%q host=%s/%s command=%q code=%s error=%v",
p.descriptor.ID,
p.descriptor.PluginID,
runtime.GOOS,
runtime.GOARCH,
p.command,
providerErr.Code,
cause,
)
return providerErr
}
func classifyPaymentPluginStartError(cause error) *ProviderError {
switch {
case errors.Is(cause, os.ErrNotExist), errors.Is(cause, syscall.ENOENT), errors.Is(cause, exec.ErrNotFound):
return &ProviderError{Code: "plugin_executable_missing", Message: "支付插件可执行文件或其运行时加载器不可用", Cause: cause}
case errors.Is(cause, os.ErrPermission), errors.Is(cause, syscall.EACCES):
return &ProviderError{Code: "plugin_permission_denied", Message: "支付插件没有执行权限", Cause: cause}
case errors.Is(cause, syscall.ENOEXEC):
return &ProviderError{Code: "plugin_exec_format_error", Message: "支付插件可执行文件与当前操作系统或 CPU 架构不兼容", Cause: cause}
default:
return &ProviderError{Code: "plugin_start_failed", Message: "支付插件启动失败", Temporary: true, Cause: cause}
}
}
func validatePaymentExecutablePlatform(command string) error {
if runtime.GOOS != "linux" {
return nil
}
file, err := os.Open(command)
if err != nil {
return err
}
defer file.Close()
var magic [4]byte
if _, err := io.ReadFull(file, magic[:]); err != nil {
return fmt.Errorf("payment plugin executable header is incomplete: %w", syscall.ENOEXEC)
}
if bytes.Equal(magic[:2], []byte{'M', 'Z'}) {
return fmt.Errorf("payment plugin executable is Windows PE on linux/%s: %w", runtime.GOARCH, syscall.ENOEXEC)
}
if isMachOMagic(magic) {
return fmt.Errorf("payment plugin executable is Mach-O on linux/%s: %w", runtime.GOARCH, syscall.ENOEXEC)
}
if !bytes.Equal(magic[:], []byte{0x7f, 'E', 'L', 'F'}) {
// Scripts and other kernel-supported executable formats are left to
// cmd.Start. Unknown binary data will be classified from ENOEXEC there.
return nil
}
binary, err := elf.NewFile(file)
if err != nil {
return fmt.Errorf("payment plugin ELF header is invalid: %w", syscall.ENOEXEC)
}
defer binary.Close()
expected, ok := expectedPaymentELFMachine(runtime.GOARCH)
if binary.Class != elf.ELFCLASS64 || (ok && binary.Machine != expected) {
return fmt.Errorf("payment plugin ELF architecture mismatch: host=linux/%s class=%s machine=%s: %w", runtime.GOARCH, binary.Class, binary.Machine, syscall.ENOEXEC)
}
return nil
}
func isMachOMagic(magic [4]byte) bool {
return magic == [4]byte{0xfe, 0xed, 0xfa, 0xce} ||
magic == [4]byte{0xce, 0xfa, 0xed, 0xfe} ||
magic == [4]byte{0xfe, 0xed, 0xfa, 0xcf} ||
magic == [4]byte{0xcf, 0xfa, 0xed, 0xfe}
}
func expectedPaymentELFMachine(arch string) (elf.Machine, bool) {
switch arch {
case "amd64":
return elf.EM_X86_64, true
case "arm64":
return elf.EM_AARCH64, true
default:
return elf.EM_NONE, false
}
}
func (p *RPCProvider) ValidateConfig(config Config) error {
return p.call(context.Background(), rpcRequest{Version: pluginRPCVersion, Operation: "validate_config", Config: config}, nil)
}

View File

@@ -2,12 +2,19 @@ package payment
import (
"context"
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"syscall"
"testing"
)
func TestRPCProviderUsesPaymentV1Contract(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test provider uses a POSIX shell script")
}
dir := t.TempDir()
entry := filepath.Join(dir, "provider")
program := "#!/bin/sh\nread request\nprintf '%s\\n' '{\"ok\":true,\"data\":{\"mode\":\"qr_code\",\"value\":\"https://pay.example/qr\"}}'\n"
@@ -27,3 +34,131 @@ func TestRPCProviderUsesPaymentV1Contract(t *testing.T) {
t.Fatalf("checkout = %#v", checkout)
}
}
func TestRPCProviderClassifiesExecutableStartFailures(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("executable error classification is asserted against Linux errno behavior")
}
tests := []struct {
name string
content []byte
mode os.FileMode
missing bool
code string
message string
}{
{name: "missing", missing: true, code: "plugin_executable_missing", message: "支付插件可执行文件或其运行时加载器不可用"},
{name: "missing-loader", content: []byte("#!/definitely/missing/payment-loader\n"), mode: 0o700, code: "plugin_executable_missing", message: "支付插件可执行文件或其运行时加载器不可用"},
{name: "permission", content: []byte("#!/bin/sh\nexit 0\n"), mode: 0o600, code: "plugin_permission_denied", message: "支付插件没有执行权限"},
{name: "mach-o", content: []byte{0xcf, 0xfa, 0xed, 0xfe, 0x0c, 0x00, 0x00, 0x01}, mode: 0o700, code: "plugin_exec_format_error", message: "支付插件可执行文件与当前操作系统或 CPU 架构不兼容"},
{name: "windows-pe", content: []byte{'M', 'Z', 0x90, 0x00}, mode: 0o700, code: "plugin_exec_format_error", message: "支付插件可执行文件与当前操作系统或 CPU 架构不兼容"},
{name: "unknown-binary", content: []byte("not an executable\n"), mode: 0o700, code: "plugin_exec_format_error", message: "支付插件可执行文件与当前操作系统或 CPU 架构不兼容"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
dir := t.TempDir()
entry := filepath.Join(dir, "backend", "provider")
if !test.missing {
if err := os.MkdirAll(filepath.Dir(entry), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(entry, test.content, test.mode); err != nil {
t.Fatal(err)
}
}
provider, err := NewRPCProvider(Descriptor{ID: "test-provider", PluginID: "test-plugin"}, dir, "backend/provider")
if err != nil {
t.Fatal(err)
}
err = provider.ValidateConfig(Config{})
var providerErr *ProviderError
if !errors.As(err, &providerErr) {
t.Fatalf("ValidateConfig() error = %T %v, want ProviderError", err, err)
}
if providerErr.Code != test.code || providerErr.Message != test.message || providerErr.Cause == nil {
t.Fatalf("ProviderError = %#v, want code=%q message=%q with cause", providerErr, test.code, test.message)
}
})
}
}
func TestClassifyPaymentPluginStartError(t *testing.T) {
tests := []struct {
name string
cause error
code string
temporary bool
}{
{name: "missing", cause: os.ErrNotExist, code: "plugin_executable_missing"},
{name: "permission", cause: os.ErrPermission, code: "plugin_permission_denied"},
{name: "format", cause: syscall.ENOEXEC, code: "plugin_exec_format_error"},
{name: "generic", cause: errors.New("start failed"), code: "plugin_start_failed", temporary: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
providerErr := classifyPaymentPluginStartError(test.cause)
if providerErr.Code != test.code || providerErr.Temporary != test.temporary || !errors.Is(providerErr, test.cause) {
t.Fatalf("classifyPaymentPluginStartError() = %#v", providerErr)
}
})
}
}
func TestRPCProviderDoesNotExposeProcessStderr(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test provider uses a POSIX shell script")
}
provider := testRPCProvider(t, "#!/bin/sh\nprintf '%s\\n' 'credential=super-secret' >&2\nexit 1\n")
err := provider.ValidateConfig(Config{})
var providerErr *ProviderError
if !errors.As(err, &providerErr) {
t.Fatalf("ValidateConfig() error = %T %v, want ProviderError", err, err)
}
if providerErr.Code != "plugin_process_failed" || providerErr.Message != "支付插件进程异常退出" {
t.Fatalf("ProviderError = %#v", providerErr)
}
if strings.Contains(providerErr.Error(), "super-secret") {
t.Fatalf("process stderr leaked through public error: %q", providerErr.Error())
}
}
func TestRPCProviderClassifiesInvalidJSONResponse(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test provider uses a POSIX shell script")
}
provider := testRPCProvider(t, "#!/bin/sh\nprintf '%s\\n' 'not-json'\n")
err := provider.ValidateConfig(Config{})
var providerErr *ProviderError
if !errors.As(err, &providerErr) || providerErr.Code != "plugin_invalid_response" || providerErr.Message != "支付插件返回了无效响应" {
t.Fatalf("ValidateConfig() error = %#v", err)
}
}
func TestRPCProviderPreservesPluginValidationError(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test provider uses a POSIX shell script")
}
provider := testRPCProvider(t, "#!/bin/sh\nprintf '%s\\n' '{\"ok\":false,\"code\":\"bad_config\",\"message\":\"配置无效\"}'\n")
err := provider.ValidateConfig(Config{})
var providerErr *ProviderError
if !errors.As(err, &providerErr) || providerErr.Code != "bad_config" || providerErr.Message != "配置无效" {
t.Fatalf("ValidateConfig() error = %#v", err)
}
}
func testRPCProvider(t *testing.T, program string) *RPCProvider {
t.Helper()
dir := t.TempDir()
entry := filepath.Join(dir, "backend", "provider")
if err := os.MkdirAll(filepath.Dir(entry), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(entry, []byte(program), 0o700); err != nil {
t.Fatal(err)
}
provider, err := NewRPCProvider(Descriptor{ID: "test-provider", PluginID: "test-plugin"}, dir, "backend/provider")
if err != nil {
t.Fatal(err)
}
return provider
}

View File

@@ -0,0 +1,82 @@
package protocol
import (
"bytes"
"debug/buildinfo"
"debug/elf"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
)
func TestOfficialPaymentArtifactsAreCanonicalLinuxAMD64(t *testing.T) {
for _, packageID := range []string{"official-payment-wechat-native", "official-payment-alipay-page"} {
t.Run(packageID, func(t *testing.T) {
root := filepath.Join("..", "..", "..", "plugin-packages")
providerPath := filepath.Join(root, packageID, "backend", "provider")
directoryProvider, err := os.ReadFile(providerPath)
if err != nil {
t.Fatal(err)
}
packageData, err := os.ReadFile(filepath.Join(root, packageID+".yingce-plugin"))
if err != nil {
t.Fatal(err)
}
pkg, err := ParsePluginPackage(packageData)
if err != nil {
t.Fatal(err)
}
packagedProvider := pkg.Files["backend/provider"]
if !bytes.Equal(directoryProvider, packagedProvider) {
t.Fatal("directory provider differs from .yingce-plugin backend/provider")
}
binary, err := elf.NewFile(bytes.NewReader(directoryProvider))
if err != nil {
t.Fatalf("provider is not ELF: %v", err)
}
if binary.Class != elf.ELFCLASS64 || binary.Machine != elf.EM_X86_64 {
t.Fatalf("provider ELF = class %s machine %s, want 64-bit x86-64", binary.Class, binary.Machine)
}
info, err := buildinfo.Read(bytes.NewReader(directoryProvider))
if err != nil {
t.Fatalf("read provider build info: %v", err)
}
settings := make(map[string]string, len(info.Settings))
for _, setting := range info.Settings {
settings[setting.Key] = setting.Value
}
if settings["GOOS"] != "linux" || settings["GOARCH"] != "amd64" || settings["CGO_ENABLED"] != "0" {
t.Fatalf("provider build settings = GOOS=%s GOARCH=%s CGO_ENABLED=%s", settings["GOOS"], settings["GOARCH"], settings["CGO_ENABLED"])
}
if runtime.GOOS == "linux" && runtime.GOARCH == "amd64" {
fileInfo, err := os.Stat(providerPath)
if err != nil {
t.Fatal(err)
}
if fileInfo.Mode().Perm()&0o111 == 0 {
t.Fatal("provider is not executable")
}
command := exec.Command(providerPath)
command.Stdin = bytes.NewBufferString("invalid-json\n")
output, err := command.Output()
if err != nil {
t.Fatalf("provider smoke test failed: %v", err)
}
var response struct {
OK bool `json:"ok"`
Code string `json:"code"`
}
if err := json.Unmarshal(output, &response); err != nil {
t.Fatalf("provider smoke response is invalid JSON: %v", err)
}
if response.OK || response.Code != "invalid_request" {
t.Fatalf("provider smoke response = %s", output)
}
}
})
}
}

View File

@@ -0,0 +1,171 @@
package service
import (
"archive/zip"
"bytes"
"os"
"path/filepath"
"testing"
"infinite-canvas/backend/internal/protocol"
)
func TestPaymentRuntimeCacheUsesCompletePackageDigest(t *testing.T) {
packageA := paymentRuntimePackage(t, []byte("provider-a"))
packageB := paymentRuntimePackage(t, []byte("provider-b"))
if pluginHash(packageA) == pluginHash(packageB) {
t.Fatal("payment packages with different providers have the same digest")
}
pkgA, err := protocol.ParsePluginPackage(packageA)
if err != nil {
t.Fatal(err)
}
pkgB, err := protocol.ParsePluginPackage(packageB)
if err != nil {
t.Fatal(err)
}
packageDir := t.TempDir()
runtimeA, err := materializePaymentBackend(packageDir, pluginHash(packageA), pkgA)
if err != nil {
t.Fatal(err)
}
runtimeB, err := materializePaymentBackend(packageDir, pluginHash(packageB), pkgB)
if err != nil {
t.Fatal(err)
}
if runtimeA == runtimeB {
t.Fatalf("different package contents reused runtime path %q", runtimeA)
}
assertPaymentRuntimeProvider(t, runtimeA, []byte("provider-a"))
assertPaymentRuntimeProvider(t, runtimeB, []byte("provider-b"))
}
func TestPaymentRuntimeReplacesInterruptedExtraction(t *testing.T) {
packageData := paymentRuntimePackage(t, []byte("complete-provider"))
pkg, err := protocol.ParsePluginPackage(packageData)
if err != nil {
t.Fatal(err)
}
digest := pluginHash(packageData)
packageDir := t.TempDir()
interruptedRoot := filepath.Join(packageDir, "runtime", digest)
if err := os.MkdirAll(filepath.Join(interruptedRoot, "backend"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(interruptedRoot, "backend", "provider"), []byte("partial-provider"), 0o700); err != nil {
t.Fatal(err)
}
runtimeDir, err := materializePaymentBackend(packageDir, digest, pkg)
if err != nil {
t.Fatal(err)
}
if runtimeDir != interruptedRoot {
t.Fatalf("runtime directory = %q, want %q", runtimeDir, interruptedRoot)
}
assertPaymentRuntimeProvider(t, runtimeDir, []byte("complete-provider"))
ready, err := os.ReadFile(filepath.Join(runtimeDir, paymentRuntimeReadyFile))
if err != nil {
t.Fatal(err)
}
if string(ready) != digest+"\n" {
t.Fatalf("runtime ready marker = %q", ready)
}
if err := os.Remove(filepath.Join(runtimeDir, "backend", "provider")); err != nil {
t.Fatal(err)
}
if _, err := materializePaymentBackend(packageDir, digest, pkg); err != nil {
t.Fatal(err)
}
assertPaymentRuntimeProvider(t, runtimeDir, []byte("complete-provider"))
}
func TestBuiltInPaymentPackageUpgradeSelectsNewRuntimeDigest(t *testing.T) {
oldPackage := paymentRuntimePackage(t, []byte("old-provider"))
newPackage := paymentRuntimePackage(t, []byte("new-provider"))
oldDigest := pluginHash(oldPackage)
newDigest := pluginHash(newPackage)
oldPkg, err := protocol.ParsePluginPackage(oldPackage)
if err != nil {
t.Fatal(err)
}
dataDir := t.TempDir()
packageDir := filepath.Join(dataDir, "plugin-packages")
if err := os.MkdirAll(packageDir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(packageDir, oldDigest+".yingce-plugin"), oldPackage, 0o600); err != nil {
t.Fatal(err)
}
if _, err := materializePaymentBackend(packageDir, oldDigest, oldPkg); err != nil {
t.Fatal(err)
}
registry := []pluginRegistryRecord{{
ID: "test-payment-runtime", Raw: oldPkg.ManifestRaw, Source: PluginOriginOfficial,
FileName: "test-payment-runtime.yingce-plugin", PackagePath: oldDigest + ".yingce-plugin", PackageSHA256: oldDigest,
}}
if err := (&pluginRuntime{registryPath: filepath.Join(dataDir, "plugin_registry.json")}).writeRegistry(registry); err != nil {
t.Fatal(err)
}
officialDir := t.TempDir()
if err := os.WriteFile(filepath.Join(officialDir, "test-payment-runtime.yingce-plugin"), newPackage, 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("CANVAS_OFFICIAL_PLUGIN_DIR", officialDir)
center, err := newPluginRuntime(dataDir)
if err != nil {
t.Fatal(err)
}
assertPaymentRuntimeProvider(t, filepath.Join(packageDir, "runtime", newDigest), []byte("new-provider"))
for _, plugin := range center.list() {
if plugin.Manifest.ID == "test-payment-runtime" {
if plugin.SHA256 != newDigest {
t.Fatalf("upgraded plugin digest = %q, want %q", plugin.SHA256, newDigest)
}
return
}
}
t.Fatal("upgraded payment plugin is missing")
}
func TestPaymentRuntimeRejectsInvalidDigest(t *testing.T) {
pkg := protocol.PluginPackage{Files: map[string][]byte{"backend/provider": []byte("provider")}}
if _, err := materializePaymentBackend(t.TempDir(), "../outside", pkg); err == nil {
t.Fatal("invalid package digest was accepted")
}
}
func paymentRuntimePackage(t *testing.T, provider []byte) []byte {
t.Helper()
manifest := []byte(`{"apiVersion":"yingce.plugin/v1","id":"test-payment-runtime","version":"1.0.0","name":"Test Payment Runtime","author":"Test","enabled":true,"runtime":{"backend":"rpc","backendEntry":"backend/provider"},"contributes":{"paymentProviders":[{"id":"test-payment","label":"Test Payment","icon":"brand:test","checkoutMode":"redirect","expiryPolicy":{"defaultMinutes":30,"minMinutes":5,"maxMinutes":1440}}]}}`)
var buffer bytes.Buffer
writer := zip.NewWriter(&buffer)
for name, content := range map[string][]byte{"manifest.json": manifest, "backend/provider": provider} {
file, err := writer.Create(name)
if err != nil {
t.Fatal(err)
}
if _, err := file.Write(content); err != nil {
t.Fatal(err)
}
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
return buffer.Bytes()
}
func assertPaymentRuntimeProvider(t *testing.T, runtimeDir string, expected []byte) {
t.Helper()
actual, err := os.ReadFile(filepath.Join(runtimeDir, "backend", "provider"))
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(actual, expected) {
t.Fatalf("runtime provider = %q, want %q", actual, expected)
}
}

View File

@@ -17,7 +17,10 @@ import (
"infinite-canvas/backend/internal/protocol"
)
const protocolPluginMaxBytes = protocol.PluginManifestMaxBytes
const (
protocolPluginMaxBytes = protocol.PluginManifestMaxBytes
paymentRuntimeReadyFile = ".ready"
)
// PluginView is the backend representation consumed by the single frontend
// plugin center. Protocol-specific runtime data is nested under Protocol so
@@ -297,7 +300,7 @@ func (c *pluginRuntime) reload() error {
return fmt.Errorf("duplicate installed protocol %q", metadata.ID)
}
packageSHA256 := storedRecord.PackageSHA256
if packageSHA256 == "" {
if packageSHA256 == "" && strings.TrimSpace(storedRecord.PackagePath) == "" {
packageSHA256 = pluginHash(data)
}
plugins[metadata.ID] = pluginRecord{Raw: data, Metadata: metadata, Source: storedRecord.Source, FileName: storedRecord.FileName, PackagePath: storedRecord.PackagePath, PackageSHA256: packageSHA256, SHA256: packageSHA256, InstalledAt: storedRecord.InstalledAt, UpdatedAt: storedRecord.UpdatedAt, Status: "invalid"}
@@ -403,6 +406,16 @@ func (c *pluginRuntime) reload() error {
plugins[id] = record
continue
}
packageSHA256 := pluginHash(packageData)
if expected := strings.TrimSpace(record.PackageSHA256); expected != "" && !strings.EqualFold(expected, packageSHA256) {
record.Status = "invalid"
record.Error = "支付插件包完整性校验失败"
plugins[id] = record
continue
}
record.PackageSHA256 = packageSHA256
record.SHA256 = packageSHA256
plugins[id] = record
pkg, parseErr := protocol.ParsePluginPackage(packageData)
if parseErr != nil {
record.Status = "invalid"
@@ -410,7 +423,7 @@ func (c *pluginRuntime) reload() error {
plugins[id] = record
continue
}
runtimeDir, materializeErr := materializePaymentBackend(c.packageDir, record.PackageSHA256, pkg)
runtimeDir, materializeErr := materializePaymentBackend(c.packageDir, packageSHA256, pkg)
if materializeErr != nil {
record.Status = "invalid"
record.Error = materializeErr.Error()
@@ -447,15 +460,34 @@ func (c *pluginRuntime) reload() error {
}
func materializePaymentBackend(packageDir, hash string, pkg protocol.PluginPackage) (string, error) {
root := filepath.Join(packageDir, "runtime", strings.TrimSpace(hash))
if err := os.MkdirAll(root, 0o700); err != nil {
digest := strings.ToLower(strings.TrimSpace(hash))
if len(digest) != sha256.Size*2 {
return "", errors.New("支付插件包摘要无效")
}
if _, err := hex.DecodeString(digest); err != nil {
return "", errors.New("支付插件包摘要无效")
}
runtimeRoot := filepath.Join(packageDir, "runtime")
root := filepath.Join(runtimeRoot, digest)
backendEntry := strings.TrimSpace(pkg.Manifest.Runtime.BackendEntry)
if paymentRuntimeReady(root, digest, backendEntry) {
return root, nil
}
if err := os.MkdirAll(runtimeRoot, 0o700); err != nil {
return "", fmt.Errorf("创建支付插件运行目录失败:%w", err)
}
temporaryRoot, err := os.MkdirTemp(runtimeRoot, ".payment-runtime-")
if err != nil {
return "", fmt.Errorf("创建支付插件临时运行目录失败:%w", err)
}
defer os.RemoveAll(temporaryRoot)
for name, content := range pkg.Files {
if !strings.HasPrefix(name, "backend/") || strings.HasSuffix(name, "/") {
continue
}
target := filepath.Join(root, filepath.FromSlash(name))
target := filepath.Join(temporaryRoot, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
return "", err
}
@@ -463,9 +495,34 @@ func materializePaymentBackend(packageDir, hash string, pkg protocol.PluginPacka
return "", fmt.Errorf("写入支付插件运行文件失败:%w", err)
}
}
if err := os.WriteFile(filepath.Join(temporaryRoot, paymentRuntimeReadyFile), []byte(digest+"\n"), 0o600); err != nil {
return "", fmt.Errorf("写入支付插件运行状态失败:%w", err)
}
if err := os.Rename(temporaryRoot, root); err != nil {
if paymentRuntimeReady(root, digest, backendEntry) {
return root, nil
}
// Digest directories are immutable once ready. An unmarked directory can
// only be residue from an interrupted extraction and is safe to replace.
if removeErr := os.RemoveAll(root); removeErr != nil {
return "", fmt.Errorf("清理未完成的支付插件运行目录失败:%w", removeErr)
}
if renameErr := os.Rename(temporaryRoot, root); renameErr != nil {
return "", fmt.Errorf("发布支付插件运行目录失败:%w", renameErr)
}
}
return root, nil
}
func paymentRuntimeReady(root, digest, backendEntry string) bool {
ready, err := os.ReadFile(filepath.Join(root, paymentRuntimeReadyFile))
if err != nil || strings.TrimSpace(string(ready)) != digest {
return false
}
entry, err := os.Stat(filepath.Join(root, filepath.FromSlash(backendEntry)))
return err == nil && !entry.IsDir()
}
func (c *pluginRuntime) list() []PluginView {
c.mu.RLock()
defer c.mu.RUnlock()