Files
ttstd-infinite-canvas/backend/internal/app/protocol_plugins.go

968 lines
34 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"sync"
"time"
"infinite-canvas/backend/internal/payment"
"infinite-canvas/backend/internal/protocol"
)
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
// the public plugin contract can grow without adding another center API.
type PluginView struct {
Manifest PluginManifestView `json:"manifest"`
Source string `json:"source"`
FileName string `json:"fileName"`
Package string `json:"package"`
SHA256 string `json:"sha256"`
InstalledAt time.Time `json:"installedAt"`
UpdatedAt time.Time `json:"updatedAt"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
Management PluginManagementView `json:"management"`
}
type PluginManifestView struct {
APIVersion string `json:"apiVersion"`
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Entry string `json:"entry,omitempty"`
Surfaces []string `json:"surfaces,omitempty"`
Description string `json:"description,omitempty"`
Documentation string `json:"documentation,omitempty"`
Author string `json:"author,omitempty"`
Permissions []string `json:"permissions"`
Trusted bool `json:"trusted"`
Runtime protocol.ManifestRuntime `json:"runtime,omitempty"`
Configuration protocol.ManifestConfiguration `json:"configuration,omitempty"`
Contributes protocol.ManifestContributions `json:"contributes"`
}
type pluginRecord struct {
Raw []byte
Metadata protocol.Metadata
Source string
FileName string
PackagePath string
PackageSHA256 string
SHA256 string
InstalledAt time.Time
UpdatedAt time.Time
Status string
Error string
}
type pluginRuntime struct {
mu sync.RWMutex
mutationMu sync.Mutex
registryPath string
packageDir string
plugins map[string]pluginRecord
registry *protocol.Registry
paymentRegistry *payment.Registry
}
type pluginRegistryRecord struct {
ID string `json:"id"`
Raw json.RawMessage `json:"manifest"`
Source string `json:"source"`
FileName string `json:"fileName,omitempty"`
PackagePath string `json:"packagePath,omitempty"`
PackageSHA256 string `json:"packageSha256,omitempty"`
InstalledAt time.Time `json:"installedAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func newPluginRuntime(dataDir string) (*pluginRuntime, error) {
dataDir = strings.TrimSpace(dataDir)
if dataDir == "" {
return nil, errors.New("plugin data directory is empty")
}
var err error
dataDir, err = filepath.Abs(dataDir)
if err != nil {
return nil, fmt.Errorf("plugin data directory is invalid: %w", err)
}
if err := os.MkdirAll(dataDir, 0o755); err != nil {
return nil, fmt.Errorf("create plugin registry directory: %w", err)
}
packageDir := filepath.Join(dataDir, "plugin-packages")
if err := os.MkdirAll(packageDir, 0o700); err != nil {
return nil, fmt.Errorf("create plugin package directory: %w", err)
}
center := &pluginRuntime{registryPath: filepath.Join(dataDir, "plugin_registry.json"), packageDir: packageDir, plugins: make(map[string]pluginRecord)}
if err := center.bootstrapBuiltInPlugins(); err != nil {
return nil, err
}
if err := center.reload(); err != nil {
return nil, err
}
return center, nil
}
func (c *pluginRuntime) bootstrapBuiltInPlugins() error {
stored, err := c.readRegistry()
if err != nil {
return err
}
byID := make(map[string]pluginRegistryRecord, len(stored))
for _, record := range stored {
byID[record.ID] = record
}
officialDir, err := officialPluginPackageDir()
if err != nil {
return err
}
entries, err := os.ReadDir(officialDir)
if err != nil {
return fmt.Errorf("读取官方插件目录失败:%w", err)
}
builtInIDs := make(map[string]struct{}, len(entries)+2)
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".yingce-plugin") {
continue
}
official, err := inspectOfficialPluginPackage(filepath.Join(officialDir, entry.Name()))
if err != nil {
return fmt.Errorf("校验官方插件包 %s:%w", entry.Name(), err)
}
packageData, pkg := official.data, official.info
if strings.HasPrefix(strings.TrimSpace(pkg.Manifest.Runtime.Backend), "host:") {
return fmt.Errorf("官方插件 %q 不能依赖 host 执行器", pkg.Manifest.Metadata.ID)
}
if len(pkg.Manifest.Contributes.PaymentProviders) == 0 {
if _, err := protocol.LoadInstalledProviders(pkg.ManifestRaw, nil); err != nil {
return fmt.Errorf("加载官方插件 %q:%w", pkg.Manifest.Metadata.ID, err)
}
}
id := pkg.Manifest.Metadata.ID
if _, duplicate := builtInIDs[id]; duplicate {
return fmt.Errorf("官方插件 ID %q 重复", id)
}
builtInIDs[id] = struct{}{}
manifest := pkg.Manifest
record := byID[id]
if len(record.Raw) > 0 {
var previous protocol.Manifest
if err := json.Unmarshal(record.Raw, &previous); err == nil {
manifest.Metadata.Enabled = previous.Metadata.Enabled
}
}
manifestData, err := json.Marshal(manifest)
if err != nil {
return fmt.Errorf("编码官方插件 %q:%w", id, err)
}
hash := official.hash
packageName := hash + ".yingce-plugin"
if err := cachePluginPackage(filepath.Join(c.packageDir, packageName), packageData, official.path); err != nil {
return fmt.Errorf("缓存官方插件 %q:%w", id, err)
}
now := time.Now().UTC()
if record.InstalledAt.IsZero() {
record.InstalledAt = now
}
source := PluginOriginOfficial
if _, systemPayment := systemPaymentPolicies[id]; systemPayment {
source = PluginOriginSystem
}
record.ID, record.Raw, record.Source, record.FileName = id, manifestData, source, entry.Name()
record.PackagePath, record.PackageSHA256, record.UpdatedAt = packageName, hash, now
byID[id] = record
}
bundledManifests := append(bundledWorkflowPluginManifests(), bundledPaymentPluginManifests()...)
bundledManifests = append(bundledManifests, bundledSMSPluginManifests()...)
for _, bundled := range bundledManifests {
builtInIDs[bundled.Metadata.ID] = struct{}{}
if existing, exists := byID[bundled.Metadata.ID]; exists && existing.PackagePath != "" && isSystemPaymentPluginID(bundled.Metadata.ID) {
// An official package is the executable source of truth. The bundled
// manifest remains only as a fallback when the package is unavailable.
continue
}
data, err := json.Marshal(bundled)
if err != nil {
return fmt.Errorf("encode built-in plugin %s: %w", bundled.Metadata.ID, err)
}
record := byID[bundled.Metadata.ID]
if len(record.Raw) > 0 {
var installed protocol.Manifest
if err := json.Unmarshal(record.Raw, &installed); err != nil {
return fmt.Errorf("decode built-in plugin %s: %w", bundled.Metadata.ID, err)
}
bundled.Metadata.Enabled = installed.Metadata.Enabled
data, err = json.Marshal(bundled)
if err != nil {
return fmt.Errorf("encode built-in plugin %s: %w", bundled.Metadata.ID, err)
}
}
now := time.Now().UTC()
if record.InstalledAt.IsZero() {
record.InstalledAt = now
}
source := PluginOriginOfficial
if _, systemPayment := systemPaymentPolicies[bundled.Metadata.ID]; systemPayment {
source = PluginOriginSystem
}
if isSystemSMSPluginID(bundled.Metadata.ID) {
source = PluginOriginSystem
}
record.UpdatedAt = now
record.ID, record.Raw, record.Source, record.PackagePath = bundled.Metadata.ID, data, source, ""
byID[bundled.Metadata.ID] = record
}
result := make([]pluginRegistryRecord, 0, len(byID))
for _, record := range byID {
if isBuiltInPluginSource(record.Source) {
if _, exists := builtInIDs[record.ID]; !exists {
// Built-in records are reconciled from repository packages and host
// manifests on every startup, so removed plugins cannot survive stale.
continue
}
}
result = append(result, record)
}
sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
return c.writeRegistry(result)
}
// officialPluginPackage 是一次读取官方插件包得到的全部结果:原始字节、摘要与清单。
type officialPluginPackage struct {
path string
data []byte
hash string
info protocol.PluginPackageInfo
}
type officialPluginPackageKey struct {
path string
size int64
modTime int64
}
// officialPluginPackageCache 在进程内缓存官方插件包的读取结果。
//
// 官方包随镜像发布、运行期间不会变化;以路径 + 大小 + 修改时间作键,文件被替换时
// 自然失效。生产环境每个进程只构造一次 Service,缓存几乎无收益;但测试会构造上千个
// Service,每次重新读取、哈希、解析 85MB 的官方包会让后端测试慢数分钟。
var officialPluginPackageCache sync.Map // officialPluginPackageKey -> officialPluginPackage
func inspectOfficialPluginPackage(path string) (officialPluginPackage, error) {
info, err := os.Stat(path)
if err != nil {
return officialPluginPackage{}, err
}
key := officialPluginPackageKey{path: path, size: info.Size(), modTime: info.ModTime().UnixNano()}
if cached, ok := officialPluginPackageCache.Load(key); ok {
result := cached.(officialPluginPackage)
// Manifest 含指针与切片字段;每个调用方拿到独立解码的副本,缓存条目永远不会被共享修改。
// 清单只有几 KB,重新解码的成本可以忽略,昂贵的是读包、哈希与 zip 结构校验。
var manifest protocol.Manifest
if err := json.Unmarshal(result.info.ManifestRaw, &manifest); err != nil {
return officialPluginPackage{}, err
}
result.info.Manifest = manifest
return result, nil
}
data, err := os.ReadFile(path)
if err != nil {
return officialPluginPackage{}, err
}
// 启动只需要清单:支付插件包内含多平台可执行文件(解压约 44MB/个),
// 真正落盘执行时 reload 会按摘要校验并只解压本机平台需要的文件。
pkg, err := protocol.InspectPluginPackage(data)
if err != nil {
return officialPluginPackage{}, err
}
result := officialPluginPackage{path: path, data: data, hash: pluginHash(data), info: pkg}
officialPluginPackageCache.Store(key, result)
return result, nil
}
func isSystemPaymentPluginID(id string) bool {
_, ok := systemPaymentPolicies[strings.TrimSpace(id)]
return ok
}
func isBuiltInPluginSource(source string) bool {
switch strings.TrimSpace(source) {
case "bundled", PluginOriginOfficial, PluginOriginSystem:
return true
default:
return false
}
}
func officialPluginPackageDir() (string, error) {
if configured := strings.TrimSpace(os.Getenv("CANVAS_OFFICIAL_PLUGIN_DIR")); configured != "" {
info, err := os.Stat(configured)
if err != nil || !info.IsDir() {
return "", fmt.Errorf("CANVAS_OFFICIAL_PLUGIN_DIR 不是可读目录:%s", configured)
}
return configured, nil
}
workingDir, err := os.Getwd()
if err != nil {
return "", err
}
candidates := []string{"/app/plugin-packages"}
current := workingDir
for range 8 {
candidates = append(candidates, filepath.Join(current, "plugin-packages"))
parent := filepath.Dir(current)
if parent == current {
break
}
current = parent
}
for _, candidate := range candidates {
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
return candidate, nil
}
}
return "", errors.New("未找到官方 plugin-packages 目录;请设置 CANVAS_OFFICIAL_PLUGIN_DIR")
}
func (c *pluginRuntime) reload() error {
c.mu.Lock()
defer c.mu.Unlock()
stored, err := c.readRegistry()
if err != nil {
return err
}
plugins := make(map[string]pluginRecord)
for _, storedRecord := range stored {
data := storedRecord.Raw
if len(data) > protocolPluginMaxBytes {
return fmt.Errorf("plugin %s exceeds %d bytes", storedRecord.ID, protocolPluginMaxBytes)
}
var manifest protocol.Manifest
if err := json.Unmarshal(data, &manifest); err != nil {
return fmt.Errorf("decode plugin %s: %w", storedRecord.ID, err)
}
metadata := manifest.Metadata
if strings.TrimSpace(metadata.ID) == "" {
return fmt.Errorf("plugin %s has no metadata id", storedRecord.ID)
}
if _, exists := plugins[metadata.ID]; exists {
return fmt.Errorf("duplicate installed protocol %q", metadata.ID)
}
packageSHA256 := storedRecord.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"}
}
registry, err := protocol.NewRegistry()
if err != nil {
return err
}
for id, record := range plugins {
var manifest protocol.Manifest
if err := json.Unmarshal(record.Raw, &manifest); err != nil {
record.Error = err.Error()
plugins[id] = record
continue
}
if len(manifest.Contributes.PaymentProviders) > 0 && record.Source == PluginOriginUploaded {
backend := strings.TrimSpace(manifest.Runtime.Backend)
if strings.HasPrefix(backend, "host:") {
record.Metadata.Enabled = false
record.Status = "invalid"
record.Error = "首期支付插件只能使用系统宿主适配器"
plugins[id] = record
continue
}
if backend != "rpc" && backend != "wasm" {
record.Metadata.Enabled = false
record.Status = "invalid"
record.Error = "上传支付插件必须声明 rpc 或 wasm 后端"
plugins[id] = record
continue
}
}
if len(manifest.Contributes.SMSProviders) > 0 && (record.Source == PluginOriginUploaded || !isSystemSMSPluginID(id)) {
record.Metadata.Enabled, record.Status, record.Error = false, "invalid", "短信插件仅支持系统宿主适配器"
plugins[id] = record
continue
}
if (len(manifest.Contributes.PaymentProviders) > 0 || len(manifest.Contributes.SMSProviders) > 0) && len(manifest.Contributes.Providers) == 0 {
if record.Metadata.Enabled {
record.Status = "enabled"
} else {
record.Status = "disabled"
}
plugins[id] = record
continue
}
adapters, loadErr := protocol.LoadInstalledProviders(record.Raw, nil)
if loadErr != nil {
record.Metadata.Enabled = false
record.Metadata.UnavailableReason = loadErr.Error()
record.Error = loadErr.Error()
_ = registry.Register(protocol.UnavailableAdapter{Info: record.Metadata})
plugins[id] = record
continue
}
if !record.Metadata.Enabled {
record.Status = "disabled"
for _, adapter := range adapters {
info := adapter.Metadata()
info.Enabled = false
_ = registry.Register(protocol.UnavailableAdapter{Info: info})
}
plugins[id] = record
continue
}
registrationFailed := false
for _, adapter := range adapters {
if err := registry.Register(adapter); err != nil {
record.Error = err.Error()
registrationFailed = true
}
}
if registrationFailed {
plugins[id] = record
continue
}
record.Status = "enabled"
plugins[id] = record
}
c.plugins = plugins
c.registry = registry
// Payment adapters are loaded exclusively from validated plugin packages.
// An empty registry is intentional when the official package is unavailable;
// payment writes must fail closed instead of silently using host code.
basePayment, registryErr := payment.NewRegistry()
if registryErr != nil {
return registryErr
}
dynamicPayments := make([]payment.Provider, 0)
for id, record := range plugins {
if (record.Source != PluginOriginUploaded && record.Source != PluginOriginOfficial && record.Source != PluginOriginSystem) || !record.Metadata.Enabled || record.Status != "enabled" {
continue
}
var manifest protocol.Manifest
if err := json.Unmarshal(record.Raw, &manifest); err != nil || len(manifest.Contributes.PaymentProviders) == 0 {
continue
}
if strings.TrimSpace(manifest.Runtime.Backend) != "rpc" {
record.Status = "invalid"
record.Error = "wasm 支付运行时尚未启用"
plugins[id] = record
continue
}
packageData, readErr := os.ReadFile(filepath.Join(c.packageDir, filepath.Base(record.PackagePath)))
if readErr != nil {
record.Status = "invalid"
record.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
runtimeDir, materializeErr := materializePaymentBackendFromPackage(c.packageDir, packageSHA256, manifest.Runtime.BackendEntry, packageData)
if materializeErr != nil {
record.Status = "invalid"
record.Error = materializeErr.Error()
plugins[id] = record
continue
}
for _, contribution := range manifest.Contributes.PaymentProviders {
provider, providerErr := payment.NewRPCProvider(payment.DescriptorFromManifest(manifest, contribution), runtimeDir, manifest.Runtime.BackendEntry)
if providerErr != nil {
record.Status = "invalid"
record.Error = providerErr.Error()
continue
}
dynamicPayments = append(dynamicPayments, provider)
}
}
dynamicIDs := make(map[string]struct{}, len(dynamicPayments))
for _, provider := range dynamicPayments {
dynamicIDs[provider.Descriptor().ID] = struct{}{}
}
baseProviders := make([]payment.Provider, 0)
for _, provider := range basePayment.Providers() {
if _, replaced := dynamicIDs[provider.Descriptor().ID]; !replaced {
baseProviders = append(baseProviders, provider)
}
}
providers := append(baseProviders, dynamicPayments...)
c.paymentRegistry, err = payment.NewRegistry(providers...)
if err != nil {
return err
}
c.plugins = plugins
return nil
}
// materializePaymentBackendFromPackage 先按摘要检查运行目录是否已就绪,只有需要落盘时
// 才完整解析插件包。摘要已由调用方对原始字节校验,就绪标记与摘要一一对应,
// 因此跳过解析不会放过被篡改的包。
func materializePaymentBackendFromPackage(packageDir, hash, backendEntry string, packageData []byte) (string, error) {
digest := strings.ToLower(strings.TrimSpace(hash))
root := filepath.Join(packageDir, "runtime", digest)
if len(digest) == sha256.Size*2 && paymentRuntimeReady(root, digest, strings.TrimSpace(backendEntry)) {
return root, nil
}
if len(digest) == sha256.Size*2 && linkReadyPaymentRuntime(packageDir, digest, strings.TrimSpace(backendEntry)) {
return root, nil
}
defer func() { rememberPaymentRuntime(digest, root, strings.TrimSpace(backendEntry)) }()
// 只解压本机平台能执行的文件:官方包内含 7 个平台的可执行文件,
// 其余平台的产物永远不会被 ResolveRPCBackendPath 选中,解压只是浪费 IO。
entry := strings.TrimSpace(backendEntry)
hostCandidates := make(map[string]struct{})
for _, candidate := range protocol.PaymentRPCBackendCandidates(entry, runtime.GOOS, runtime.GOARCH) {
hostCandidates[candidate] = struct{}{}
}
pkg, err := protocol.ParsePluginPackageFiles(packageData, func(name string) bool {
if !strings.HasPrefix(name, "backend/") {
return false
}
if _, host := hostCandidates[name]; host {
return true
}
return !protocol.IsPaymentRPCBackendArtifact(entry, name)
})
if err != nil {
return "", err
}
return materializePaymentBackend(packageDir, hash, pkg)
}
// readyPaymentRuntimes 记录本进程内已就绪的支付插件运行目录(摘要 -> 目录)。
// 同一摘要的运行目录内容完全相同,新的数据目录可以直接硬链接复用,
// 避免每次重新解压约 6MB 的可执行文件。生产环境只有一个数据目录,这里不会命中。
var readyPaymentRuntimes sync.Map // digest -> root
func rememberPaymentRuntime(digest, root, backendEntry string) {
if paymentRuntimeReady(root, digest, backendEntry) {
readyPaymentRuntimes.Store(digest, root)
}
}
// linkReadyPaymentRuntime 把本进程已就绪的同摘要运行目录硬链接到 packageDir。
// 源目录必须仍然通过就绪校验;任何一步失败都返回 false,由调用方走完整解压。
func linkReadyPaymentRuntime(packageDir, digest, backendEntry string) bool {
cached, ok := readyPaymentRuntimes.Load(digest)
if !ok {
return false
}
source := cached.(string)
root := filepath.Join(packageDir, "runtime", digest)
if source == root || !paymentRuntimeReady(source, digest, backendEntry) {
return false
}
runtimeRoot := filepath.Join(packageDir, "runtime")
if err := os.MkdirAll(runtimeRoot, 0o700); err != nil {
return false
}
temporaryRoot, err := os.MkdirTemp(runtimeRoot, ".payment-runtime-")
if err != nil {
return false
}
defer os.RemoveAll(temporaryRoot)
linkErr := filepath.WalkDir(source, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(source, path)
if err != nil {
return err
}
target := filepath.Join(temporaryRoot, rel)
if entry.IsDir() {
return os.MkdirAll(target, 0o700)
}
if !entry.Type().IsRegular() {
return fmt.Errorf("unexpected runtime entry %q", rel)
}
return os.Link(path, target)
})
if linkErr != nil || os.Rename(temporaryRoot, root) != nil {
return false
}
return paymentRuntimeReady(root, digest, backendEntry)
}
func materializePaymentBackend(packageDir, hash string, pkg protocol.PluginPackage) (string, error) {
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 {
// content 为 nil 表示该条目按需未解压(见 ParsePluginPackageFiles),不能写成空文件。
if content == nil || !strings.HasPrefix(name, "backend/") || strings.HasSuffix(name, "/") {
continue
}
target := filepath.Join(temporaryRoot, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
return "", err
}
if err := os.WriteFile(target, content, 0o700); err != nil {
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
}
return protocol.HasAnyPaymentRPCBackend(backendEntry, paymentRuntimeBackendFiles(root))
}
func paymentRuntimeBackendFiles(root string) map[string][]byte {
files := map[string][]byte{}
backendRoot := filepath.Join(root, "backend")
_ = filepath.WalkDir(backendRoot, func(path string, entry fs.DirEntry, err error) error {
if err != nil || entry.IsDir() {
return err
}
rel, relErr := filepath.Rel(root, path)
if relErr != nil {
return nil
}
files[filepath.ToSlash(rel)] = []byte{1}
return nil
})
return files
}
func (c *pluginRuntime) list() []PluginView {
c.mu.RLock()
defer c.mu.RUnlock()
items := make([]PluginView, 0, len(c.plugins))
for _, item := range c.plugins {
items = append(items, PluginView{Manifest: pluginManifestView(item.Raw, item.Metadata, item.Source), Source: item.Source, FileName: item.FileName, Package: protocol.PluginPackageFormat, SHA256: item.SHA256, InstalledAt: item.InstalledAt, UpdatedAt: item.UpdatedAt, Status: item.Status, Error: item.Error})
}
sort.Slice(items, func(i, j int) bool { return items[i].Manifest.ID < items[j].Manifest.ID })
return items
}
func (c *pluginRuntime) registrySnapshot() *protocol.Registry {
c.mu.RLock()
defer c.mu.RUnlock()
return c.registry
}
func (c *pluginRuntime) paymentRegistrySnapshot() *payment.Registry {
c.mu.RLock()
defer c.mu.RUnlock()
return c.paymentRegistry
}
func (c *pluginRuntime) install(data []byte, fileName string) (PluginView, error) {
c.mutationMu.Lock()
defer c.mutationMu.Unlock()
if len(data) == 0 || len(data) > protocol.PluginPackageMaxBytes {
return PluginView{}, fmt.Errorf("plugin package must be between 1 and %d bytes", protocol.PluginPackageMaxBytes)
}
pkg, err := protocol.ParsePluginPackage(data)
if err != nil {
return PluginView{}, err
}
manifest := pkg.Manifest
if strings.HasPrefix(strings.TrimSpace(manifest.Runtime.Backend), "host:") {
return PluginView{}, errors.New("上传插件不能使用宿主内置执行器")
}
if len(manifest.Contributes.PaymentProviders) == 0 {
if _, err := protocol.LoadInstalledProviders(pkg.ManifestRaw, nil); err != nil {
return PluginView{}, err
}
}
c.mu.RLock()
existing, exists := c.plugins[manifest.Metadata.ID]
c.mu.RUnlock()
if exists && isBuiltInPluginSource(existing.Source) && !isPaymentPluginManifest(manifest) {
return PluginView{}, fmt.Errorf("内置插件 %q 不能通过上传覆盖", manifest.Metadata.ID)
}
manifest.Metadata.Enabled = !exists || existing.Metadata.Enabled
manifestData, err := json.Marshal(manifest)
if err != nil {
return PluginView{}, err
}
hash := pluginHash(data)
packageName := filepath.Base(strings.TrimSpace(fileName))
if packageName == "." || packageName == "" || packageName == string(filepath.Separator) {
packageName = manifest.Metadata.ID + ".yingce-plugin"
}
packagePath := filepath.Join(c.packageDir, hash+".yingce-plugin")
if err := writePluginFile(packagePath, data); err != nil {
return PluginView{}, fmt.Errorf("保存插件包失败:%w", err)
}
stored, err := c.readRegistry()
if err != nil {
_ = os.Remove(packagePath)
return PluginView{}, err
}
previousStored := append([]pluginRegistryRecord(nil), stored...)
now := time.Now().UTC()
newRecord := pluginRegistryRecord{ID: manifest.Metadata.ID, Raw: manifestData, Source: "uploaded", FileName: packageName, PackagePath: filepath.Base(packagePath), PackageSHA256: hash, InstalledAt: now, UpdatedAt: now}
if exists {
newRecord.InstalledAt = existing.InstalledAt
for index := range stored {
if stored[index].ID == manifest.Metadata.ID {
stored[index] = newRecord
break
}
}
} else {
stored = append(stored, newRecord)
}
if err := c.writeRegistry(stored); err != nil {
_ = os.Remove(packagePath)
return PluginView{}, fmt.Errorf("保存插件失败:%w", err)
}
if err := c.reload(); err != nil {
_ = c.writeRegistry(previousStored)
_ = c.reload()
_ = os.Remove(packagePath)
return PluginView{}, err
}
if exists && existing.PackagePath != "" && existing.PackagePath != filepath.Base(packagePath) {
_ = os.Remove(filepath.Join(c.packageDir, filepath.Base(existing.PackagePath)))
}
for _, item := range c.list() {
if item.Manifest.ID == manifest.Metadata.ID {
return item, nil
}
}
return PluginView{}, errors.New("插件保存后未加载")
}
func isPaymentPluginManifest(manifest protocol.Manifest) bool {
backend := strings.TrimSpace(manifest.Runtime.Backend)
return len(manifest.Contributes.PaymentProviders) > 0 && (backend == "rpc" || backend == "wasm")
}
func (c *pluginRuntime) setEnabled(id string, enabled bool) (PluginView, error) {
c.mutationMu.Lock()
defer c.mutationMu.Unlock()
c.mu.RLock()
record, ok := c.plugins[strings.TrimSpace(id)]
c.mu.RUnlock()
if !ok {
return PluginView{}, fmt.Errorf("插件 %q 不存在", id)
}
var manifest protocol.Manifest
if err := json.Unmarshal(record.Raw, &manifest); err != nil {
return PluginView{}, err
}
manifest.Metadata.Enabled = enabled
data, err := json.Marshal(manifest)
if err != nil {
return PluginView{}, err
}
stored, err := c.readRegistry()
if err != nil {
return PluginView{}, err
}
for index := range stored {
if stored[index].ID == record.Metadata.ID {
stored[index].Raw = data
stored[index].UpdatedAt = time.Now().UTC()
}
}
if err := c.writeRegistry(stored); err != nil {
return PluginView{}, err
}
if err := c.reload(); err != nil {
return PluginView{}, err
}
for _, item := range c.list() {
if item.Manifest.ID == manifest.Metadata.ID {
return item, nil
}
}
return PluginView{}, errors.New("插件状态更新后未加载")
}
func pluginManifestView(raw []byte, metadata protocol.Metadata, source string) PluginManifestView {
var manifest protocol.Manifest
_ = json.Unmarshal(raw, &manifest)
if manifest.Metadata.ID == "" {
manifest.Metadata = metadata
}
return PluginManifestView{
ID: metadata.ID, Name: metadata.Name, Version: metadata.Version, APIVersion: "yingce.plugin/v1", Entry: manifest.Entry, Surfaces: manifest.Surfaces,
Description: metadata.Description, Documentation: metadata.Documentation, Author: metadata.Vendor,
Permissions: manifest.Permissions, Trusted: isBuiltInPluginSource(source), Runtime: manifest.Runtime,
Configuration: manifest.Configuration, Contributes: manifest.Contributes,
}
}
func (c *pluginRuntime) uninstall(id string) error {
c.mutationMu.Lock()
defer c.mutationMu.Unlock()
c.mu.RLock()
record, ok := c.plugins[strings.TrimSpace(id)]
c.mu.RUnlock()
if !ok {
return fmt.Errorf("插件 %q 不存在", id)
}
if isBuiltInPluginSource(record.Source) {
return fmt.Errorf("内置插件 %q 不能卸载,可停用该插件", id)
}
stored, err := c.readRegistry()
if err != nil {
return err
}
filtered := stored[:0]
for _, item := range stored {
if item.ID != record.Metadata.ID {
filtered = append(filtered, item)
}
}
if err := c.writeRegistry(filtered); err != nil {
return err
}
if err := c.reload(); err != nil {
return err
}
if record.PackagePath != "" {
_ = os.Remove(filepath.Join(c.packageDir, filepath.Base(record.PackagePath)))
}
return nil
}
func (c *pluginRuntime) readRegistry() ([]pluginRegistryRecord, error) {
data, err := os.ReadFile(c.registryPath)
if errors.Is(err, os.ErrNotExist) {
return []pluginRegistryRecord{}, nil
}
if err != nil {
return nil, err
}
if len(data) > protocolPluginMaxBytes*64 {
return nil, fmt.Errorf("插件 registry 超过大小限制")
}
var records []pluginRegistryRecord
if err := json.Unmarshal(data, &records); err != nil {
return nil, fmt.Errorf("读取插件 registry 失败:%w", err)
}
return records, nil
}
func (c *pluginRuntime) writeRegistry(records []pluginRegistryRecord) error {
data, err := json.MarshalIndent(records, "", " ")
if err != nil {
return err
}
return writePluginFile(c.registryPath, data)
}
// cachePluginPackage 写入以内容摘要命名的插件包缓存。文件名即摘要,且 writePluginFile
// 通过临时文件 + rename 原子发布,所以同名文件存在且大小一致就是同一份内容,无需重写。
//
// source 非空时先尝试硬链接到官方包原文件(同一文件系统内零拷贝);跨文件系统
// (生产环境镜像目录与 /data 卷)会失败并回退为普通写入。即使链接到的内容与摘要
// 不一致,reload 也会按摘要校验并把插件标为 invalid,不会放过错误的包。
func cachePluginPackage(path string, data []byte, source string) error {
if info, err := os.Stat(path); err == nil && info.Mode().IsRegular() && info.Size() == int64(len(data)) {
return nil
}
if source != "" && os.Link(source, path) == nil {
return nil
}
return writePluginFile(path, data)
}
func writePluginFile(path string, data []byte) error {
temporary, err := os.CreateTemp(filepath.Dir(path), ".plugin-*.tmp")
if err != nil {
return err
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
if err := temporary.Chmod(0o600); err != nil {
_ = temporary.Close()
return err
}
if _, err := temporary.Write(data); err != nil {
_ = temporary.Close()
return err
}
if err := temporary.Sync(); err != nil {
_ = temporary.Close()
return err
}
if err := temporary.Close(); err != nil {
return err
}
return os.Rename(temporaryPath, path)
}
func pluginSource(metadata protocol.Metadata) string {
return "uploaded"
}
func pluginHash(data []byte) string {
digest := sha256.Sum256(data)
return hex.EncodeToString(digest[:])
}