diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..5b1f7b6bf --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +plugin-packages/*.sh text eol=lf diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index ca5637d96..00c21abe6 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -27,6 +27,25 @@ jobs: - name: Run Go tests run: go test ./... + payment-plugins: + name: Payment plugin artifacts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Verify canonical payment packages + env: + PAYMENT_PLUGIN_SMOKE_TEST: "1" + run: sh plugin-packages/verify-payment-packages.sh linux amd64 + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + - name: Build and execute payment packages for every image platform + run: >- + docker buildx build + --platform linux/amd64,linux/arm64 + --target payment-package-smoke + --file backend/Dockerfile + . + web: name: Web checks runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 89757627a..d9ccd8b74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- 修复微信支付与支付宝插件被宿主平台二进制污染的问题,按 Docker 目标平台确定性生成并重打包 Linux amd64/arm64 provider,增加内容寻址的原子运行时缓存、可执行文件诊断和 CI 架构冒烟校验。 + ## v1.2.5 - 素材库支持用户自定义分类、分页筛选、网格密度切换、图片完整预览与单张缩放拖拽。 diff --git a/backend/Dockerfile b/backend/Dockerfile index b2b0390fd..7b9748fe3 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,5 +1,42 @@ # syntax=docker/dockerfile:1.7 +ARG BUILDPLATFORM +ARG TARGETPLATFORM + +FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS payment-package-build + +ARG TARGETOS +ARG TARGETARCH +ARG TARGETPLATFORM +ARG BUILDPLATFORM +ARG GOPROXY=https://proxy.golang.org,direct +ENV GOPROXY=$GOPROXY + +RUN apk add --no-cache nodejs unzip zip + +WORKDIR /src +COPY backend/go.mod backend/go.sum ./backend/ +RUN --mount=type=cache,target=/go/pkg/mod cd backend && go mod download +COPY backend ./backend +COPY plugin-packages ./plugin-packages +RUN --mount=type=cache,target=/root/.cache/go-build set -eu; \ + test -n "$TARGETOS"; \ + test -n "$TARGETARCH"; \ + PAYMENT_PLUGIN_GOOS="$TARGETOS" \ + PAYMENT_PLUGIN_GOARCH="$TARGETARCH" \ + PAYMENT_PLUGIN_CGO_ENABLED=0 \ + ./plugin-packages/build-packages.sh --payments-only; \ + sh ./plugin-packages/verify-payment-packages.sh "$TARGETOS" "$TARGETARCH" + +FROM --platform=$TARGETPLATFORM alpine:3.22 AS payment-package-smoke + +ARG TARGETOS +ARG TARGETARCH +RUN apk add --no-cache unzip +COPY --from=payment-package-build /src/plugin-packages /app/plugin-packages +RUN PAYMENT_PLUGIN_SMOKE_TEST=1 \ + sh /app/plugin-packages/verify-payment-packages.sh "$TARGETOS" "$TARGETARCH" + FROM golang:1.25-alpine AS backend-build ARG GOPROXY=https://proxy.golang.org,direct @@ -37,7 +74,7 @@ RUN mkdir -p /data && chown -R app:app /data COPY --from=backend-build /out/infinite-canvas-backend /usr/local/bin/infinite-canvas-backend COPY --from=backend-build /out/migrate-schema /usr/local/bin/migrate-schema COPY --from=backend-build /out/migrate-sqlite-postgres /usr/local/bin/migrate-sqlite-postgres -COPY plugin-packages /app/plugin-packages +COPY --from=payment-package-smoke /app/plugin-packages /app/plugin-packages USER app EXPOSE 8080 diff --git a/backend/internal/payment/rpc.go b/backend/internal/payment/rpc.go index 2205e401e..573c64c25 100644 --- a/backend/internal/payment/rpc.go +++ b/backend/internal/payment/rpc.go @@ -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) } diff --git a/backend/internal/payment/rpc_test.go b/backend/internal/payment/rpc_test.go index 66c27fa57..266b5570e 100644 --- a/backend/internal/payment/rpc_test.go +++ b/backend/internal/payment/rpc_test.go @@ -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 +} diff --git a/backend/internal/protocol/payment_artifact_test.go b/backend/internal/protocol/payment_artifact_test.go new file mode 100644 index 000000000..d7c7b2d14 --- /dev/null +++ b/backend/internal/protocol/payment_artifact_test.go @@ -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) + } + } + }) + } +} diff --git a/backend/internal/service/payment_runtime_cache_test.go b/backend/internal/service/payment_runtime_cache_test.go new file mode 100644 index 000000000..8a7352b25 --- /dev/null +++ b/backend/internal/service/payment_runtime_cache_test.go @@ -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) + } +} diff --git a/backend/internal/service/protocol_plugins.go b/backend/internal/service/protocol_plugins.go index 1b5188f47..047de1c5c 100644 --- a/backend/internal/service/protocol_plugins.go +++ b/backend/internal/service/protocol_plugins.go @@ -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() diff --git a/docs/content/docs/plugins/payment-plugin-runtime.mdx b/docs/content/docs/plugins/payment-plugin-runtime.mdx index 9ad37b0ad..be83ea67e 100644 --- a/docs/content/docs/plugins/payment-plugin-runtime.mdx +++ b/docs/content/docs/plugins/payment-plugin-runtime.mdx @@ -29,6 +29,27 @@ Manifest 的支付贡献声明 `runtime.backend` 为 `rpc` 或 `wasm`,并通 请求固定带有 `version: "yingce.payment/v1"`。插件不得访问宿主数据库,不得自行入账积分。宿主目前控制进程工作目录、环境变量、标准输出大小和 30 秒超时;支付宝、微信实现还会在插件内部校验网关和账单下载域名。通用插件的操作系统级沙箱、签名校验和网络白名单尚未启用,不应直接运行不受信任的支付插件。 +## 原生包的平台边界 + +`runtime.backend: "rpc"` 的 `backend/provider` 是原生可执行文件,不是平台无关静态资源。官方 backend 镜像在 Docker 构建中读取 `TARGETOS` 和 `TARGETARCH`,以 `CGO_ENABLED=0` 分别生成目标平台 provider,并在同一阶段重新生成 `.yingce-plugin`;目录文件与包内文件会比较内容,并在目标平台容器中真实执行冒烟请求。因此 Apple Silicon、Intel Mac 或 Windows 上发起的 Docker 构建不会把宿主二进制复制进 Linux 镜像。 + +仓库中保留的官方支付包用于 Linux amd64 源码运行和回归测试,其规范平台固定为 `linux/amd64`。在其他平台直接运行源码时,应先在具有 Node.js 和 `zip` 的 POSIX shell 中执行: + +```bash +PAYMENT_PLUGIN_GOOS="$(go env GOOS)" \ +PAYMENT_PLUGIN_GOARCH="$(go env GOARCH)" \ +PAYMENT_PLUGIN_CGO_ENABLED=0 \ + ./plugin-packages/build-packages.sh --payments-only +``` + +当前 manifest 尚未声明 native artifact 的 OS/CPU;第三方市场若要分发原生 RPC 插件,应后续在 package index 或 manifest ABI 中增加平台选择信息,不能把单一 native package 视为跨平台包。 + +## 运行时缓存与诊断 + +宿主以完整 `.yingce-plugin` 字节的 SHA-256 作为 package 文件名和 `runtime/` 缓存键。provider 内容变化会产生新 digest 和新运行目录;解包先写临时目录,完成后写入 ready marker 并原子发布,旧目录不会覆盖新包,也不要求升级时手工清空整个数据卷。 + +执行前,Linux 宿主会拒绝 Mach-O、Windows PE、损坏的 ELF 和与当前 CPU 不匹配的 ELF。管理员可区分可执行文件缺失或加载器不可用、权限不足、格式或架构不兼容、进程异常退出、超时和无效 JSON。服务端启动日志只记录插件 ID、provider ID、宿主平台、可执行路径和操作系统错误;不会记录 RPC 请求、支付配置、密钥或插件 stderr。 + ## Manifest 示例 ```json diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 6443190cb..bfee8fe00 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,13 @@ description: 影策已实现功能的手工验证清单与未确认边界 # 待测试 +## 支付插件跨平台构建 + +- [x] 微信与支付宝 provider 已使用同一 target-aware 构建脚本完成 `CGO_ENABLED=0` 的 linux/amd64、linux/arm64 交叉编译;目录 provider 与 `.yingce-plugin` 内 provider 的 ELF 架构和内容一致性校验通过。 +- [x] 包内外产物一致性、完整包摘要缓存、同版本 provider 更新失效、未完成解包恢复等专项测试通过;RPC 错误映射的跨平台单元测试通过。 +- [ ] 在 Linux CI 完成无文件、无权限、错误格式、异常退出与无效 JSON 的 RPC 进程测试,并使用 Docker Buildx/QEMU 执行 `payment-package-smoke` 的 amd64/arm64 双平台构建,确认目标容器内两个 provider 均能真实执行无效 JSON 冒烟请求。 +- [ ] 保留含旧 Mach-O runtime 的生产 `/data` 卷升级到修复镜像,确认启动后自动选择新 package digest,管理后台保存微信和支付宝配置进入真实凭证校验。 + ## 平台外观管理 管理后台已新增“系统配置 → 外观管理”,并让公开品牌配置在应用渲染前完成解析;以下真实浏览器与媒体场景需要持续确认: diff --git a/plugin-packages/build-packages.sh b/plugin-packages/build-packages.sh index 4ff05ad4b..1dc9b892a 100755 --- a/plugin-packages/build-packages.sh +++ b/plugin-packages/build-packages.sh @@ -2,21 +2,69 @@ set -eu root_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +repo_dir=$(CDPATH= cd -- "$root_dir/.." && pwd) +payment_plugins="official-payment-wechat-native official-payment-alipay-page" +payments_only=false -node "$root_dir/embed-documentation.mjs" +if [ "${1:-}" = "--payments-only" ]; then + payments_only=true + shift +fi +if [ "$#" -ne 0 ]; then + echo "usage: $0 [--payments-only]" >&2 + exit 2 +fi + +if [ "$payments_only" = true ]; then + node "$root_dir/embed-documentation.mjs" $payment_plugins +else + node "$root_dir/embed-documentation.mjs" +fi # Payment backends are executable plugin payloads. They are compiled outside # the host binary and then included under backend/provider in the package. -repo_dir=$(CDPATH= cd -- "$root_dir/.." && pwd) -for payment_plugin in official-payment-wechat-native official-payment-alipay-page; do - mkdir -p "$root_dir/$payment_plugin/backend" -done -(cd "$repo_dir/backend" && go build -trimpath -ldflags='-s -w' -o "$root_dir/official-payment-wechat-native/backend/provider" ./cmd/payment-wechat) -(cd "$repo_dir/backend" && go build -trimpath -ldflags='-s -w' -o "$root_dir/official-payment-alipay-page/backend/provider" ./cmd/payment-alipay) +payment_goos=${PAYMENT_PLUGIN_GOOS:-$(go env GOOS)} +payment_goarch=${PAYMENT_PLUGIN_GOARCH:-$(go env GOARCH)} +payment_cgo=${PAYMENT_PLUGIN_CGO_ENABLED:-0} -for manifest in "$root_dir"/*/manifest.json; do - package_dir=${manifest%/manifest.json} - package_id=${package_dir##*/} +case "$payment_cgo" in + 0|1) ;; + *) + echo "PAYMENT_PLUGIN_CGO_ENABLED must be 0 or 1" >&2 + exit 2 + ;; +esac + +build_payment_provider() { + package_id=$1 + command_path=$2 + output_file="$root_dir/$package_id/backend/provider" + temporary_file="$output_file.tmp" + + mkdir -p "$(dirname -- "$output_file")" + rm -f "$temporary_file" + ( + cd "$repo_dir/backend" + CGO_ENABLED="$payment_cgo" \ + GOOS="$payment_goos" \ + GOARCH="$payment_goarch" \ + go build -trimpath -ldflags='-s -w' -o "$temporary_file" "$command_path" + ) + mv "$temporary_file" "$output_file" + chmod 0755 "$output_file" + + build_info=$(go version -m "$output_file") + printf '%s\n' "$build_info" | grep -F "GOOS=$payment_goos" >/dev/null + printf '%s\n' "$build_info" | grep -F "GOARCH=$payment_goarch" >/dev/null + printf '%s\n' "$build_info" | grep -F "CGO_ENABLED=$payment_cgo" >/dev/null +} + +build_payment_provider official-payment-wechat-native ./cmd/payment-wechat +build_payment_provider official-payment-alipay-page ./cmd/payment-alipay + +package_plugin() { + package_id=$1 + package_dir="$root_dir/$package_id" output_file="$root_dir/$package_id.yingce-plugin" temporary_file="$root_dir/.$package_id.yingce-plugin.tmp" rm -f "$temporary_file" @@ -25,4 +73,21 @@ for manifest in "$root_dir"/*/manifest.json; do find manifest.json README.md docs assets web backend LICENSE -type f 2>/dev/null | LC_ALL=C sort | zip -X -q "$temporary_file" -@ ) mv "$temporary_file" "$output_file" +} + +if [ "$payments_only" = true ]; then + for payment_plugin in $payment_plugins; do + package_plugin "$payment_plugin" + done + exit 0 +fi + +for payment_plugin in $payment_plugins; do + mkdir -p "$root_dir/$payment_plugin/backend" +done + +for manifest in "$root_dir"/*/manifest.json; do + package_dir=${manifest%/manifest.json} + package_id=${package_dir##*/} + package_plugin "$package_id" done diff --git a/plugin-packages/embed-documentation.mjs b/plugin-packages/embed-documentation.mjs index 529f81de2..209c10e9a 100644 --- a/plugin-packages/embed-documentation.mjs +++ b/plugin-packages/embed-documentation.mjs @@ -8,6 +8,10 @@ const contractEnd = ""; const recursiveDocumentationPlaceholder = "<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>"; +function normalizeNewlines(document) { + return document.replace(/\r\n?/g, "\n"); +} + function withoutGeneratedContract(document) { const start = document.indexOf(contractStart); if (start < 0) return document.trimEnd(); @@ -45,19 +49,21 @@ for (const packageID of packageDirectories) { const packageRoot = join(root, packageID); let manifestSource; try { - manifestSource = await readFile(join(packageRoot, "manifest.json"), "utf8"); + manifestSource = normalizeNewlines(await readFile(join(packageRoot, "manifest.json"), "utf8")); } catch (error) { if (error?.code === "ENOENT") continue; throw error; } const manifest = JSON.parse(manifestSource); - const readme = (await readFile(join(packageRoot, "README.md"), "utf8")).trim(); + const readmePath = join(packageRoot, "README.md"); + const readme = normalizeNewlines(await readFile(readmePath, "utf8")).trim(); const interfacePath = join(packageRoot, "docs", "interface.md"); - const interfaceSource = await readFile(interfacePath, "utf8"); + const interfaceSource = normalizeNewlines(await readFile(interfacePath, "utf8")); const interfaceDocument = `${withoutGeneratedContract(interfaceSource)}\n\n${renderContract(manifest)}\n`; manifest.documentation = `${readme}\n\n---\n\n${interfaceDocument.trim()}\n`; + await writeFile(readmePath, `${readme}\n`); await writeFile(interfacePath, interfaceDocument); await writeFile(join(packageRoot, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`); updated += 1; diff --git a/plugin-packages/official-payment-alipay-page.yingce-plugin b/plugin-packages/official-payment-alipay-page.yingce-plugin index 33725630b..3dabc19fd 100644 Binary files a/plugin-packages/official-payment-alipay-page.yingce-plugin and b/plugin-packages/official-payment-alipay-page.yingce-plugin differ diff --git a/plugin-packages/official-payment-alipay-page/backend/provider b/plugin-packages/official-payment-alipay-page/backend/provider index 96db21374..4a8fbdbe4 100755 Binary files a/plugin-packages/official-payment-alipay-page/backend/provider and b/plugin-packages/official-payment-alipay-page/backend/provider differ diff --git a/plugin-packages/official-payment-wechat-native.yingce-plugin b/plugin-packages/official-payment-wechat-native.yingce-plugin index 5eac518c6..7322857b4 100644 Binary files a/plugin-packages/official-payment-wechat-native.yingce-plugin and b/plugin-packages/official-payment-wechat-native.yingce-plugin differ diff --git a/plugin-packages/official-payment-wechat-native/backend/provider b/plugin-packages/official-payment-wechat-native/backend/provider index d2d1f824b..25849a72d 100755 Binary files a/plugin-packages/official-payment-wechat-native/backend/provider and b/plugin-packages/official-payment-wechat-native/backend/provider differ diff --git a/plugin-packages/verify-payment-packages.sh b/plugin-packages/verify-payment-packages.sh new file mode 100644 index 000000000..e5936e876 --- /dev/null +++ b/plugin-packages/verify-payment-packages.sh @@ -0,0 +1,65 @@ +#!/bin/sh +set -eu + +root_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +expected_goos=${1:-} +expected_goarch=${2:-} + +if [ "$expected_goos" != "linux" ]; then + echo "payment package verification currently requires expected GOOS linux" >&2 + exit 2 +fi + +case "$expected_goarch" in + amd64) expected_machine=3e00 ;; + arm64) expected_machine=b700 ;; + *) + echo "unsupported payment package architecture: $expected_goarch" >&2 + exit 2 + ;; +esac + +temporary_dir=$(mktemp -d) +trap 'rm -rf "$temporary_dir"' EXIT HUP INT TERM + +for package_id in official-payment-wechat-native official-payment-alipay-page; do + provider="$root_dir/$package_id/backend/provider" + package_file="$root_dir/$package_id.yingce-plugin" + archived_provider="$temporary_dir/$package_id-provider" + + test -f "$provider" + if [ "$(uname -s)" = "Linux" ]; then + test -x "$provider" + fi + test -f "$package_file" + + magic=$(od -An -tx1 -N4 "$provider" | tr -d '[:space:]') + if [ "$magic" != "7f454c46" ]; then + echo "$package_id provider is not an ELF executable (magic=$magic)" >&2 + exit 1 + fi + + class=$(od -An -tx1 -j4 -N1 "$provider" | tr -d '[:space:]') + byte_order=$(od -An -tx1 -j5 -N1 "$provider" | tr -d '[:space:]') + if [ "$class" != "02" ] || [ "$byte_order" != "01" ]; then + echo "$package_id provider is not a 64-bit little-endian ELF executable" >&2 + exit 1 + fi + + machine=$(od -An -tx1 -j18 -N2 "$provider" | tr -d '[:space:]') + if [ "$machine" != "$expected_machine" ]; then + echo "$package_id provider architecture does not match linux/$expected_goarch (ELF machine=$machine)" >&2 + exit 1 + fi + + unzip -p "$package_file" backend/provider >"$archived_provider" + if ! cmp -s "$provider" "$archived_provider"; then + echo "$package_id directory provider and packaged provider differ" >&2 + exit 1 + fi + + if [ "${PAYMENT_PLUGIN_SMOKE_TEST:-0}" = "1" ]; then + response=$(printf 'invalid-json\n' | "$provider") + printf '%s\n' "$response" | grep -F '"code":"invalid_request"' >/dev/null + fi +done