From 2853f160af3fcc69e9ade55f22168b4a653fee0f Mon Sep 17 00:00:00 2001 From: joeykchen <466719968@qq.com> Date: Fri, 21 Aug 2026 18:08:38 +0800 Subject: [PATCH] feat(xgodriver): support verified source and published drivers --- .github/actions/driver-bundle/action.yml | 58 +++ .github/actions/standalone/prepare/action.yml | 37 +- .github/scripts/driverbundle/files.go | 225 +++++++++ .github/scripts/driverbundle/main.go | 58 +++ .github/scripts/driverbundle/main_test.go | 171 +++++++ .github/scripts/driverbundle/package.go | 222 ++++++++ .github/scripts/driverbundle/release.go | 227 +++++++++ .github/scripts/driverbundle/verify.go | 125 +++++ .../scripts/driverbundle/verify_release.go | 159 ++++++ .../driverbundle/verify_release_test.go | 156 ++++++ .github/scripts/driverbundle/workflow_test.py | 124 +++++ .github/scripts/release/workflow_test.py | 126 ++++- .github/scripts/release_pin.py | 472 ++++++++++++++++++ .github/scripts/release_pin_test.py | 371 ++++++++++++++ .github/scripts/runtime/manifest.go | 32 +- .github/scripts/runtime/manifest_test.go | 15 + .github/scripts/runtime/version.go | 3 + .github/scripts/runtime/version_test.go | 4 + .github/workflows/release.yml | 412 ++++++++++++++- .github/workflows/release_driver.yml | 330 ++++++++++++ .github/workflows/release_driver_platform.yml | 87 ++++ .github/workflows/static_checks.yml | 3 + Makefile | 7 +- cmd/spx/internal/command/builderai/gox.mod | 3 +- cmd/spx/internal/command/buildlauncher.go | 10 +- .../internal/command/buildlauncher_graph.go | 17 +- .../command/buildlauncher_graph_test.go | 20 +- cmd/xgodriver/main.go | 56 +++ cmd/xgodriver/main_test.go | 40 ++ docs/en/dev/engine/release.md | 16 +- .../xgo-project-driver-proposal-issue.md | 324 ++++++++++++ docs/zh/dev/engine/release.md | 18 +- .../xgo-project-driver-proposal-issue.md | 324 ++++++++++++ gox.mod | 3 +- internal/cmd/buildctl/engine/api.go | 15 +- internal/cmd/buildctl/engine/cmd.go | 13 +- internal/cmd/buildctl/engine/download.go | 1 + .../buildctl/engine/download_linux_pack.go | 1 + .../cmd/buildctl/engine/download_local.go | 17 +- internal/cmd/buildctl/engine/download_test.go | 29 ++ internal/cmd/buildctl/prepare.go | 9 +- internal/cmd/buildctl/prepare_test.go | 18 + internal/driverbundle/dependency_test.go | 55 ++ internal/driverbundle/digest.go | 61 +++ internal/driverbundle/identity.go | 83 +++ internal/driverbundle/manifest.go | 173 +++++++ internal/driverbundle/manifest_test.go | 213 ++++++++ internal/driverbundle/pins.go | 169 +++++++ internal/driverbundle/pins/README | 5 + internal/driverbundle/pins_test.go | 125 +++++ internal/driverbundle/validation.go | 207 ++++++++ internal/launchpack/assets_verify.go | 123 +++++ internal/launchpack/assets_verify_test.go | 91 ++++ internal/launchpack/bridge_build.go | 88 ++++ internal/launchpack/driver_published.go | 234 +++++++++ .../driver_published_acquire_test.go | 211 ++++++++ .../driver_published_boundaries_test.go | 238 +++++++++ .../launchpack/driver_published_bundle.go | 107 ++++ .../driver_published_fixture_test.go | 196 ++++++++ .../driver_published_payload_test.go | 194 +++++++ .../launchpack/driver_published_support.go | 132 +++++ internal/launchpack/payload.go | 105 +--- internal/launchpack/payload_files.go | 20 +- internal/launchpack/payload_manifest.go | 100 ++++ internal/launchpack/runtime_assets.go | 144 +----- internal/launchpack/runtime_environment.go | 96 ++++ internal/launchpack/runtime_fetch.go | 47 +- internal/launchpack/runtime_fetch_test.go | 81 +++ internal/launchpack/runtime_local_manifest.go | 89 ++++ internal/launchpack/runtime_source_test.go | 52 +- internal/launchpack/service.go | 68 ++- internal/launchpack/types.go | 22 +- internal/launchpack/validation.go | 34 +- internal/release/runtime_manifest_pin.go | 55 ++ internal/release/runtime_manifest_pin_test.go | 52 +- internal/runtimebundle/acquire.go | 5 +- internal/runtimebundle/acquire_test.go | 6 + internal/runtimebundle/cache_materialize.go | 23 + internal/runtimebundle/cache_test.go | 99 ++++ internal/runtimebundle/manifest.go | 38 +- internal/runtimebundle/verify.go | 3 + internal/xgodriver/argv.go | 222 ++++++++ internal/xgodriver/argv_test.go | 427 ++++++++++++++++ internal/xgodriver/build.go | 47 ++ internal/xgodriver/build_provenance.go | 138 +++++ internal/xgodriver/config_adapter.go | 65 +++ internal/xgodriver/config_adapter_test.go | 106 ++++ internal/xgodriver/driver.go | 125 +++++ internal/xgodriver/driver_test.go | 213 ++++++++ internal/xgodriver/graph.go | 237 +++++++++ internal/xgodriver/graph_test.go | 102 ++++ internal/xgodriver/metadata.go | 78 +++ internal/xgodriver/metadata_test.go | 94 ++++ internal/xgodriver/provenance.go | 112 +++++ internal/xgodriver/run.go | 157 ++++++ 95 files changed, 9936 insertions(+), 389 deletions(-) create mode 100644 .github/actions/driver-bundle/action.yml create mode 100644 .github/scripts/driverbundle/files.go create mode 100644 .github/scripts/driverbundle/main.go create mode 100644 .github/scripts/driverbundle/main_test.go create mode 100644 .github/scripts/driverbundle/package.go create mode 100644 .github/scripts/driverbundle/release.go create mode 100644 .github/scripts/driverbundle/verify.go create mode 100644 .github/scripts/driverbundle/verify_release.go create mode 100644 .github/scripts/driverbundle/verify_release_test.go create mode 100644 .github/scripts/driverbundle/workflow_test.py create mode 100644 .github/scripts/release_pin.py create mode 100644 .github/scripts/release_pin_test.py create mode 100644 .github/workflows/release_driver.yml create mode 100644 .github/workflows/release_driver_platform.yml create mode 100644 cmd/xgodriver/main.go create mode 100644 cmd/xgodriver/main_test.go create mode 100644 docs/en/dev/engine/xgo-project-driver-proposal-issue.md create mode 100644 docs/zh/dev/engine/xgo-project-driver-proposal-issue.md create mode 100644 internal/driverbundle/dependency_test.go create mode 100644 internal/driverbundle/digest.go create mode 100644 internal/driverbundle/identity.go create mode 100644 internal/driverbundle/manifest.go create mode 100644 internal/driverbundle/manifest_test.go create mode 100644 internal/driverbundle/pins.go create mode 100644 internal/driverbundle/pins/README create mode 100644 internal/driverbundle/pins_test.go create mode 100644 internal/driverbundle/validation.go create mode 100644 internal/launchpack/assets_verify.go create mode 100644 internal/launchpack/assets_verify_test.go create mode 100644 internal/launchpack/bridge_build.go create mode 100644 internal/launchpack/driver_published.go create mode 100644 internal/launchpack/driver_published_acquire_test.go create mode 100644 internal/launchpack/driver_published_boundaries_test.go create mode 100644 internal/launchpack/driver_published_bundle.go create mode 100644 internal/launchpack/driver_published_fixture_test.go create mode 100644 internal/launchpack/driver_published_payload_test.go create mode 100644 internal/launchpack/driver_published_support.go create mode 100644 internal/launchpack/payload_manifest.go create mode 100644 internal/launchpack/runtime_environment.go create mode 100644 internal/launchpack/runtime_fetch_test.go create mode 100644 internal/launchpack/runtime_local_manifest.go create mode 100644 internal/xgodriver/argv.go create mode 100644 internal/xgodriver/argv_test.go create mode 100644 internal/xgodriver/build.go create mode 100644 internal/xgodriver/build_provenance.go create mode 100644 internal/xgodriver/config_adapter.go create mode 100644 internal/xgodriver/config_adapter_test.go create mode 100644 internal/xgodriver/driver.go create mode 100644 internal/xgodriver/driver_test.go create mode 100644 internal/xgodriver/graph.go create mode 100644 internal/xgodriver/graph_test.go create mode 100644 internal/xgodriver/metadata.go create mode 100644 internal/xgodriver/metadata_test.go create mode 100644 internal/xgodriver/provenance.go create mode 100644 internal/xgodriver/run.go diff --git a/.github/actions/driver-bundle/action.yml b/.github/actions/driver-bundle/action.yml new file mode 100644 index 000000000..37df0f05b --- /dev/null +++ b/.github/actions/driver-bundle/action.yml @@ -0,0 +1,58 @@ +name: Package and verify driver bundle +description: Build a deterministic Engine/PCK/bridge driver ZIP and verify it. +inputs: + engine: + description: Path to the canonical Engine input + required: true + pack: + description: Path to the canonical runtime PCK input + required: true + bridge: + description: Path to the canonical interpreter bridge input + required: true + output: + description: Output driver ZIP path + required: true + descriptor: + description: Output driver descriptor JSON path + required: true + goos: + description: Target GOOS + required: true + goarch: + description: Target GOARCH + required: true +runs: + using: composite + steps: + - name: Package driver bundle + shell: bash + env: + ENGINE_PATH: ${{ inputs.engine }} + PACK_PATH: ${{ inputs.pack }} + BRIDGE_PATH: ${{ inputs.bridge }} + OUTPUT_PATH: ${{ inputs.output }} + DESCRIPTOR_PATH: ${{ inputs.descriptor }} + TARGET_GOOS: ${{ inputs.goos }} + TARGET_GOARCH: ${{ inputs.goarch }} + run: | + set -euo pipefail + go run ./.github/scripts/driverbundle package \ + --engine "$ENGINE_PATH" \ + --pack "$PACK_PATH" \ + --bridge "$BRIDGE_PATH" \ + --output "$OUTPUT_PATH" \ + --descriptor "$DESCRIPTOR_PATH" \ + --goos "$TARGET_GOOS" \ + --goarch "$TARGET_GOARCH" + + - name: Verify driver bundle + shell: bash + env: + OUTPUT_PATH: ${{ inputs.output }} + DESCRIPTOR_PATH: ${{ inputs.descriptor }} + run: | + set -euo pipefail + go run ./.github/scripts/driverbundle verify \ + --output "$OUTPUT_PATH" \ + --descriptor "$DESCRIPTOR_PATH" diff --git a/.github/actions/standalone/prepare/action.yml b/.github/actions/standalone/prepare/action.yml index 131d9dc11..037215b5b 100644 --- a/.github/actions/standalone/prepare/action.yml +++ b/.github/actions/standalone/prepare/action.yml @@ -1,5 +1,5 @@ name: Prepare standalone release -description: Prepare runtime assets, reinstall spx, and expose the rebuilt binary path. +description: Prepare runtime assets and expose rebuilt SPX component paths. inputs: engine-asset-dir: description: 'Optional directory containing engine artifacts from the current workflow run' @@ -9,6 +9,15 @@ outputs: spx-bin: description: 'Path to the rebuilt spx binary' value: ${{ steps.prepare-standalone-release.outputs.spx-bin }} + engine-path: + description: 'Path to the verified host Engine' + value: ${{ steps.prepare-standalone-release.outputs.engine-path }} + pack-path: + description: 'Path to the verified runtime PCK' + value: ${{ steps.prepare-standalone-release.outputs.pack-path }} + bridge-path: + description: 'Path to the rebuilt interpreter bridge' + value: ${{ steps.prepare-standalone-release.outputs.bridge-path }} runs: using: "composite" steps: @@ -37,10 +46,28 @@ runs: "$BUILDCTL" setup "${setup_args[@]}" GOEXE="$(go_env_goexe)" + GOOS="$(go env GOOS | tr -d '\r')" + GOARCH="$(go env GOARCH | tr -d '\r')" + RUNTIME_VERSION="$(go run ./.github/scripts/runtime/version.go | tr -d '\r\n')" + GO_BIN_DIR="$(go_env_bin_dir "$GOEXE")" SPX_BIN_PATH="$(go_env_spx_path "$GOEXE")" - if [ ! -f "$SPX_BIN_PATH" ]; then - log_error "Rebuilt SPX binary not found: $SPX_BIN_PATH" - exit 1 - fi + ENGINE_PATH="$GO_BIN_DIR/gdspxrt${RUNTIME_VERSION}${GOEXE}" + PACK_PATH="$GO_BIN_DIR/gdspxrt${RUNTIME_VERSION}.pck" + case "$GOOS" in + darwin) BRIDGE_EXT=dylib ;; + linux) BRIDGE_EXT=so ;; + windows) BRIDGE_EXT=dll ;; + *) log_error "Unsupported driver platform: $GOOS/$GOARCH"; exit 1 ;; + esac + BRIDGE_PATH="$GO_BIN_DIR/gdspx-${GOOS}-${GOARCH}.${BRIDGE_EXT}" + for asset in "$SPX_BIN_PATH" "$ENGINE_PATH" "$PACK_PATH" "$BRIDGE_PATH"; do + if [ ! -f "$asset" ]; then + log_error "Prepared release asset not found: $asset" + exit 1 + fi + done echo "spx-bin=$SPX_BIN_PATH" >> "$GITHUB_OUTPUT" + echo "engine-path=$ENGINE_PATH" >> "$GITHUB_OUTPUT" + echo "pack-path=$PACK_PATH" >> "$GITHUB_OUTPUT" + echo "bridge-path=$BRIDGE_PATH" >> "$GITHUB_OUTPUT" log_success "Rebuilt spx binary: $SPX_BIN_PATH" diff --git a/.github/scripts/driverbundle/files.go b/.github/scripts/driverbundle/files.go new file mode 100644 index 000000000..010334c38 --- /dev/null +++ b/.github/scripts/driverbundle/files.go @@ -0,0 +1,225 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package main + +import ( + "errors" + "fmt" + "hash" + "io" + "os" + "path/filepath" + "runtime" + + "github.com/goplus/spx/v3/internal/driverbundle" +) + +func openRegularFile(path string) (*os.File, os.FileInfo, error) { + info, err := os.Lstat(path) + if err != nil { + return nil, nil, err + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return nil, nil, ¬RegularError{path: path} + } + file, err := os.Open(path) + if err != nil { + return nil, nil, err + } + opened, err := file.Stat() + if err != nil { + _ = file.Close() + return nil, nil, err + } + if !os.SameFile(info, opened) { + _ = file.Close() + return nil, nil, &changedFileError{path: path} + } + return file, info, nil +} + +func readRegularFile(path string) ([]byte, error) { + file, _, err := openRegularFile(path) + if err != nil { + return nil, err + } + defer file.Close() + data, err := io.ReadAll(io.LimitReader(file, driverbundle.MaxManifestSize+1)) + if err != nil { + return nil, err + } + if int64(len(data)) > driverbundle.MaxManifestSize { + return nil, fmt.Errorf("descriptor exceeds %d-byte limit", driverbundle.MaxManifestSize) + } + return data, nil +} + +func rejectOutputAliases(outputPath, descriptorPath string, inputs ...string) error { + outputs := []string{outputPath, descriptorPath} + absoluteOutputs := make([]string, len(outputs)) + for i, path := range outputs { + value, err := filepath.Abs(path) + if err != nil { + return err + } + absoluteOutputs[i] = filepath.Clean(value) + } + if absoluteOutputs[0] == absoluteOutputs[1] { + return &aliasError{first: outputPath, second: descriptorPath} + } + for outputIndex, output := range outputs { + for _, input := range inputs { + inputAbs, err := filepath.Abs(input) + if err != nil { + return err + } + if absoluteOutputs[outputIndex] == filepath.Clean(inputAbs) { + return &aliasError{first: output, second: input} + } + outputInfo, outputErr := os.Stat(output) + inputInfo, inputErr := os.Stat(input) + if outputErr == nil && inputErr == nil && os.SameFile(outputInfo, inputInfo) { + return &aliasError{first: output, second: input} + } + } + } + return nil +} + +func ensureParent(path string) error { + return os.MkdirAll(filepath.Dir(path), 0o755) +} + +func atomicWrite(path string, data []byte, mode os.FileMode) error { + temporary, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".tmp-") + if err != nil { + return err + } + temporaryPath := temporary.Name() + committed := false + defer func() { + _ = temporary.Close() + if !committed { + _ = os.Remove(temporaryPath) + } + }() + if _, err := temporary.Write(data); err != nil { + return err + } + if err := temporary.Chmod(mode); err != nil { + return err + } + if err := temporary.Sync(); err != nil { + return err + } + if err := temporary.Close(); err != nil { + return err + } + if err := os.Rename(temporaryPath, path); err != nil { + return err + } + committed = true + if err := syncDirectory(filepath.Dir(path)); err != nil && runtime.GOOS != "windows" { + return err + } + return nil +} + +func copyRegularFile(source, destination string) error { + if filepath.Clean(source) == filepath.Clean(destination) { + return errors.New("source and destination are the same path") + } + input, info, err := openRegularFile(source) + if err != nil { + return err + } + defer input.Close() + if err := ensureParent(destination); err != nil { + return err + } + temporary, err := os.CreateTemp(filepath.Dir(destination), "."+filepath.Base(destination)+".tmp-") + if err != nil { + return err + } + temporaryPath := temporary.Name() + committed := false + defer func() { + _ = temporary.Close() + if !committed { + _ = os.Remove(temporaryPath) + } + }() + count, err := io.Copy(temporary, input) + if err != nil { + return err + } + if count != info.Size() { + return fmt.Errorf("copied size %d, want %d", count, info.Size()) + } + if err := temporary.Chmod(0o644); err != nil { + return err + } + if err := temporary.Sync(); err != nil { + return err + } + if err := temporary.Close(); err != nil { + return err + } + if err := os.Rename(temporaryPath, destination); err != nil { + return err + } + committed = true + return nil +} + +type digestWriter struct { + writer io.Writer + digest hash.Hash + size int64 +} + +func (w *digestWriter) Write(data []byte) (int, error) { + count, err := w.writer.Write(data) + if count > 0 { + if _, digestErr := w.digest.Write(data[:count]); err == nil { + err = digestErr + } + w.size += int64(count) + } + return count, err +} + +func syncDirectory(path string) error { + directory, err := os.Open(path) + if err != nil { + return err + } + defer directory.Close() + return directory.Sync() +} + +type notRegularError struct{ path string } + +func (e *notRegularError) Error() string { return e.path + " is not a regular non-symlink file" } + +type changedFileError struct{ path string } + +func (e *changedFileError) Error() string { return e.path + " changed while opening" } + +type aliasError struct{ first, second string } + +func (e *aliasError) Error() string { return "paths alias: " + e.first + " and " + e.second } diff --git a/.github/scripts/driverbundle/main.go b/.github/scripts/driverbundle/main.go new file mode 100644 index 000000000..f26202418 --- /dev/null +++ b/.github/scripts/driverbundle/main.go @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Command driverbundle packages and verifies the host driver release bundle. +package main + +import ( + "fmt" + "io" + "os" +) + +func main() { + if len(os.Args) < 2 { + usage(os.Stderr) + os.Exit(2) + } + + var err error + switch os.Args[1] { + case "package": + err = runPackage(os.Args[2:]) + case "verify": + err = runVerify(os.Args[2:]) + case "assemble": + err = runAssemble(os.Args[2:]) + case "verify-release": + err = runVerifyRelease(os.Args[2:]) + case "check-prerequisites": + err = runCheckPrerequisites(os.Args[2:]) + case "-h", "--help", "help": + usage(os.Stdout) + return + default: + err = fmt.Errorf("unknown command %q (want package, verify, assemble, verify-release, or check-prerequisites)", os.Args[1]) + } + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func usage(output io.Writer) { + fmt.Fprintln(output, "usage: driverbundle package|verify|assemble|verify-release|check-prerequisites [flags]") +} diff --git a/.github/scripts/driverbundle/main_test.go b/.github/scripts/driverbundle/main_test.go new file mode 100644 index 000000000..d1fa2590c --- /dev/null +++ b/.github/scripts/driverbundle/main_test.go @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package main + +import ( + "archive/zip" + "bytes" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/goplus/spx/v3/internal/driverbundle" + "github.com/goplus/spx/v3/internal/release" +) + +func TestPackageAndVerifyIsDeterministic(t *testing.T) { + directory := t.TempDir() + runtimeVersion := release.DefaultRuntimeLock().RuntimeVersion + enginePath := filepath.Join(directory, "gdspxrt"+runtimeVersion) + packPath := filepath.Join(directory, "gdspxrt"+runtimeVersion+".pck") + bridgePath := filepath.Join(directory, "gdspx-linux-amd64.so") + for path, data := range map[string][]byte{ + enginePath: []byte("engine bytes"), + packPath: []byte("pack bytes"), + bridgePath: []byte("bridge bytes"), + } { + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + } + outputPath := filepath.Join(directory, "spx-driver-linux-amd64.zip") + descriptorPath := filepath.Join(directory, "bundle.json") + args := []string{ + "--engine", enginePath, "--pack", packPath, "--bridge", bridgePath, + "--output", outputPath, "--descriptor", descriptorPath, + "--goos", "linux", "--goarch", "amd64", + } + if err := runPackage(args); err != nil { + t.Fatalf("package: %v", err) + } + firstZIP, err := os.ReadFile(outputPath) + if err != nil { + t.Fatal(err) + } + firstDescriptor, err := os.ReadFile(descriptorPath) + if err != nil { + t.Fatal(err) + } + if err := runVerify([]string{"--output", outputPath, "--descriptor", descriptorPath}); err != nil { + t.Fatalf("verify: %v", err) + } + + secondOutput := filepath.Join(directory, "second", "spx-driver-linux-amd64.zip") + secondDescriptor := filepath.Join(directory, "second", "bundle.json") + secondArgs := append([]string{}, args...) + for i := range secondArgs { + if secondArgs[i] == outputPath { + secondArgs[i] = secondOutput + } + if secondArgs[i] == descriptorPath { + secondArgs[i] = secondDescriptor + } + } + if err := runPackage(secondArgs); err != nil { + t.Fatalf("second package: %v", err) + } + secondZIP, err := os.ReadFile(secondOutput) + if err != nil { + t.Fatal(err) + } + secondDescriptorData, err := os.ReadFile(secondDescriptor) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(firstZIP, secondZIP) || !bytes.Equal(firstDescriptor, secondDescriptorData) { + t.Fatal("package output is not deterministic") + } + + bundle, err := driverbundle.ParseBundle(firstDescriptor) + if err != nil { + t.Fatal(err) + } + if len(bundle.Files) != 3 || bundle.Files[0].Mode != 0o755 || bundle.Files[1].Mode != 0o644 || bundle.Files[2].Mode != 0o755 { + t.Fatalf("descriptor files = %#v", bundle.Files) + } + archive, err := zip.OpenReader(outputPath) + if err != nil { + t.Fatal(err) + } + defer archive.Close() + for i, entry := range archive.File { + if entry.Name != bundle.Files[i].Name || entry.Mode().Perm() != os.FileMode(bundle.Files[i].Mode) { + t.Fatalf("ZIP entry %d = %q mode %#o", i, entry.Name, entry.Mode().Perm()) + } + if !entry.Modified.Equal(time.Date(1980, time.January, 1, 0, 0, 0, 0, time.UTC)) { + t.Fatalf("ZIP entry %q modified = %s", entry.Name, entry.Modified) + } + } +} + +func TestPackageRejectsNonCanonicalBasename(t *testing.T) { + directory := t.TempDir() + for _, name := range []string{"engine", "pack", "bridge"} { + if err := os.WriteFile(filepath.Join(directory, name), []byte(name), 0o600); err != nil { + t.Fatal(err) + } + } + err := runPackage([]string{ + "--engine", filepath.Join(directory, "engine"), + "--pack", filepath.Join(directory, "pack"), + "--bridge", filepath.Join(directory, "bridge"), + "--output", filepath.Join(directory, "spx-driver-linux-amd64.zip"), + "--descriptor", filepath.Join(directory, "bundle.json"), + "--goos", "linux", "--goarch", "amd64", + }) + if err == nil { + t.Fatal("accepted non-canonical input basenames") + } +} + +func TestCheckPrerequisitesRejectsUnpinnedRuntime(t *testing.T) { + lock := release.DefaultRuntimeLock() + lock.RuntimeVersion = "9.9.9" + lockPath := filepath.Join(t.TempDir(), "runtime.lock.json") + data, err := lock.JSON() + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(lockPath, data, 0o600); err != nil { + t.Fatal(err) + } + err = runCheckPrerequisites([]string{"--lock", lockPath}) + if err == nil || !strings.Contains(err.Error(), "runtime release prerequisite") { + t.Fatalf("check-prerequisites error = %v", err) + } +} + +func TestCheckPrerequisitesRejectsUnpinnedDriver(t *testing.T) { + lock, err := release.RuntimeLockForVersion("2.4.3") + if err != nil { + t.Fatal(err) + } + lockPath := filepath.Join(t.TempDir(), "runtime.lock.json") + data, err := lock.JSON() + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(lockPath, data, 0o600); err != nil { + t.Fatal(err) + } + err = runCheckPrerequisites([]string{"--lock", lockPath, "--driver-version", "v9.9.9"}) + if err == nil || !strings.Contains(err.Error(), "driver release prerequisite") { + t.Fatalf("check-prerequisites error = %v", err) + } +} diff --git a/.github/scripts/driverbundle/package.go b/.github/scripts/driverbundle/package.go new file mode 100644 index 000000000..d65dbec6a --- /dev/null +++ b/.github/scripts/driverbundle/package.go @@ -0,0 +1,222 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package main + +import ( + "archive/zip" + "crypto/sha256" + "encoding/hex" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/goplus/spx/v3/internal/driverbundle" + "github.com/goplus/spx/v3/internal/release" +) + +const ( + zipModeEngine = 0o755 + zipModePack = 0o644 + zipModeBridge = 0o755 +) + +var zipEpoch = time.Date(1980, time.January, 1, 0, 0, 0, 0, time.UTC) + +func runPackage(args []string) error { + flags := flag.NewFlagSet("driverbundle package", flag.ContinueOnError) + flags.SetOutput(os.Stderr) + var enginePath, packPath, bridgePath, outputPath, descriptorPath, goos, goarch string + flags.StringVar(&enginePath, "engine", "", "Engine input path") + flags.StringVar(&packPath, "pack", "", "runtime PCK input path") + flags.StringVar(&bridgePath, "bridge", "", "interpreter bridge input path") + flags.StringVar(&outputPath, "output", "", "output ZIP path") + flags.StringVar(&descriptorPath, "descriptor", "", "output descriptor JSON path") + flags.StringVar(&goos, "goos", "", "target GOOS") + flags.StringVar(&goarch, "goarch", "", "target GOARCH") + flags.Usage = func() { + fmt.Fprintln(flags.Output(), "usage: driverbundle package --engine PATH --pack PATH --bridge PATH --output ZIP --descriptor JSON --goos GOOS --goarch GOARCH") + flags.PrintDefaults() + } + if err := flags.Parse(args); err != nil { + return err + } + if flags.NArg() != 0 { + return fmt.Errorf("unexpected positional arguments: %s", strings.Join(flags.Args(), " ")) + } + paths := []string{enginePath, packPath, bridgePath, outputPath, descriptorPath} + for _, path := range paths { + if strings.TrimSpace(path) == "" { + return errors.New("engine, pack, bridge, output, and descriptor paths are required") + } + } + lock := release.DefaultRuntimeLock() + if err := validateTarget(goos, goarch); err != nil { + return err + } + if err := validateInputBasenames(lock.RuntimeVersion, goos, goarch, enginePath, packPath, bridgePath, outputPath); err != nil { + return err + } + if err := rejectOutputAliases(outputPath, descriptorPath, enginePath, packPath, bridgePath); err != nil { + return err + } + if err := ensureParent(outputPath); err != nil { + return fmt.Errorf("prepare ZIP output: %w", err) + } + if err := ensureParent(descriptorPath); err != nil { + return fmt.Errorf("prepare descriptor output: %w", err) + } + + temporary, err := os.CreateTemp(filepath.Dir(outputPath), "."+filepath.Base(outputPath)+".tmp-") + if err != nil { + return fmt.Errorf("create temporary ZIP: %w", err) + } + temporaryPath := temporary.Name() + committed := false + defer func() { + _ = temporary.Close() + if !committed { + _ = os.Remove(temporaryPath) + } + }() + + outerDigest := sha256.New() + outer := &digestWriter{writer: temporary, digest: outerDigest} + archive := zip.NewWriter(outer) + files := make([]driverbundle.File, 0, 3) + + engine, err := addZipFile(archive, enginePath, filepath.Base(enginePath), zipModeEngine) + if err != nil { + return fmt.Errorf("package Engine: %w", err) + } + files = append(files, engine) + pack, err := addZipFile(archive, packPath, filepath.Base(packPath), zipModePack) + if err != nil { + return fmt.Errorf("package PCK: %w", err) + } + files = append(files, pack) + bridge, err := addZipFile(archive, bridgePath, filepath.Base(bridgePath), zipModeBridge) + if err != nil { + return fmt.Errorf("package bridge: %w", err) + } + files = append(files, bridge) + if err := archive.Close(); err != nil { + return fmt.Errorf("close ZIP: %w", err) + } + if err := temporary.Chmod(0o644); err != nil { + return fmt.Errorf("set ZIP mode: %w", err) + } + if err := temporary.Sync(); err != nil { + return fmt.Errorf("sync ZIP: %w", err) + } + if err := temporary.Close(); err != nil { + return fmt.Errorf("close temporary ZIP: %w", err) + } + + interfaceDigest, err := driverbundle.ComputeEngineInterfaceDigestFromSHA256(engine.SHA256, pack.SHA256) + if err != nil { + return fmt.Errorf("identify Engine interface: %w", err) + } + bundle := driverbundle.Bundle{ + GOOS: goos, GOARCH: goarch, Name: filepath.Base(outputPath), + Size: outer.size, SHA256: hex.EncodeToString(outerDigest.Sum(nil)), + EngineInterfaceDigest: interfaceDigest, Files: files, + } + if err := bundle.ValidateForRuntime(lock.RuntimeVersion); err != nil { + return fmt.Errorf("validate generated descriptor: %w", err) + } + descriptor, err := bundle.JSON() + if err != nil { + return fmt.Errorf("encode descriptor: %w", err) + } + if err := os.Rename(temporaryPath, outputPath); err != nil { + return fmt.Errorf("publish ZIP %s: %w", outputPath, err) + } + committed = true + if err := atomicWrite(descriptorPath, descriptor, 0o644); err != nil { + return fmt.Errorf("publish descriptor %s: %w", descriptorPath, err) + } + return nil +} + +func validateTarget(goos, goarch string) error { + switch { + case goos == "darwin" && (goarch == "amd64" || goarch == "arm64"), + goos == "linux" && goarch == "amd64", + goos == "windows" && goarch == "amd64": + return nil + default: + return fmt.Errorf("unsupported driver target %s/%s", goos, goarch) + } +} + +func validateInputBasenames(runtimeVersion, goos, goarch, enginePath, packPath, bridgePath, outputPath string) error { + validDigest := strings.Repeat("0", sha256.Size*2) + interfaceDigest, err := driverbundle.ComputeEngineInterfaceDigestFromSHA256(validDigest, validDigest) + if err != nil { + return err + } + bundle := driverbundle.Bundle{ + GOOS: goos, GOARCH: goarch, Name: filepath.Base(outputPath), Size: 1, + SHA256: validDigest, EngineInterfaceDigest: interfaceDigest, + Files: []driverbundle.File{ + {Name: filepath.Base(enginePath), Mode: zipModeEngine, Size: 1, SHA256: validDigest}, + {Name: filepath.Base(packPath), Mode: zipModePack, Size: 1, SHA256: validDigest}, + {Name: filepath.Base(bridgePath), Mode: zipModeBridge, Size: 1, SHA256: validDigest}, + }, + } + if err := bundle.ValidateForRuntime(runtimeVersion); err != nil { + return fmt.Errorf("validate input basenames: %w", err) + } + return nil +} + +func addZipFile(archive *zip.Writer, sourcePath, name string, mode uint32) (driverbundle.File, error) { + input, info, err := openRegularFile(sourcePath) + if err != nil { + return driverbundle.File{}, err + } + header := &zip.FileHeader{Name: name, Method: zip.Store} + header.SetMode(os.FileMode(mode)) + header.SetModTime(zipEpoch) + entry, err := archive.CreateHeader(header) + if err != nil { + _ = input.Close() + return driverbundle.File{}, err + } + fileDigest := sha256.New() + count, copyErr := io.Copy(io.MultiWriter(entry, fileDigest), input) + finalInfo, statErr := input.Stat() + closeErr := input.Close() + if copyErr != nil { + return driverbundle.File{}, copyErr + } + if statErr != nil { + return driverbundle.File{}, statErr + } + if closeErr != nil { + return driverbundle.File{}, closeErr + } + if count != info.Size() || finalInfo.Size() != info.Size() { + return driverbundle.File{}, fmt.Errorf("source changed while reading (size %d, want %d)", count, info.Size()) + } + return driverbundle.File{Name: name, Mode: mode, Size: count, SHA256: hex.EncodeToString(fileDigest.Sum(nil))}, nil +} diff --git a/.github/scripts/driverbundle/release.go b/.github/scripts/driverbundle/release.go new file mode 100644 index 000000000..cb2b463ee --- /dev/null +++ b/.github/scripts/driverbundle/release.go @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package main + +import ( + "errors" + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + + "github.com/goplus/spx/v3/internal/driverbundle" + "github.com/goplus/spx/v3/internal/release" +) + +const expectedDriverBundleCount = 4 + +type descriptorInputs []string + +func (v *descriptorInputs) String() string { return strings.Join(*v, ",") } + +func (v *descriptorInputs) Set(value string) error { + if strings.TrimSpace(value) == "" { + return errors.New("descriptor path must not be empty") + } + *v = append(*v, value) + return nil +} + +// runCheckPrerequisites is intentionally small and side-effect free. A +// driver build may create the first driver pin, but it must never create or +// infer the runtime trust root that the bundle records. +func runCheckPrerequisites(args []string) error { + flags := flag.NewFlagSet("driverbundle check-prerequisites", flag.ContinueOnError) + flags.SetOutput(os.Stderr) + lockPath := "internal/release/runtime.lock.json" + driverVersion := "" + flags.StringVar(&lockPath, "lock", lockPath, "runtime lock JSON") + flags.StringVar(&driverVersion, "driver-version", "", "optional embedded driver pin version") + if err := flags.Parse(args); err != nil { + return err + } + if flags.NArg() != 0 { + return fmt.Errorf("unexpected positional arguments: %s", strings.Join(flags.Args(), " ")) + } + lock, err := loadDriverLock(lockPath) + if err != nil { + return err + } + if _, err := release.RuntimeManifestPinForLock(lock); err != nil { + return fmt.Errorf("runtime release prerequisite: %w", err) + } + if driverVersion != "" { + pin, err := driverbundle.ForVersion(driverVersion) + if err != nil { + return fmt.Errorf("driver release prerequisite: %w", err) + } + if pin.RuntimeVersion != lock.RuntimeVersion { + return fmt.Errorf("driver release prerequisite: runtime version = %q, want %q", pin.RuntimeVersion, lock.RuntimeVersion) + } + } + return nil +} + +func runAssemble(args []string) error { + flags := flag.NewFlagSet("driverbundle assemble", flag.ContinueOnError) + flags.SetOutput(os.Stderr) + lockPath := "internal/release/runtime.lock.json" + spxVersion := release.DefaultReleaseMeta().SPXVersion + producerCommit := strings.TrimSpace(os.Getenv("GITHUB_SHA")) + manifestPath, pinPath := "", "" + var descriptors descriptorInputs + flags.StringVar(&lockPath, "lock", lockPath, "runtime lock JSON") + flags.StringVar(&spxVersion, "spx-version", spxVersion, "SPX release version") + flags.StringVar(&producerCommit, "producer-commit", producerCommit, "producer commit SHA") + flags.StringVar(&manifestPath, "manifest", "", "output driver-manifest.json path") + flags.StringVar(&pinPath, "pin", "", "output embedded driver pin path") + flags.Var(&descriptors, "descriptor", "bundle descriptor JSON path; repeat exactly four times") + flags.Usage = func() { + fmt.Fprintln(flags.Output(), "usage: driverbundle assemble --manifest PATH --pin PATH --descriptor JSON ...") + flags.PrintDefaults() + } + if err := flags.Parse(args); err != nil { + return err + } + if flags.NArg() != 0 { + return fmt.Errorf("unexpected positional arguments: %s", strings.Join(flags.Args(), " ")) + } + if strings.TrimSpace(manifestPath) == "" || strings.TrimSpace(pinPath) == "" { + return errors.New("manifest and pin paths are required") + } + if len(descriptors) != expectedDriverBundleCount { + return fmt.Errorf("descriptor count = %d, want %d", len(descriptors), expectedDriverBundleCount) + } + if strings.TrimSpace(producerCommit) == "" { + producerCommit = gitHEAD() + } + lock, err := loadDriverLock(lockPath) + if err != nil { + return err + } + runtimePin, err := release.RuntimeManifestPinForLock(lock) + if err != nil { + return fmt.Errorf("runtime release prerequisite: %w", err) + } + lockSHA, err := lock.SHA256() + if err != nil { + return err + } + + bundles := make([]driverbundle.Bundle, 0, len(descriptors)) + seen := make(map[string]struct{}, len(descriptors)) + sources := make(map[string]string, len(descriptors)) + for _, descriptorPath := range descriptors { + data, err := readRegularFile(descriptorPath) + if err != nil { + return fmt.Errorf("read descriptor %s: %w", descriptorPath, err) + } + bundle, err := driverbundle.ParseBundle(data) + if err != nil { + return fmt.Errorf("parse descriptor %s: %w", descriptorPath, err) + } + if _, ok := seen[bundle.GOOS+"/"+bundle.GOARCH]; ok { + return fmt.Errorf("duplicate driver target %s/%s", bundle.GOOS, bundle.GOARCH) + } + seen[bundle.GOOS+"/"+bundle.GOARCH] = struct{}{} + zipPath := filepath.Join(filepath.Dir(descriptorPath), bundle.Name) + if err := verifyBundleFileForLock(zipPath, bundle, lock); err != nil { + return fmt.Errorf("verify %s/%s: %w", bundle.GOOS, bundle.GOARCH, err) + } + sources[bundle.GOOS+"/"+bundle.GOARCH] = zipPath + bundles = append(bundles, bundle) + } + slices.SortFunc(bundles, compareDriverBundles) + manifest := driverbundle.Manifest{ + Schema: driverbundle.ManifestSchema, + SPXModule: driverbundle.SPXModulePath, + SPXVersion: spxVersion, + RuntimeVersion: lock.RuntimeVersion, + RuntimeABI: lock.RuntimeABI, + ReleaseRepository: lock.ReleaseRepository, + RuntimeLockSHA256: lockSHA, + RuntimeManifestSHA256: runtimePin.SHA256, + GoVersion: lock.Toolchain.Go, + ProducerCommit: producerCommit, + Bundles: bundles, + } + manifestData, err := manifest.JSON() + if err != nil { + return err + } + pin, err := driverbundle.NewPin(manifest, manifestData) + if err != nil { + return fmt.Errorf("create generated pin: %w", err) + } + if err := manifest.ValidateFor(lock, pin, runtimePin); err != nil { + return fmt.Errorf("validate aggregate manifest: %w", err) + } + pinData, err := pin.JSON() + if err != nil { + return err + } + if err := ensureParent(manifestPath); err != nil { + return fmt.Errorf("prepare manifest output: %w", err) + } + if err := ensureParent(pinPath); err != nil { + return fmt.Errorf("prepare pin output: %w", err) + } + for _, bundle := range bundles { + key := bundle.GOOS + "/" + bundle.GOARCH + destination := filepath.Join(filepath.Dir(manifestPath), bundle.Name) + if err := copyRegularFile(sources[key], destination); err != nil { + return fmt.Errorf("copy %s/%s bundle: %w", bundle.GOOS, bundle.GOARCH, err) + } + if err := verifyBundleFileForLock(destination, bundle, lock); err != nil { + return fmt.Errorf("reverify copied %s/%s bundle: %w", bundle.GOOS, bundle.GOARCH, err) + } + } + if err := atomicWrite(manifestPath, manifestData, 0o644); err != nil { + return fmt.Errorf("write driver manifest: %w", err) + } + if err := atomicWrite(pinPath, pinData, 0o644); err != nil { + return fmt.Errorf("write driver pin: %w", err) + } + return nil +} + +func loadDriverLock(path string) (release.RuntimeLock, error) { + data, err := os.ReadFile(path) + if err != nil { + return release.RuntimeLock{}, fmt.Errorf("read runtime lock: %w", err) + } + lock, err := release.ParseRuntimeLock(data) + if err != nil { + return release.RuntimeLock{}, err + } + return lock, nil +} + +func compareDriverBundles(a, b driverbundle.Bundle) int { + return strings.Compare(a.GOOS+"/"+a.GOARCH, b.GOOS+"/"+b.GOARCH) +} + +func gitHEAD() string { + output, err := exec.Command("git", "rev-parse", "HEAD").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(output)) +} diff --git a/.github/scripts/driverbundle/verify.go b/.github/scripts/driverbundle/verify.go new file mode 100644 index 000000000..e6f1da855 --- /dev/null +++ b/.github/scripts/driverbundle/verify.go @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package main + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/goplus/spx/v3/internal/driverbundle" + "github.com/goplus/spx/v3/internal/release" + "github.com/goplus/spx/v3/internal/runtimebundle" +) + +func runVerify(args []string) error { + flags := flag.NewFlagSet("driverbundle verify", flag.ContinueOnError) + flags.SetOutput(os.Stderr) + var zipPath, descriptorPath string + flags.StringVar(&zipPath, "output", "", "ZIP path to verify") + flags.StringVar(&descriptorPath, "descriptor", "", "descriptor JSON path") + flags.Usage = func() { + fmt.Fprintln(flags.Output(), "usage: driverbundle verify --output ZIP --descriptor JSON") + flags.PrintDefaults() + } + if err := flags.Parse(args); err != nil { + return err + } + if flags.NArg() != 0 { + return fmt.Errorf("unexpected positional arguments: %s", strings.Join(flags.Args(), " ")) + } + if strings.TrimSpace(zipPath) == "" || strings.TrimSpace(descriptorPath) == "" { + return errors.New("output and descriptor paths are required") + } + + descriptorData, err := readRegularFile(descriptorPath) + if err != nil { + return fmt.Errorf("read descriptor: %w", err) + } + bundle, err := driverbundle.ParseBundle(descriptorData) + if err != nil { + return fmt.Errorf("parse descriptor: %w", err) + } + return verifyBundleFile(zipPath, bundle) +} + +// verifyBundleFile verifies a ZIP against a trusted bundle descriptor. The +// descriptor may come from the packaging job or from an aggregate release +// manifest; both paths use the same byte-level checks. +func verifyBundleFile(zipPath string, bundle driverbundle.Bundle) error { + lock := release.DefaultRuntimeLock() + return verifyBundleFileForLock(zipPath, bundle, lock) +} + +func verifyBundleFileForLock(zipPath string, bundle driverbundle.Bundle, lock release.RuntimeLock) error { + if err := validateTarget(bundle.GOOS, bundle.GOARCH); err != nil { + return fmt.Errorf("validate descriptor target: %w", err) + } + if err := bundle.ValidateForRuntime(lock.RuntimeVersion); err != nil { + return fmt.Errorf("validate descriptor: %w", err) + } + if filepath.Base(zipPath) != bundle.Name { + return fmt.Errorf("ZIP basename %q does not match descriptor name %q", filepath.Base(zipPath), bundle.Name) + } + + archiveFile, archiveInfo, err := openRegularFile(zipPath) + if err != nil { + return fmt.Errorf("open ZIP: %w", err) + } + defer archiveFile.Close() + if archiveInfo.Size() != bundle.Size { + return fmt.Errorf("ZIP size = %d, want descriptor size %d", archiveInfo.Size(), bundle.Size) + } + outerDigest := sha256.New() + if _, err := io.Copy(outerDigest, archiveFile); err != nil { + return fmt.Errorf("hash ZIP: %w", err) + } + if got := hex.EncodeToString(outerDigest.Sum(nil)); got != bundle.SHA256 { + return fmt.Errorf("ZIP SHA-256 = %s, want descriptor %s", got, bundle.SHA256) + } + + expected := runtimebundle.Bundle{ + Schema: runtimebundle.SchemaV1, Namespace: runtimebundle.NamespaceDriver, + Entries: runtimeEntries(bundle.Files), + } + _, err = runtimebundle.VerifyZipReader(archiveFile, archiveInfo.Size(), runtimebundle.VerifyOptions{Expected: &expected}) + if err != nil { + return fmt.Errorf("verify ZIP with runtimebundle: %w", err) + } + interfaceDigest, err := driverbundle.ComputeEngineInterfaceDigestFromSHA256(bundle.Files[0].SHA256, bundle.Files[1].SHA256) + if err != nil { + return fmt.Errorf("identify Engine interface: %w", err) + } + if interfaceDigest != bundle.EngineInterfaceDigest { + return fmt.Errorf("Engine interface digest = %s, want descriptor %s", interfaceDigest, bundle.EngineInterfaceDigest) + } + return nil +} + +func runtimeEntries(files []driverbundle.File) []runtimebundle.Entry { + entries := make([]runtimebundle.Entry, len(files)) + for i, file := range files { + entries[i] = runtimebundle.Entry{Name: file.Name, Mode: file.Mode, Size: file.Size, SHA256: file.SHA256} + } + return entries +} diff --git a/.github/scripts/driverbundle/verify_release.go b/.github/scripts/driverbundle/verify_release.go new file mode 100644 index 000000000..80bdbfe40 --- /dev/null +++ b/.github/scripts/driverbundle/verify_release.go @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package main + +import ( + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/goplus/spx/v3/internal/driverbundle" + "github.com/goplus/spx/v3/internal/release" +) + +func runVerifyRelease(args []string) error { + flags := flag.NewFlagSet("driverbundle verify-release", flag.ContinueOnError) + flags.SetOutput(os.Stderr) + directory, manifestPath, expectedVersion := ".", "", "" + producerCommit, pinOutput := "", "" + lockPath := "internal/release/runtime.lock.json" + verifyLineage := false + flags.StringVar(&lockPath, "lock", lockPath, "runtime lock JSON") + flags.StringVar(&directory, "directory", directory, "download directory") + flags.StringVar(&manifestPath, "manifest", "", "manifest path (defaults to directory/driver-manifest.json)") + flags.StringVar(&expectedVersion, "spx-version", "", "expected SPX release version") + flags.StringVar(&producerCommit, "producer-commit", "", "expected producer commit") + flags.StringVar(&pinOutput, "write-pin", "", "write a verified driver pin") + flags.BoolVar(&verifyLineage, "verify-lineage", false, "require producer commit ancestry and pin-only changes through HEAD") + if err := flags.Parse(args); err != nil { + return err + } + if flags.NArg() != 0 { + return fmt.Errorf("unexpected positional arguments: %s", strings.Join(flags.Args(), " ")) + } + if pinOutput != "" && (expectedVersion == "" || producerCommit == "") { + return fmt.Errorf("--write-pin requires --spx-version and --producer-commit") + } + if strings.TrimSpace(manifestPath) == "" { + manifestPath = filepath.Join(directory, driverbundle.ManifestName) + } + data, err := readRegularFile(manifestPath) + if err != nil { + return fmt.Errorf("read public driver manifest: %w", err) + } + manifest, err := driverbundle.Parse(data) + if err != nil { + return fmt.Errorf("parse public driver manifest: %w", err) + } + if expectedVersion != "" && manifest.SPXVersion != expectedVersion { + return fmt.Errorf("public driver manifest version = %q, want %q", manifest.SPXVersion, expectedVersion) + } + if producerCommit != "" && manifest.ProducerCommit != producerCommit { + return fmt.Errorf("public driver producer commit = %q, want %q", manifest.ProducerCommit, producerCommit) + } + lock, err := loadDriverLock(lockPath) + if err != nil { + return err + } + runtimePin, err := release.RuntimeManifestPinForLock(lock) + if err != nil { + return fmt.Errorf("embedded runtime manifest pin prerequisite: %w", err) + } + derivedPin, err := driverbundle.NewPin(manifest, data) + if err != nil { + return fmt.Errorf("derive public driver pin: %w", err) + } + pin := derivedPin + if pinOutput == "" { + pin, err = driverbundle.ForVersion(manifest.SPXVersion) + if err != nil { + return fmt.Errorf("embedded driver pin prerequisite: %w", err) + } + if pin != derivedPin { + return fmt.Errorf("public driver manifest does not match embedded pin") + } + } + if err := manifest.ValidateFor(lock, pin, runtimePin); err != nil { + return fmt.Errorf("validate public driver manifest: %w", err) + } + if verifyLineage { + if err := verifyProducerLineage(manifest.ProducerCommit, manifest.SPXVersion); err != nil { + return err + } + } + + expectedFiles := map[string]struct{}{driverbundle.ManifestName: {}} + for _, bundle := range manifest.Bundles { + expectedFiles[bundle.Name] = struct{}{} + zipPath := filepath.Join(directory, bundle.Name) + if err := verifyBundleFileForLock(zipPath, bundle, lock); err != nil { + return fmt.Errorf("verify public %s/%s: %w", bundle.GOOS, bundle.GOARCH, err) + } + } + entries, err := os.ReadDir(directory) + if err != nil { + return fmt.Errorf("list public driver release: %w", err) + } + for _, entry := range entries { + if _, ok := expectedFiles[entry.Name()]; !ok { + return fmt.Errorf("unexpected public driver release asset %q", entry.Name()) + } + if !entry.Type().IsRegular() { + return fmt.Errorf("public driver release asset %q is not regular", entry.Name()) + } + } + if len(entries) != len(expectedFiles) { + return fmt.Errorf("public driver release asset count = %d, want %d", len(entries), len(expectedFiles)) + } + if pinOutput != "" { + data, err := pin.JSON() + if err != nil { + return err + } + if err := ensureParent(pinOutput); err != nil { + return fmt.Errorf("prepare driver pin output: %w", err) + } + if err := atomicWrite(pinOutput, data, 0o644); err != nil { + return fmt.Errorf("write driver pin: %w", err) + } + } + return nil +} + +func verifyProducerLineage(producerCommit, spxVersion string) error { + if err := exec.Command("git", "merge-base", "--is-ancestor", producerCommit, "HEAD").Run(); err != nil { + return fmt.Errorf("driver producer commit %s is not an ancestor of HEAD: %w", producerCommit, err) + } + output, err := exec.Command("git", "diff", "--name-status", "--no-renames", producerCommit+"..HEAD", "--").Output() + if err != nil { + return fmt.Errorf("inspect changes after driver producer %s: %w", producerCommit, err) + } + wantPath := filepath.ToSlash(filepath.Join("internal", "driverbundle", "pins", spxVersion+".json")) + for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { + if strings.TrimSpace(line) == "" { + continue + } + fields := strings.Fields(line) + if len(fields) != 2 || fields[0] != "A" || filepath.ToSlash(fields[1]) != wantPath { + return fmt.Errorf("changes after driver producer must only add %s; found %q", wantPath, line) + } + } + return nil +} diff --git a/.github/scripts/driverbundle/verify_release_test.go b/.github/scripts/driverbundle/verify_release_test.go new file mode 100644 index 000000000..e433d09ed --- /dev/null +++ b/.github/scripts/driverbundle/verify_release_test.go @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package main + +import ( + "archive/zip" + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/goplus/spx/v3/internal/driverbundle" + "github.com/goplus/spx/v3/internal/release" +) + +func TestVerifyReleaseRecoversPinAfterFullVerification(t *testing.T) { + lock, err := release.RuntimeLockForVersion("2.4.3") + if err != nil { + t.Fatal(err) + } + runtimePin, err := release.RuntimeManifestPinForLock(lock) + if err != nil { + t.Fatal(err) + } + lockSHA, err := lock.SHA256() + if err != nil { + t.Fatal(err) + } + + directory := t.TempDir() + lockPath := filepath.Join(t.TempDir(), "runtime.lock.json") + lockData, err := lock.JSON() + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(lockPath, lockData, 0o600); err != nil { + t.Fatal(err) + } + targets := [][2]string{{"darwin", "amd64"}, {"darwin", "arm64"}, {"linux", "amd64"}, {"windows", "amd64"}} + bundles := make([]driverbundle.Bundle, 0, len(targets)) + for _, target := range targets { + bundles = append(bundles, writeReleaseTestBundle(t, directory, lock, target[0], target[1])) + } + producer := strings.Repeat("a", 40) + manifest := driverbundle.Manifest{ + Schema: driverbundle.ManifestSchema, SPXModule: driverbundle.SPXModulePath, SPXVersion: "v3.2.4", + RuntimeVersion: lock.RuntimeVersion, RuntimeABI: lock.RuntimeABI, ReleaseRepository: lock.ReleaseRepository, + RuntimeLockSHA256: lockSHA, RuntimeManifestSHA256: runtimePin.SHA256, + GoVersion: lock.Toolchain.Go, ProducerCommit: producer, Bundles: bundles, + } + manifestData, err := manifest.JSON() + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, driverbundle.ManifestName), manifestData, 0o600); err != nil { + t.Fatal(err) + } + + pinPath := filepath.Join(t.TempDir(), "pin", manifest.SPXVersion+".json") + args := []string{"--lock", lockPath, "--directory", directory, "--spx-version", manifest.SPXVersion, "--producer-commit", producer, "--write-pin", pinPath} + if err := runVerifyRelease(args); err != nil { + t.Fatal(err) + } + pinData, err := os.ReadFile(pinPath) + if err != nil { + t.Fatal(err) + } + got, err := driverbundle.ParsePin(pinData) + if err != nil { + t.Fatal(err) + } + want, err := driverbundle.NewPin(manifest, manifestData) + if err != nil || got != want { + t.Fatalf("recovered pin = %#v, want %#v: %v", got, want, err) + } + + if err := os.WriteFile(filepath.Join(directory, bundles[0].Name), []byte("tampered"), 0o600); err != nil { + t.Fatal(err) + } + badPin := filepath.Join(t.TempDir(), "pin.json") + args[len(args)-1] = badPin + if err := runVerifyRelease(args); err == nil { + t.Fatal("tampered release produced a pin") + } + if _, err := os.Stat(badPin); !os.IsNotExist(err) { + t.Fatalf("pin exists after failed verification: %v", err) + } +} + +func writeReleaseTestBundle(t *testing.T, directory string, lock release.RuntimeLock, goos, goarch string) driverbundle.Bundle { + t.Helper() + spec, err := release.HostRuntimeSpecFor(lock, goos, goarch) + if err != nil { + t.Fatal(err) + } + extension := map[string]string{"darwin": ".dylib", "linux": ".so", "windows": ".dll"}[goos] + names := [3]string{spec.RuntimeName, spec.PackName, "gdspx-" + goos + "-" + goarch + extension} + modes := [3]uint32{0o755, 0o644, 0o755} + name := "spx-driver-" + goos + "-" + goarch + ".zip" + path := filepath.Join(directory, name) + output, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + archive := zip.NewWriter(output) + files := make([]driverbundle.File, 0, len(names)) + for i, name := range names { + data := []byte(name + " bytes") + header := &zip.FileHeader{Name: name, Method: zip.Store, Modified: zipEpoch} + header.SetMode(os.FileMode(modes[i])) + writer, err := archive.CreateHeader(header) + if err != nil { + t.Fatal(err) + } + if _, err := writer.Write(data); err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(data) + files = append(files, driverbundle.File{Name: name, Mode: modes[i], Size: int64(len(data)), SHA256: hex.EncodeToString(digest[:])}) + } + if err := archive.Close(); err != nil { + t.Fatal(err) + } + if err := output.Close(); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(data) + interfaceDigest, err := driverbundle.ComputeEngineInterfaceDigestFromSHA256(files[0].SHA256, files[1].SHA256) + if err != nil { + t.Fatal(err) + } + return driverbundle.Bundle{ + GOOS: goos, GOARCH: goarch, Name: name, Size: int64(len(data)), SHA256: hex.EncodeToString(digest[:]), + EngineInterfaceDigest: interfaceDigest, Files: files, + } +} diff --git a/.github/scripts/driverbundle/workflow_test.py b/.github/scripts/driverbundle/workflow_test.py new file mode 100644 index 000000000..192368a0a --- /dev/null +++ b/.github/scripts/driverbundle/workflow_test.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 + +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +DRIVER_WORKFLOW = ROOT / "workflows" / "release_driver.yml" +PLATFORM_WORKFLOW = ROOT / "workflows" / "release_driver_platform.yml" +RELEASE_WORKFLOW = ROOT / "workflows" / "release.yml" +PREPARE_ACTION = ROOT / "actions" / "standalone" / "prepare" / "action.yml" + + +class DriverWorkflowTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.driver = DRIVER_WORKFLOW.read_text(encoding="utf-8") + cls.platform = PLATFORM_WORKFLOW.read_text(encoding="utf-8") + cls.release = RELEASE_WORKFLOW.read_text(encoding="utf-8") + cls.prepare = PREPARE_ACTION.read_text(encoding="utf-8") + + def test_driver_release_is_reusable_and_pinned(self): + self.assertIn("workflow_call:", self.driver) + self.assertNotIn("workflow_dispatch:", self.driver) + self.assertIn("driver-v&2 + exit 1 + fi + state=ready + fi + echo "${kind}_pin_state=$state" >> "$GITHUB_OUTPUT" + done + if [ -f "$driver_pin" ] && [ ! -f "$runtime_pin" ]; then + echo "[error] Driver pin requires the matching runtime manifest pin" >&2 + exit 1 + fi + if [ -f "$runtime_pin" ]; then + args=(check-prerequisites) + if [ -f "$driver_pin" ]; then + args+=(--driver-version "$SPX_VERSION") + fi + go run ./.github/scripts/driverbundle "${args[@]}" + fi + - name: Validate release inputs shell: bash env: @@ -209,15 +247,41 @@ jobs: local label="$3" local allow_public_reuse="$4" local is_draft + local ref_json local release_json - local target_commit + local release_target + local tag_commit + local tag_ref + local tag_type - if release_json="$(gh release view "$tag" --repo "$REPOSITORY" --json isDraft,assets 2>/dev/null)"; then + if release_json="$(gh release view "$tag" --repo "$REPOSITORY" --json isDraft,targetCommitish,assets 2>/dev/null)"; then is_draft="$(jq -r .isDraft <<< "$release_json")" - if [ "$allow_public_reuse" != true ] || [ "$is_draft" = true ]; then - target_commit="$(gh api "repos/$REPOSITORY/commits/$tag" --jq .sha)" - if [ "$target_commit" != "$GITHUB_SHA" ]; then - echo "[error] $label tag $tag points to $target_commit, not this commit $GITHUB_SHA" >&2 + release_target="$(jq -r .targetCommitish <<< "$release_json")" + if [[ ! "$release_target" =~ ^[0-9a-f]{40}$ ]]; then + echo "[error] $label release $tag has a non-canonical target: $release_target" >&2 + exit 1 + fi + if [ "$is_draft" = true ]; then + if [ "$release_target" != "$GITHUB_SHA" ]; then + echo "[error] $label draft $tag targets $release_target, not this commit $GITHUB_SHA" >&2 + exit 1 + fi + else + ref_json="$(gh api "repos/$REPOSITORY/git/ref/tags/$tag")" + tag_ref="$(jq -r .ref <<< "$ref_json")" + tag_type="$(jq -r .object.type <<< "$ref_json")" + tag_commit="$(jq -r .object.sha <<< "$ref_json")" + if [ "$tag_ref" != "refs/tags/$tag" ] || [ "$tag_type" != commit ] || \ + [[ ! "$tag_commit" =~ ^[0-9a-f]{40}$ ]]; then + echo "[error] $label release $tag has no exact lightweight tag ref" >&2 + exit 1 + fi + if [ "$tag_commit" != "$release_target" ]; then + echo "[error] $label tag $tag and release target disagree" >&2 + exit 1 + fi + if [ "$allow_public_reuse" != true ] && [ "$tag_commit" != "$GITHUB_SHA" ]; then + echo "[error] $label tag $tag points to $tag_commit, not this commit $GITHUB_SHA" >&2 exit 1 fi fi @@ -246,6 +310,8 @@ jobs: env: PLATFORMS_INPUT: ${{ inputs.platforms }} OPERATION_INPUT: ${{ inputs.operation }} + RUNTIME_PIN_STATE: ${{ steps.release-pins.outputs.runtime_pin_state }} + DRIVER_PIN_STATE: ${{ steps.release-pins.outputs.driver_pin_state }} run: | set -euo pipefail @@ -266,6 +332,13 @@ jobs: echo "[error] Publishing requires platforms=all" >&2 exit 1 fi + if [ "$OPERATION_INPUT" = publish-release ] && { [ "$RUNTIME_PIN_STATE" = missing ] || [ "$DRIVER_PIN_STATE" = missing ]; }; then + echo "run_web=false" >> "$GITHUB_OUTPUT" + echo "run_macos=false" >> "$GITHUB_OUTPUT" + echo "run_windows=false" >> "$GITHUB_OUTPUT" + echo "run_linux=false" >> "$GITHUB_OUTPUT" + exit 0 + fi if [ "$platforms" = all ]; then platforms="web,macos,windows,linux" fi @@ -383,6 +456,136 @@ jobs: with: engine_artifacts: ${{ needs.setup.outputs.runtime_state == 'missing' }} + runtime-pin-handoff: + name: Prepare runtime pin handoff + needs: [setup, publish-runtime] + if: >- + ${{ + !cancelled() && + inputs.operation == 'publish-release' && + needs.publish-runtime.result == 'success' && + needs.setup.outputs.runtime_pin_state == 'missing' + }} + runs-on: ubuntu-22.04 + steps: + - name: Check out release source + uses: actions/checkout@v7 + + - name: Set up locked Go and XGo + uses: ./.github/actions/deps + with: + setup-mode: none + + - name: Generate verified runtime pin + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RUNTIME_VERSION: ${{ needs.setup.outputs.runtime_version }} + run: | + set -euo pipefail + make release-pin + pin="internal/release/runtime_manifest_pins/$RUNTIME_VERSION.json" + test -f "$pin" + mkdir -p dist/runtime-pin + cp "$pin" "dist/runtime-pin/$RUNTIME_VERSION.json" + + - name: Upload generated runtime pin + uses: actions/upload-artifact@v7 + with: + name: spx-runtime-pin-${{ needs.setup.outputs.runtime_version }} + path: dist/runtime-pin + if-no-files-found: error + retention-days: 14 + + driver-release: + name: Publish project driver + needs: [setup, publish-runtime] + if: >- + ${{ + !cancelled() && + inputs.operation == 'publish-release' && + needs.publish-runtime.result == 'success' && + needs.setup.outputs.runtime_pin_state == 'ready' && + needs.setup.outputs.driver_pin_state == 'missing' + }} + permissions: + contents: write + uses: ./.github/workflows/release_driver.yml + with: + release_tag: ${{ needs.setup.outputs.driver_tag }} + + driver-verify: + name: Verify published project driver + needs: [setup, runtime-ready, publish-runtime, driver-release] + if: >- + ${{ + always() && + !cancelled() && + inputs.operation == 'publish-release' && + needs.publish-runtime.result == 'success' && + needs.setup.outputs.driver_pin_state == 'ready' && + needs.driver-release.result == 'skipped' + }} + runs-on: ubuntu-22.04 + steps: + - name: Check out code + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Set up locked Go and XGo + uses: ./.github/actions/deps + with: + setup-mode: none + + - name: Download and verify immutable driver release + env: + GH_TOKEN: ${{ github.token }} + DRIVER_TAG: ${{ needs.setup.outputs.driver_tag }} + REPOSITORY: ${{ needs.setup.outputs.release_repository }} + SPX_VERSION: ${{ needs.setup.outputs.release_tag }} + run: | + set -euo pipefail + + public_driver_target() { + local ref_json + local release_json + local tag_commit + local tag_ref + local tag_type + local target_commit + + release_json="$(gh release view "$DRIVER_TAG" --repo "$REPOSITORY" --json isDraft,targetCommitish)" + target_commit="$(jq -r .targetCommitish <<< "$release_json")" + if [ "$(jq -r .isDraft <<< "$release_json")" != false ] || \ + [[ ! "$target_commit" =~ ^[0-9a-f]{40}$ ]]; then + echo "[error] Driver release is not a canonical public producer: $DRIVER_TAG" >&2 + return 1 + fi + ref_json="$(gh api "repos/$REPOSITORY/git/ref/tags/$DRIVER_TAG")" + tag_ref="$(jq -r .ref <<< "$ref_json")" + tag_type="$(jq -r .object.type <<< "$ref_json")" + tag_commit="$(jq -r .object.sha <<< "$ref_json")" + if [ "$tag_ref" != "refs/tags/$DRIVER_TAG" ] || [ "$tag_type" != commit ] || \ + [ "$tag_commit" != "$target_commit" ]; then + echo "[error] Driver release tag and producer disagree: $DRIVER_TAG" >&2 + return 1 + fi + printf '%s\n' "$target_commit" + } + + producer_commit="$(public_driver_target)" + gh release download "$DRIVER_TAG" --repo "$REPOSITORY" --dir public-driver + go run ./.github/scripts/driverbundle verify-release \ + --directory public-driver \ + --spx-version "$SPX_VERSION" \ + --producer-commit "$producer_commit" \ + --verify-lineage + if [ "$(public_driver_target)" != "$producer_commit" ]; then + echo "[error] Driver release identity changed during verification: $DRIVER_TAG" >&2 + exit 1 + fi + assemble: name: Assemble and verify releases needs: @@ -415,7 +618,14 @@ jobs: setup-mode: none - name: Download all build artifacts - if: needs.setup.outputs.runtime_state == 'missing' || inputs.operation != 'publish-runtime' + if: >- + ${{ + needs.setup.outputs.runtime_state == 'missing' || + needs.setup.outputs.run_web == 'true' || + needs.setup.outputs.run_macos == 'true' || + needs.setup.outputs.run_windows == 'true' || + needs.setup.outputs.run_linux == 'true' + }} uses: actions/download-artifact@v8 with: path: artifacts @@ -495,9 +705,40 @@ jobs: run: | set -euo pipefail - if gh release view "$RELEASE_TAG" --repo "$REPOSITORY" >/dev/null 2>&1; then - is_draft="$(gh release view "$RELEASE_TAG" --repo "$REPOSITORY" --json isDraft --jq .isDraft)" + public_release_target() { + local ref_json + local release_json + local tag_commit + local tag_ref + local tag_type + local target_commit + + release_json="$(gh release view "$RELEASE_TAG" --repo "$REPOSITORY" --json isDraft,targetCommitish)" + if [ "$(jq -r .isDraft <<< "$release_json")" != false ]; then + echo "[error] Runtime release is not public: $RELEASE_TAG" >&2 + return 1 + fi + target_commit="$(jq -r .targetCommitish <<< "$release_json")" + if [[ ! "$target_commit" =~ ^[0-9a-f]{40}$ ]]; then + echo "[error] Runtime release has a non-canonical target: $target_commit" >&2 + return 1 + fi + ref_json="$(gh api "repos/$REPOSITORY/git/ref/tags/$RELEASE_TAG")" + tag_ref="$(jq -r .ref <<< "$ref_json")" + tag_type="$(jq -r .object.type <<< "$ref_json")" + tag_commit="$(jq -r .object.sha <<< "$ref_json")" + if [ "$tag_ref" != "refs/tags/$RELEASE_TAG" ] || [ "$tag_type" != commit ] || \ + [ "$tag_commit" != "$target_commit" ]; then + echo "[error] Runtime release tag and target disagree: $RELEASE_TAG" >&2 + return 1 + fi + printf '%s\n' "$target_commit" + } + + if release_json="$(gh release view "$RELEASE_TAG" --repo "$REPOSITORY" --json isDraft,targetCommitish 2>/dev/null)"; then + is_draft="$(jq -r .isDraft <<< "$release_json")" if [ "$is_draft" != true ]; then + published_target="$(public_release_target)" mkdir -p published gh release download "$RELEASE_TAG" --repo "$REPOSITORY" --dir published (cd published && sha256sum -c SHA256SUMS) @@ -506,12 +747,22 @@ jobs: if cmp -s "dist/$RUNTIME_MANIFEST" "published/$RUNTIME_MANIFEST" && \ cmp -s dist/SHA256SUMS published/SHA256SUMS && \ cmp -s published/local-assets.txt published/remote-assets.txt; then + rechecked_target="$(public_release_target)" + if [ "$rechecked_target" != "$published_target" ]; then + echo "[error] Runtime release target changed during verification: $RELEASE_TAG" >&2 + exit 1 + fi echo "[info] Identical immutable runtime release already exists: $RELEASE_TAG" exit 0 fi echo "[error] Runtime tag $RELEASE_TAG already exists with different provenance" >&2 exit 1 fi + target_commit="$(jq -r .targetCommitish <<< "$release_json")" + if [[ ! "$target_commit" =~ ^[0-9a-f]{40}$ ]] || [ "$target_commit" != "$GITHUB_SHA" ]; then + echo "[error] Runtime draft $RELEASE_TAG targets $target_commit, not $GITHUB_SHA" >&2 + exit 1 + fi while IFS= read -r name; do [ -z "$name" ] || gh release delete-asset "$RELEASE_TAG" "$name" --repo "$REPOSITORY" --yes done < <(gh release view "$RELEASE_TAG" --repo "$REPOSITORY" --json assets --jq '.assets[].name') @@ -525,12 +776,23 @@ jobs: fi gh release upload "$RELEASE_TAG" dist/* --repo "$REPOSITORY" + release_json="$(gh release view "$RELEASE_TAG" --repo "$REPOSITORY" --json isDraft,targetCommitish)" + if [ "$(jq -r .isDraft <<< "$release_json")" != true ] || \ + [ "$(jq -r .targetCommitish <<< "$release_json")" != "$GITHUB_SHA" ]; then + echo "[error] Runtime draft changed before publication: $RELEASE_TAG" >&2 + exit 1 + fi gh release edit "$RELEASE_TAG" --repo "$REPOSITORY" --draft=false + published_target="$(public_release_target)" + if [ "$published_target" != "$GITHUB_SHA" ]; then + echo "[error] Published runtime identity changed: $RELEASE_TAG" >&2 + exit 1 + fi publish-spx: name: Prepare SPX release draft - needs: [setup, assemble, publish-runtime] - if: ${{ !cancelled() && inputs.operation == 'publish-release' && needs.publish-runtime.result == 'success' }} + needs: [setup, assemble, publish-runtime, driver-verify] + if: ${{ !cancelled() && inputs.operation == 'publish-release' && needs.publish-runtime.result == 'success' && needs.driver-verify.result == 'success' }} runs-on: ubuntu-22.04 permissions: contents: write @@ -550,9 +812,44 @@ jobs: run: | set -euo pipefail - if gh release view "$RELEASE_TAG" --repo "$REPOSITORY" >/dev/null 2>&1; then - is_draft="$(gh release view "$RELEASE_TAG" --repo "$REPOSITORY" --json isDraft --jq .isDraft)" + public_release_target() { + local ref_json + local release_json + local tag_commit + local tag_ref + local tag_type + local target_commit + + release_json="$(gh release view "$RELEASE_TAG" --repo "$REPOSITORY" --json isDraft,targetCommitish)" + if [ "$(jq -r .isDraft <<< "$release_json")" != false ]; then + echo "[error] SPX release is not public: $RELEASE_TAG" >&2 + return 1 + fi + target_commit="$(jq -r .targetCommitish <<< "$release_json")" + if [[ ! "$target_commit" =~ ^[0-9a-f]{40}$ ]]; then + echo "[error] SPX release has a non-canonical target: $target_commit" >&2 + return 1 + fi + ref_json="$(gh api "repos/$REPOSITORY/git/ref/tags/$RELEASE_TAG")" + tag_ref="$(jq -r .ref <<< "$ref_json")" + tag_type="$(jq -r .object.type <<< "$ref_json")" + tag_commit="$(jq -r .object.sha <<< "$ref_json")" + if [ "$tag_ref" != "refs/tags/$RELEASE_TAG" ] || [ "$tag_type" != commit ] || \ + [ "$tag_commit" != "$target_commit" ]; then + echo "[error] SPX release tag and target disagree: $RELEASE_TAG" >&2 + return 1 + fi + printf '%s\n' "$target_commit" + } + + if release_json="$(gh release view "$RELEASE_TAG" --repo "$REPOSITORY" --json isDraft,targetCommitish 2>/dev/null)"; then + is_draft="$(jq -r .isDraft <<< "$release_json")" if [ "$is_draft" != true ]; then + published_target="$(public_release_target)" + if [ "$published_target" != "$GITHUB_SHA" ]; then + echo "[error] SPX release targets $published_target, not $GITHUB_SHA" >&2 + exit 1 + fi mkdir -p published gh release download "$RELEASE_TAG" --repo "$REPOSITORY" --dir published (cd published && sha256sum -c SHA256SUMS) @@ -560,12 +857,22 @@ jobs: gh release view "$RELEASE_TAG" --repo "$REPOSITORY" --json assets --jq '.assets[].name' | sort > published/remote-assets.txt if cmp -s dist/SHA256SUMS published/SHA256SUMS && \ cmp -s published/local-assets.txt published/remote-assets.txt; then + rechecked_target="$(public_release_target)" + if [ "$rechecked_target" != "$published_target" ]; then + echo "[error] SPX release target changed during verification: $RELEASE_TAG" >&2 + exit 1 + fi echo "[info] Identical SPX release already exists: $RELEASE_TAG" exit 0 fi echo "[error] SPX tag $RELEASE_TAG already exists with different assets" >&2 exit 1 fi + target_commit="$(jq -r .targetCommitish <<< "$release_json")" + if [[ ! "$target_commit" =~ ^[0-9a-f]{40}$ ]] || [ "$target_commit" != "$GITHUB_SHA" ]; then + echo "[error] SPX draft $RELEASE_TAG targets $target_commit, not $GITHUB_SHA" >&2 + exit 1 + fi while IFS= read -r name; do [ -z "$name" ] || gh release delete-asset "$RELEASE_TAG" "$name" --repo "$REPOSITORY" --yes done < <(gh release view "$RELEASE_TAG" --repo "$REPOSITORY" --json assets --jq '.assets[].name') @@ -579,6 +886,12 @@ jobs: fi gh release upload "$RELEASE_TAG" dist/* --repo "$REPOSITORY" + release_json="$(gh release view "$RELEASE_TAG" --repo "$REPOSITORY" --json isDraft,targetCommitish)" + if [ "$(jq -r .isDraft <<< "$release_json")" != true ] || \ + [ "$(jq -r .targetCommitish <<< "$release_json")" != "$GITHUB_SHA" ]; then + echo "[error] SPX draft changed after upload: $RELEASE_TAG" >&2 + exit 1 + fi publish-web-package: name: Publish npm package @@ -651,7 +964,52 @@ jobs: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ needs.setup.outputs.release_tag }} REPOSITORY: ${{ needs.setup.outputs.release_repository }} - run: gh release edit "$RELEASE_TAG" --repo "$REPOSITORY" --draft=false + run: | + set -euo pipefail + + public_release_target() { + local ref_json + local release_json + local tag_commit + local tag_ref + local tag_type + local target_commit + + release_json="$(gh release view "$RELEASE_TAG" --repo "$REPOSITORY" --json isDraft,targetCommitish)" + if [ "$(jq -r .isDraft <<< "$release_json")" != false ]; then + echo "[error] SPX release is not public: $RELEASE_TAG" >&2 + return 1 + fi + target_commit="$(jq -r .targetCommitish <<< "$release_json")" + if [[ ! "$target_commit" =~ ^[0-9a-f]{40}$ ]]; then + echo "[error] SPX release has a non-canonical target: $target_commit" >&2 + return 1 + fi + ref_json="$(gh api "repos/$REPOSITORY/git/ref/tags/$RELEASE_TAG")" + tag_ref="$(jq -r .ref <<< "$ref_json")" + tag_type="$(jq -r .object.type <<< "$ref_json")" + tag_commit="$(jq -r .object.sha <<< "$ref_json")" + if [ "$tag_ref" != "refs/tags/$RELEASE_TAG" ] || [ "$tag_type" != commit ] || \ + [ "$tag_commit" != "$target_commit" ]; then + echo "[error] SPX release tag and target disagree: $RELEASE_TAG" >&2 + return 1 + fi + printf '%s\n' "$target_commit" + } + + release_json="$(gh release view "$RELEASE_TAG" --repo "$REPOSITORY" --json isDraft,targetCommitish)" + target_commit="$(jq -r .targetCommitish <<< "$release_json")" + if [[ ! "$target_commit" =~ ^[0-9a-f]{40}$ ]] || [ "$target_commit" != "$GITHUB_SHA" ]; then + echo "[error] SPX release $RELEASE_TAG targets $target_commit, not $GITHUB_SHA" >&2 + exit 1 + fi + if [ "$(jq -r .isDraft <<< "$release_json")" = true ]; then + gh release edit "$RELEASE_TAG" --repo "$REPOSITORY" --draft=false + fi + if [ "$(public_release_target)" != "$GITHUB_SHA" ]; then + echo "[error] Published SPX identity changed: $RELEASE_TAG" >&2 + exit 1 + fi release-gate: name: Verify release operation completed @@ -659,6 +1017,9 @@ jobs: - setup - assemble - publish-runtime + - runtime-pin-handoff + - driver-release + - driver-verify - publish-spx - publish-web-package - finalize-spx @@ -671,11 +1032,16 @@ jobs: shell: bash env: OPERATION: ${{ inputs.operation }} + RUNTIME_PIN_STATE: ${{ needs.setup.outputs.runtime_pin_state }} + DRIVER_PIN_STATE: ${{ needs.setup.outputs.driver_pin_state }} + RUNTIME_VERSION: ${{ needs.setup.outputs.runtime_version }} + SPX_VERSION: ${{ needs.setup.outputs.release_tag }} NEEDS_JSON: ${{ toJSON(needs) }} run: | set -euo pipefail required=() + handoff= case "$OPERATION" in dry-run) required+=(setup assemble) @@ -684,7 +1050,15 @@ jobs: required+=(setup assemble publish-runtime) ;; publish-release) - required+=(setup assemble publish-runtime publish-spx publish-web-package finalize-spx) + if [ "$RUNTIME_PIN_STATE" = missing ]; then + required+=(setup assemble publish-runtime runtime-pin-handoff) + handoff="runtime pin|spx-runtime-pin-$RUNTIME_VERSION|internal/release/runtime_manifest_pins/$RUNTIME_VERSION.json" + elif [ "$DRIVER_PIN_STATE" = missing ]; then + required+=(setup assemble publish-runtime driver-release) + handoff="driver pin|spx-driver-pin-$SPX_VERSION|internal/driverbundle/pins/$SPX_VERSION.json" + else + required+=(setup assemble publish-runtime driver-verify publish-spx publish-web-package finalize-spx) + fi ;; publish-dev-npm) required+=(dev-npm-guard publish-dev-web-package) @@ -701,3 +1075,11 @@ jobs: exit 1 fi done + if [ -n "$handoff" ]; then + IFS='|' read -r label artifact destination <<< "$handoff" + echo "[error] $label handoff required before SPX can be published" >&2 + echo "[error] From this exact commit, run 'make release-pin' and commit only $destination" >&2 + echo "[error] Artifact $artifact contains the same verified pin when local generation is unavailable" >&2 + echo "[error] Start a new publish-release run from the pin commit; do not rerun this SHA" >&2 + exit 1 + fi diff --git a/.github/workflows/release_driver.yml b/.github/workflows/release_driver.yml new file mode 100644 index 000000000..901102709 --- /dev/null +++ b/.github/workflows/release_driver.yml @@ -0,0 +1,330 @@ +name: Release project driver + +on: + workflow_call: + inputs: + release_tag: + description: 'Driver release tag declared by this commit (driver-v&2 + exit 1 + fi + case "$EXPECTED_TAG" in + driver-v[0-9]*.[0-9]*.[0-9]*) ;; + *) echo "[error] Driver tag must be driver-v: $EXPECTED_TAG" >&2; exit 1 ;; + esac + if [ "$CURRENT_REPOSITORY" != "$REPOSITORY" ]; then + echo "[error] Driver publication is locked to $REPOSITORY, not $CURRENT_REPOSITORY" >&2 + exit 1 + fi + + - name: Require embedded runtime manifest pin + shell: bash + run: go run ./.github/scripts/driverbundle check-prerequisites + + - name: Resolve and verify published runtime release + id: runtime + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + go run ./.github/scripts/runtime/resolution.go \ + --lock internal/release/runtime.lock.json \ + --repo-root . \ + --revision HEAD \ + --github-output "$GITHUB_OUTPUT" + if [ "$(grep '^runtime_state=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2-)" != ready ]; then + echo "[error] Driver release requires an already published and verified runtime release" >&2 + exit 1 + fi + + - name: Resolve driver release state + id: driver-release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + DRIVER_TAG: ${{ steps.release.outputs.driver_tag }} + REPOSITORY: ${{ steps.release.outputs.release_repository }} + run: | + set -euo pipefail + state=missing + if release_json="$(gh release view "$DRIVER_TAG" --repo "$REPOSITORY" --json isDraft,targetCommitish 2>/dev/null)"; then + is_draft="$(jq -r .isDraft <<< "$release_json")" + target_commit="$(jq -r .targetCommitish <<< "$release_json")" + if [[ ! "$target_commit" =~ ^[0-9a-f]{40}$ ]] || [ "$target_commit" != "$GITHUB_SHA" ]; then + echo "[error] Driver tag $DRIVER_TAG points to ${target_commit:-unknown}, not $GITHUB_SHA" >&2 + exit 1 + fi + if [ "$is_draft" = true ]; then + state=draft + echo "[info] Resuming existing draft driver release: $DRIVER_TAG" + else + ref_json="$(gh api "repos/$REPOSITORY/git/ref/tags/$DRIVER_TAG")" + tag_ref="$(jq -r .ref <<< "$ref_json")" + tag_type="$(jq -r .object.type <<< "$ref_json")" + tag_commit="$(jq -r .object.sha <<< "$ref_json")" + if [ "$tag_ref" != "refs/tags/$DRIVER_TAG" ] || [ "$tag_type" != commit ] || \ + [ "$tag_commit" != "$target_commit" ]; then + echo "[error] Driver release tag and target disagree: $DRIVER_TAG" >&2 + exit 1 + fi + state=ready + echo "[info] Reusing existing immutable driver release: $DRIVER_TAG" + fi + elif git ls-remote --exit-code --tags "https://github.com/$REPOSITORY.git" "refs/tags/$DRIVER_TAG" >/dev/null 2>&1; then + echo "[error] Driver tag $DRIVER_TAG already exists without a resumable release" >&2 + exit 1 + fi + echo "driver_state=$state" >> "$GITHUB_OUTPUT" + + driver: + name: Driver ${{ matrix.goos }}/${{ matrix.goarch }} + needs: setup + if: needs.setup.outputs.driver_state != 'ready' + strategy: + fail-fast: false + matrix: + include: + - {goos: darwin, goarch: amd64, runner: macos-15-intel} + - {goos: darwin, goarch: arm64, runner: macos-15} + - {goos: linux, goarch: amd64, runner: ubuntu-22.04} + - {goos: windows, goarch: amd64, runner: windows-latest} + uses: ./.github/workflows/release_driver_platform.yml + with: + goos: ${{ matrix.goos }} + goarch: ${{ matrix.goarch }} + runner: ${{ matrix.runner }} + + assemble: + name: Assemble and verify driver release + needs: + - setup + - driver + runs-on: ubuntu-22.04 + steps: + - name: Check out code + uses: actions/checkout@v7 + + - name: Set up locked Go and XGo + uses: ./.github/actions/deps + with: + setup-mode: none + + - name: Download platform driver artifacts + uses: actions/download-artifact@v8 + with: + pattern: spx-driver-* + path: dist/input + + - name: Assemble strict manifest and generated pin + shell: bash + env: + SPX_VERSION: ${{ needs.setup.outputs.spx_version }} + run: | + set -euo pipefail + mapfile -t descriptors < <(find dist/input -type f -name driver-bundle.json -print | sort) + if [ "${#descriptors[@]}" -ne 4 ]; then + echo "[error] Found ${#descriptors[@]} driver descriptors, want exactly four" >&2 + exit 1 + fi + args=(assemble --spx-version "$SPX_VERSION" --producer-commit "$GITHUB_SHA" \ + --manifest dist/driver/driver-manifest.json \ + --pin "dist/pin/$SPX_VERSION.json") + for descriptor in "${descriptors[@]}"; do + args+=(--descriptor "$descriptor") + done + mkdir -p dist/driver dist/pin + go run ./.github/scripts/driverbundle "${args[@]}" + + - name: Upload assembled driver release + uses: actions/upload-artifact@v7 + with: + name: spx-driver-release-${{ needs.setup.outputs.spx_version }} + path: dist/driver + if-no-files-found: error + retention-days: 14 + + - name: Upload generated driver pin + uses: actions/upload-artifact@v7 + with: + name: spx-driver-pin-${{ needs.setup.outputs.spx_version }} + path: dist/pin + if-no-files-found: error + retention-days: 14 + + recover-pin: + name: Recover pin from immutable driver release + needs: setup + if: needs.setup.outputs.driver_state == 'ready' + runs-on: ubuntu-22.04 + steps: + - name: Check out code + uses: actions/checkout@v7 + + - name: Set up locked Go and XGo + uses: ./.github/actions/deps + with: + setup-mode: none + + - name: Download and verify immutable driver release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + DRIVER_TAG: ${{ needs.setup.outputs.driver_tag }} + REPOSITORY: ${{ needs.setup.outputs.repository }} + SPX_VERSION: ${{ needs.setup.outputs.spx_version }} + run: | + set -euo pipefail + release_json="$(gh release view "$DRIVER_TAG" --repo "$REPOSITORY" --json isDraft,targetCommitish)" + if [ "$(jq -r .isDraft <<< "$release_json")" != false ] || [ "$(jq -r .targetCommitish <<< "$release_json")" != "$GITHUB_SHA" ]; then + echo "[error] Driver release is not the expected public producer: $DRIVER_TAG" >&2 + exit 1 + fi + ref_json="$(gh api "repos/$REPOSITORY/git/ref/tags/$DRIVER_TAG")" + tag_ref="$(jq -r .ref <<< "$ref_json")" + tag_type="$(jq -r .object.type <<< "$ref_json")" + tag_commit="$(jq -r .object.sha <<< "$ref_json")" + if [ "$tag_ref" != "refs/tags/$DRIVER_TAG" ] || [ "$tag_type" != commit ] || \ + [ "$tag_commit" != "$GITHUB_SHA" ]; then + echo "[error] Driver release tag and producer disagree: $DRIVER_TAG" >&2 + exit 1 + fi + gh release download "$DRIVER_TAG" --repo "$REPOSITORY" --dir public-driver + go run ./.github/scripts/driverbundle verify-release \ + --directory public-driver \ + --spx-version "$SPX_VERSION" \ + --producer-commit "$GITHUB_SHA" \ + --write-pin "dist/pin/$SPX_VERSION.json" + release_json="$(gh release view "$DRIVER_TAG" --repo "$REPOSITORY" --json isDraft,targetCommitish)" + ref_json="$(gh api "repos/$REPOSITORY/git/ref/tags/$DRIVER_TAG")" + tag_ref="$(jq -r .ref <<< "$ref_json")" + tag_type="$(jq -r .object.type <<< "$ref_json")" + tag_commit="$(jq -r .object.sha <<< "$ref_json")" + if [ "$(jq -r .isDraft <<< "$release_json")" != false ] || \ + [ "$(jq -r .targetCommitish <<< "$release_json")" != "$GITHUB_SHA" ] || \ + [ "$tag_ref" != "refs/tags/$DRIVER_TAG" ] || [ "$tag_type" != commit ] || \ + [ "$tag_commit" != "$GITHUB_SHA" ]; then + echo "[error] Driver release identity changed during pin recovery: $DRIVER_TAG" >&2 + exit 1 + fi + + - name: Upload recovered driver pin + uses: actions/upload-artifact@v7 + with: + name: spx-driver-pin-${{ needs.setup.outputs.spx_version }} + path: dist/pin + if-no-files-found: error + retention-days: 14 + + publish: + name: Publish immutable driver release + needs: [setup, assemble] + runs-on: ubuntu-22.04 + permissions: + contents: write + steps: + - name: Download assembled driver release + uses: actions/download-artifact@v8 + with: + name: spx-driver-release-${{ needs.setup.outputs.spx_version }} + path: dist/driver + + - name: Publish driver release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + DRIVER_TAG: ${{ needs.setup.outputs.driver_tag }} + SPX_VERSION: ${{ needs.setup.outputs.spx_version }} + REPOSITORY: ${{ needs.setup.outputs.repository }} + run: | + set -euo pipefail + mapfile -t assets < <(find dist/driver -mindepth 1 -maxdepth 1 -type f -printf '%f\n' | sort) + expected=(driver-manifest.json spx-driver-darwin-amd64.zip spx-driver-darwin-arm64.zip spx-driver-linux-amd64.zip spx-driver-windows-amd64.zip) + if [ "${#assets[@]}" -ne "${#expected[@]}" ] || ! diff -u <(printf '%s\n' "${expected[@]}") <(printf '%s\n' "${assets[@]}"); then + echo "[error] Driver release assets are not exactly manifest plus four platform bundles" >&2 + exit 1 + fi + if gh release view "$DRIVER_TAG" --repo "$REPOSITORY" >/dev/null 2>&1; then + release_json="$(gh release view "$DRIVER_TAG" --repo "$REPOSITORY" --json isDraft,targetCommitish)" + is_draft="$(jq -r .isDraft <<< "$release_json")" + if [ "$is_draft" != true ]; then + echo "[error] Existing driver release is no longer a draft: $DRIVER_TAG" >&2 + exit 1 + fi + target_commit="$(jq -r .targetCommitish <<< "$release_json")" + if [[ ! "$target_commit" =~ ^[0-9a-f]{40}$ ]] || [ "$target_commit" != "$GITHUB_SHA" ]; then + echo "[error] Existing draft driver tag $DRIVER_TAG points to ${target_commit:-unknown}, not $GITHUB_SHA" >&2 + exit 1 + fi + while IFS= read -r name; do + [ -z "$name" ] || gh release delete-asset "$DRIVER_TAG" "$name" --repo "$REPOSITORY" --yes + done < <(gh release view "$DRIVER_TAG" --repo "$REPOSITORY" --json assets --jq '.assets[].name') + else + gh release create "$DRIVER_TAG" --repo "$REPOSITORY" --target "$GITHUB_SHA" \ + --title "SPX Project Driver $SPX_VERSION" --notes "Content-verifiable Engine/PCK/bridge bundles." --draft + fi + gh release upload "$DRIVER_TAG" dist/driver/* --repo "$REPOSITORY" + release_json="$(gh release view "$DRIVER_TAG" --repo "$REPOSITORY" --json isDraft,targetCommitish)" + if [ "$(jq -r .isDraft <<< "$release_json")" != true ] || \ + [ "$(jq -r .targetCommitish <<< "$release_json")" != "$GITHUB_SHA" ]; then + echo "[error] Driver draft changed after upload: $DRIVER_TAG" >&2 + exit 1 + fi + gh release edit "$DRIVER_TAG" --repo "$REPOSITORY" --draft=false + release_json="$(gh release view "$DRIVER_TAG" --repo "$REPOSITORY" --json isDraft,targetCommitish)" + ref_json="$(gh api "repos/$REPOSITORY/git/ref/tags/$DRIVER_TAG")" + tag_ref="$(jq -r .ref <<< "$ref_json")" + tag_type="$(jq -r .object.type <<< "$ref_json")" + tag_commit="$(jq -r .object.sha <<< "$ref_json")" + if [ "$(jq -r .isDraft <<< "$release_json")" != false ] || \ + [ "$(jq -r .targetCommitish <<< "$release_json")" != "$GITHUB_SHA" ] || \ + [ "$tag_ref" != "refs/tags/$DRIVER_TAG" ] || [ "$tag_type" != commit ] || \ + [ "$tag_commit" != "$GITHUB_SHA" ]; then + echo "[error] Published driver identity changed: $DRIVER_TAG" >&2 + exit 1 + fi diff --git a/.github/workflows/release_driver_platform.yml b/.github/workflows/release_driver_platform.yml new file mode 100644 index 000000000..9d2626bfd --- /dev/null +++ b/.github/workflows/release_driver_platform.yml @@ -0,0 +1,87 @@ +name: Package project driver + +on: + workflow_call: + inputs: + goos: + description: Target GOOS + required: true + type: string + goarch: + description: Target GOARCH + required: true + type: string + runner: + description: Native runner label + required: true + type: string + +concurrency: + group: driver-${{ inputs.goos }}-${{ inputs.goarch }}-${{ github.run_id }} + cancel-in-progress: true + +jobs: + package: + name: Driver ${{ inputs.goos }}/${{ inputs.goarch }} + runs-on: ${{ inputs.runner }} + steps: + - name: Check out code + uses: actions/checkout@v7 + + - name: Install Linux dependencies + if: inputs.goos == 'linux' + uses: ./.github/actions/install-linux-dependencies + timeout-minutes: 20 + with: + packages: | + gcc + libgl1-mesa-dev + libegl1-mesa-dev + libgles2-mesa-dev + libx11-dev + xorg-dev + libasound2-dev + libopenal-dev + zip + + - name: Set up Windows packaging tools + if: inputs.goos == 'windows' + uses: msys2/setup-msys2@v2 + with: + update: true + install: >- + base-devel + mingw-w64-x86_64-toolchain + zip + unzip + bash + + - name: Set up locked toolchain + uses: ./.github/actions/deps + with: + setup-mode: none + + - name: Prepare standalone runtime and bridge + id: prepare + uses: ./.github/actions/standalone/prepare + + - name: Package and verify driver bundle + uses: ./.github/actions/driver-bundle + with: + engine: ${{ steps.prepare.outputs.engine-path }} + pack: ${{ steps.prepare.outputs.pack-path }} + bridge: ${{ steps.prepare.outputs.bridge-path }} + output: spx-driver-${{ inputs.goos }}-${{ inputs.goarch }}.zip + descriptor: driver-bundle.json + goos: ${{ inputs.goos }} + goarch: ${{ inputs.goarch }} + + - name: Upload driver bundle inputs + uses: actions/upload-artifact@v7 + with: + name: spx-driver-${{ inputs.goos }}-${{ inputs.goarch }} + path: | + spx-driver-${{ inputs.goos }}-${{ inputs.goarch }}.zip + driver-bundle.json + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/static_checks.yml b/.github/workflows/static_checks.yml index ecfe070df..f47334367 100644 --- a/.github/workflows/static_checks.yml +++ b/.github/workflows/static_checks.yml @@ -53,9 +53,12 @@ jobs: go test -v .github/scripts/runtime/manifest.go .github/scripts/runtime/manifest_test.go go test -v .github/scripts/runtime/resolution.go .github/scripts/runtime/resolution_test.go go test -v .github/scripts/runtime/version.go .github/scripts/runtime/version_test.go + go test -v ./.github/scripts/driverbundle PYTHONDONTWRITEBYTECODE=1 python3 .github/scripts/runtime_build_contract_test.py PYTHONDONTWRITEBYTECODE=1 python3 .github/scripts/release/workflow_test.py + PYTHONDONTWRITEBYTECODE=1 python3 .github/scripts/driverbundle/workflow_test.py PYTHONDONTWRITEBYTECODE=1 python3 .github/scripts/release_bump_test.py + PYTHONDONTWRITEBYTECODE=1 python3 .github/scripts/release_pin_test.py go run .github/scripts/runtime/digest.go pack-source HEAD >/dev/null go run .github/scripts/runtime/digest.go build-recipe HEAD >/dev/null go test -v $(go list ./... | grep -v /internal/webffi) diff --git a/Makefile b/Makefile index fd4897fa6..97e667d00 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,7 @@ ifeq ($(LOCKED_GO_HOST_GOOS),darwin) LOCKED_GO = bash "$(CURDIR)/$(MACOS_GO_TOOLCHAIN)" go$(LOCKED_GO_VERSION) endif -.PHONY: $(BUILDCTL_TARGETS) help help-advanced buildctl format generate generate-bindings generate-runtime bump-release pin-godot pin-godot-unpublished pin-godot-candidate clean-projects validate-download-engine validate-install-web validate-bump-release validate-pin-godot +.PHONY: $(BUILDCTL_TARGETS) help help-advanced buildctl format generate generate-bindings generate-runtime bump-release release-pin pin-godot pin-godot-unpublished pin-godot-candidate clean-projects validate-download-engine validate-install-web validate-bump-release validate-pin-godot DEMO_INDEX ?= 3 APK_PROJECT_DIR ?= tutorial/00-Hello @@ -78,6 +78,9 @@ bump-release: validate-bump-release ## Advance SPX and create a paired immutable python3 .github/scripts/release_bump.py "$(SPX_VERSION)" "$(RUNTIME_VERSION)"$(if $(strip $(RUNTIME_ABI)), --runtime-abi "$(RUNTIME_ABI)") git diff --check +release-pin: ## Generate the next verified runtime or driver release pin + PYTHONDONTWRITEBYTECODE=1 python3 .github/scripts/release_pin.py + pin-godot: override PIN_GODOT_POLICY := pin-godot-unpublished: override PIN_GODOT_POLICY := --unpublished pin-godot-candidate: override PIN_GODOT_POLICY := --premerge @@ -117,11 +120,13 @@ help-advanced: ## Show all commands, including low-level targets @echo " PLATFORM is required by download-engine." @echo " GODOT_REF is optional for pin-godot targets; omitting it retains the current lock ref." @echo " bump-release requires authenticated gh access; current tags must be public and target tags unused." + @echo " release-pin requires authenticated gh access and a clean frozen release worktree." @echo " Use pin-godot-unpublished only after confirming that the current snapshot is unpublished." @echo " pin-godot-candidate permits verified candidate-only pinning; never use it for publication." @echo " Examples:" @echo " make bump-release SPX_VERSION=v3.3.0 RUNTIME_VERSION=2.5.0" @echo " make bump-release SPX_VERSION=v3.4.0 RUNTIME_VERSION=3.0.0 RUNTIME_ABI=3" + @echo " make release-pin" @echo " make pin-godot GODOT_SHA=<40-sha>" @echo " make pin-godot-unpublished GODOT_SHA=<40-sha>" @echo " make pin-godot-candidate GODOT_SHA=<40-sha>" diff --git a/cmd/spx/internal/command/builderai/gox.mod b/cmd/spx/internal/command/builderai/gox.mod index bac6d22c8..2801a938a 100644 --- a/cmd/spx/internal/command/builderai/gox.mod +++ b/cmd/spx/internal/command/builderai/gox.mod @@ -1,6 +1,7 @@ -xgo 1.7.5 +xgo 1.8.0 project main.spx Game github.com/goplus/spx/v3 math +driver v1 github.com/goplus/spx/v3/cmd/xgodriver class -embed *.spx SpriteImpl diff --git a/cmd/spx/internal/command/buildlauncher.go b/cmd/spx/internal/command/buildlauncher.go index af9d6c40d..6fdc3dfed 100644 --- a/cmd/spx/internal/command/buildlauncher.go +++ b/cmd/spx/internal/command/buildlauncher.go @@ -81,7 +81,7 @@ func (cmd *CmdTool) runBuildLauncher() error { config := launchpack.Config{ ProjectDir: project.dir, ProjectFile: project.file, ProjectExt: project.extension, PackDir: project.packDir, PackIndex: project.packIndex, PortableConfig: snapshot, - RuntimeSourceRoot: source.root, RuntimeIdentity: launchpack.RuntimeIdentity{GOOS: runtime.GOOS, GOARCH: runtime.GOARCH}, + RuntimeIdentity: launchpack.RuntimeIdentity{GOOS: runtime.GOOS, GOARCH: runtime.GOARCH}, Source: launchpack.SourceIdentity{ SelectedPath: spxModulePath, SelectedVersion: source.selectedVersion, EffectivePath: source.effectivePath, EffectiveVersion: source.effectiveVersion, @@ -89,8 +89,12 @@ func (cmd *CmdTool) runBuildLauncher() error { }, GoCommand: source.goCommand, WorkDir: source.workDir, GoWork: source.goWork, GraphFlags: source.graphFlags, BuildFlags: buildLauncherBuildFlags(cmd.Args), Output: stage, - BridgePackage: spxModulePath + "/cmd/ispxnative", VerifyGraph: source.verifyGraph, - IO: launchpack.IO{Stdin: os.Stdin, Stdout: os.Stdout, Stderr: os.Stderr, Env: source.env}, + VerifyGraph: source.verifyGraph, + IO: launchpack.IO{Stdin: os.Stdin, Stdout: os.Stdout, Stderr: os.Stderr, Env: source.env}, + } + if source.sourceMode { + config.RuntimeSourceRoot = source.root + config.BridgePackage = spxModulePath + "/cmd/ispxnative" } builder := cmd.launcherBuilder if builder == nil { diff --git a/cmd/spx/internal/command/buildlauncher_graph.go b/cmd/spx/internal/command/buildlauncher_graph.go index 9521ef29e..322fce560 100644 --- a/cmd/spx/internal/command/buildlauncher_graph.go +++ b/cmd/spx/internal/command/buildlauncher_graph.go @@ -23,6 +23,9 @@ import ( "os/exec" "path/filepath" "strings" + + gomodule "golang.org/x/mod/module" + "golang.org/x/mod/semver" ) type launcherSource struct { @@ -135,15 +138,21 @@ func resolveSPXSource(module listedModule) (root, selectedVersion, effectiveVers return "", "", "", false, fmt.Errorf("buildlauncher: Go graph selected %q for SPX, want %q", module.Path, spxModulePath) } sourceMode = module.Main || module.Replace != nil && module.Replace.Version == "" + if !sourceMode { + if module.Replace != nil { + return "", "", "", false, fmt.Errorf("buildlauncher: published SPX module must not use a versioned replacement") + } + if !semver.IsValid(module.Version) || semver.Canonical(module.Version) != module.Version || gomodule.IsPseudoVersion(module.Version) { + return "", "", "", false, fmt.Errorf("buildlauncher: published SPX requires an exact canonical release version, got %q", module.Version) + } + return "", module.Version, module.Version, false, nil + } effective := module if module.Replace != nil { effective = *module.Replace } if effective.Dir == "" { - if sourceMode { - return "", "", "", false, fmt.Errorf("buildlauncher: local SPX module has no source directory") - } - return "", module.Version, effective.Version, false, nil + return "", "", "", false, fmt.Errorf("buildlauncher: local SPX module has no source directory") } root, err = canonicalDirectory(effective.Dir) if err != nil { diff --git a/cmd/spx/internal/command/buildlauncher_graph_test.go b/cmd/spx/internal/command/buildlauncher_graph_test.go index 860296aac..77b3605ca 100644 --- a/cmd/spx/internal/command/buildlauncher_graph_test.go +++ b/cmd/spx/internal/command/buildlauncher_graph_test.go @@ -59,13 +59,31 @@ func TestResolveSPXSourceModes(t *testing.T) { if err != nil { t.Fatal(err) } - if gotRoot != wantRoot || selected != "v3.1.0" || mode != test.mode { + expectedRoot := "" + if test.mode { + expectedRoot = wantRoot + } + if gotRoot != expectedRoot || selected != "v3.1.0" || mode != test.mode { t.Fatalf("source = root %q, selected %q, mode %v", gotRoot, selected, mode) } }) } } +func TestResolveSPXSourceRejectsUnsupportedPublishedIdentities(t *testing.T) { + for _, module := range []listedModule{ + {Path: spxModulePath, Version: "v3.2.5-0.20260821120000-0123456789ab"}, + { + Path: spxModulePath, Version: "v3.2.4", + Replace: &listedModule{Path: spxModulePath, Version: "v3.2.3"}, + }, + } { + if _, _, _, _, err := resolveSPXSource(module); err == nil { + t.Fatalf("resolveSPXSource accepted %#v", module) + } + } +} + func TestResolveSPXSourceWithoutModuleDirectory(t *testing.T) { root, selected, effective, sourceMode, err := resolveSPXSource(listedModule{ Path: spxModulePath, Version: "v3.1.0", diff --git a/cmd/xgodriver/main.go b/cmd/xgodriver/main.go new file mode 100644 index 000000000..aad411552 --- /dev/null +++ b/cmd/xgodriver/main.go @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Command xgodriver implements the SPX side of XGo project driver v1. +package main + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/goplus/spx/v3/internal/xgodriver" + "github.com/goplus/spx/v3/x/xgolauncher" +) + +func main() { + status, err := xgolauncher.RunCommand(context.Background(), func(ctx context.Context) (xgolauncher.ProcessStatus, error) { + cfg, err := xgodriver.Parse(os.Args[1:]) + if err != nil { + return xgolauncher.ProcessStatus{Code: 2}, err + } + return xgodriver.Execute(ctx, cfg, xgodriver.IO{ + Stdin: os.Stdin, Stdout: os.Stdout, Stderr: os.Stderr, Env: os.Environ(), + }) + }) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, commandErrorMessage(err)) + if status.Success() { + status = xgolauncher.ProcessStatus{Code: 1} + } + } + xgolauncher.Exit(status) +} + +func commandErrorMessage(err error) string { + const prefix = "xgodriver: " + message := err.Error() + for strings.HasPrefix(message, prefix) { + message = strings.TrimPrefix(message, prefix) + } + return prefix + message +} diff --git a/cmd/xgodriver/main_test.go b/cmd/xgodriver/main_test.go new file mode 100644 index 000000000..c90a5bd7c --- /dev/null +++ b/cmd/xgodriver/main_test.go @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package main + +import ( + "errors" + "testing" +) + +func TestCommandErrorMessageAddsOnePrefix(t *testing.T) { + for _, test := range []struct { + name string + err error + want string + }{ + {name: "unprefixed", err: errors.New("failed"), want: "xgodriver: failed"}, + {name: "prefixed", err: errors.New("xgodriver: failed"), want: "xgodriver: failed"}, + {name: "repeated", err: errors.New("xgodriver: xgodriver: failed"), want: "xgodriver: failed"}, + } { + t.Run(test.name, func(t *testing.T) { + if got := commandErrorMessage(test.err); got != test.want { + t.Fatalf("commandErrorMessage() = %q, want %q", got, test.want) + } + }) + } +} diff --git a/docs/en/dev/engine/release.md b/docs/en/dev/engine/release.md index 9b45bf129..118c31678 100644 --- a/docs/en/dev/engine/release.md +++ b/docs/en/dev/engine/release.md @@ -65,7 +65,7 @@ Also complete at least these checks: - Regress live/offline capture, audio, SVG/complex fonts, and perform Android/iOS device smoke tests. - If Windows releases require ANGLE, ensure an ANGLE download failure fails the build instead of silently downgrading it. -## Runtime-aware CI and three-stage bootstrap +## Runtime-aware CI and runtime-to-driver-to-SPX bootstrap Ordinary CI resolves the locked runtime release before starting a runtime consumer. The resolver reads release metadata and the manifest only; it validates the exact asset-name set, lock, module tree, runtime-pack source digest, and build-recipe digest without downloading every runtime asset. @@ -83,19 +83,23 @@ Use a frozen release branch in `goplus/spx` for the bootstrap operations: | --- | --- | --- | | `dry-run` | Build and verify the exact locked candidate without publishing; report canonical-ref ancestry | Usually `all` | | `publish-runtime` | Publish only the immutable `runtime-v*` bundle | Ignored | -| `publish-release` | Publish runtime, SPX products, and npm | Must be `all` | +| `publish-release` | Advance runtime-pin, driver-pin, and final SPX/npm publication stages | Must be `all` | `release_tag` must exactly equal the SPX tag declared by the selected commit. Product `platforms=all` means Web, macOS, Windows, and Linux packages; Android and iOS belong to the complete runtime asset matrix and device smoke tests, not the SPX product targets. 1. Run `release_tag=`, `platforms=all`, and `operation=dry-run` against the exact locked Godot candidate. Check the workflow summary: a pre-merge candidate is allowed here but is explicitly marked as not publication-ready. Download and inspect every runtime/product artifact, then complete install and demo smoke tests. -2. Promote that exact Godot commit into the canonical `godot.ref` and run the strict ancestry verifier. Rerun the same SPX commit with `operation=publish-runtime`. If no reusable runtime exists, this mode builds, verifies, and publishes the complete runtime asset set while skipping SPX products, the SPX release, and npm. -3. Let ordinary CI automatically switch to the public-runtime path and pass the Web normal product smoke. Merge without changing any module/pack/recipe identity input, then run the final SPX commit with `platforms=all` and `operation=publish-release`. The workflow verifies and reuses that runtime before publishing SPX products and npm. +2. Promote that exact Godot commit into the canonical `godot.ref` and pass the strict ancestry verifier. Then run `operation=publish-release` from the frozen release branch. The workflow first publishes or reuses `runtime-v`. If the module has no runtime pin, it emits `spx-runtime-pin-` and stops at the handoff gate. Run `make release-pin` from the clean frozen branch; the command detects the missing runtime pin, downloads the public release, verifies its exact tag and current runtime-build identity, and writes only `internal/release/runtime_manifest_pins/.json`. Review and commit that file as the only change, then run the same operation from the new commit. The CI artifact remains an equivalent fallback when local generation is unavailable. +3. When the runtime pin exists and the driver pin is missing, the same `publish-release` automatically calls the reusable driver workflow. Four native hosts reuse the public Engine/PCK, build the bridge from the current SPX producer commit, publish `driver-v`, and emit `spx-driver-pin-`. If that immutable release already exists, the workflow verifies it and regenerates the pin instead of rebuilding or overwriting it. `release_driver.yml` is no longer a manual publication entry point. +4. Run `make release-pin` again from the exact producer commit. It now detects the missing driver pin, verifies the public manifest, all four platform ZIPs, and the producer SHA, and writes only `internal/driverbundle/pins/.json`. Commit that file as the only change, then run the same `publish-release` again. The final SPX commit must descend from the driver producer; `driver-verify --verify-lineage` rejects every change other than that pin. +5. Once both pins exist, the workflow downloads and verifies the public driver release before building and publishing SPX products and npm. An SPX-only upgrade that reuses an existing runtime starts directly at the driver handoff and does not rebuild Godot Engine. -The runtime manifest, `SHA256SUMS`, and the lock's required asset set must match exactly. A public tag with different provenance or assets fails rather than being overwritten. An unpublished runtime/SPX draft tag must target the current `GITHUB_SHA`; a public runtime may target the candidate commit from the previous stage, but only an identical full reuse contract allows the final SPX commit to consume it. The SPX tag always targets the final commit. If the merge changes any runtime identity input, the final run rejects reuse; freeze again and bump `runtime_version` instead. +The handoff gate intentionally fails with `make release-pin`, the artifact name, and the destination path so “pin generated” cannot be mistaken for “SPX published.” The local command requires a clean worktree, freezes the current HEAD, and rejects drafts, wrong producers, unexpected assets, symlinks, and concurrent changes. It never commits, pushes, or dispatches a workflow. After committing a pin, start a new run from the new commit rather than rerunning the old SHA. + +The runtime manifest, `SHA256SUMS`, and the lock's required asset set must match exactly. A public tag with different provenance or assets fails rather than being overwritten. Runtime, driver, and SPX draft releases must target the current `GITHUB_SHA`; an existing public driver release cannot be replaced. The SPX tag always targets the final pin commit. If the merge changes any runtime identity input, the final run rejects reuse; freeze again and bump `runtime_version` instead. ## Development npm package -`publish-dev-npm` is an independent on-demand operation, not a fourth stage of the production release. It is restricted to the canonical `goplus/spx` `dev` branch; leave `release_tag` empty and note that `platforms` is ignored: +`publish-dev-npm` is an independent on-demand operation, outside the production release flow. It is restricted to the canonical `goplus/spx` `dev` branch; leave `release_tag` empty and note that `platforms` is ignored: ```sh gh workflow run release.yml \ diff --git a/docs/en/dev/engine/xgo-project-driver-proposal-issue.md b/docs/en/dev/engine/xgo-project-driver-proposal-issue.md new file mode 100644 index 000000000..fb28e26b1 --- /dev/null +++ b/docs/en/dev/engine/xgo-project-driver-proposal-issue.md @@ -0,0 +1,324 @@ +# [Proposal] XGo Project Driver v1 and SPX Runtime Integration + +> Status: source mode and the published bundle path are implemented; each SPX version remains disabled until its driver assets and embedded pin pass release validation +> +> Scope: `goplus/mod`, `goplus/xgo`, `goplus/spx` + +## Summary + +This proposal introduces a framework-owned, project-scoped, versioned project driver for XGo class projects. The framework declares the driver in its own `gox.mod`; XGo locates and builds it from the application's effective Go module/workspace graph, then delegates `run` or a transactional `build` to the driver. `install` reuses `build`. + +XGo implements only generic discovery, identity validation, process management, and output transactions. It contains no SPX, Godot, Engine, PCK, or resource-format special cases. The SPX driver owns interpreted execution, runtime assets, project packaging, and the self-contained launcher. + +The current implementation covers source mode for the main module, workspace modules, and local replacements. Published mode uses an immutable combined driver bundle v1: the canonical SPX module pins `driver-manifest.json`, and each host ZIP contains exactly the Engine, PCK, and interpreter bridge. This ZIP is an independent driver release asset, not an existing standalone Engine/PCK runtime ZIP. The bundle is published under `driver-v` and checked by size and SHA-256; its identity and URLs are not derived from `runtime_version`. + +## Problem and goals + +SPX is not an ordinary "generate Go, then execute" runtime: it requires a matching Engine Runtime, PCK, interpreter bridge, project resource directory, and an isolated run session. Encoding these rules in XGo would make the generic toolchain depend on a particular framework, and would not guarantee that the SPX module, bridge, and Engine ABI come from a consistent version. + +This design provides the following user-facing commands: + +```sh +xgo run ./game --headless +xgo build -o ./bin/game ./game +xgo install ./game +``` + +It also enforces these constraints: + +- XGo is unaware of SPX/Godot implementation details; +- the project driver and class metadata come from the same effective Go graph; +- `xgo run` does not generate `xgo_autogen.go` or write `.temp`, `.godot`, or build intermediates into the project; +- `xgo build` produces a single-file program that runs offline on the host platform; +- projects without a declared driver continue entirely through the existing GenGo path; +- once a driver matches, every subsequent error is terminal and must not silently fall back to GenGo. + +## Design principles + +1. **Framework owns policy**: the framework decides how to run and package its projects; XGo provides only the lifecycle. +2. **One graph, one identity**: metadata and the driver package come from the same effective module/workspace graph; source mode also builds its bridge from that graph, while published mode uses the module-pinned bundle. +3. **Fail closed after match**: the legacy path is allowed only when driver discovery explicitly reports no match. +4. **Immutable inputs**: critical metadata, release manifests, bundles, and outputs are bound to content digests or file identities. +5. **Project remains read-only**: runtime state is kept strictly separate from user source and resources. +6. **Content-addressed reuse**: identical Engine/bridge bundles are reused across projects, with every cache hit verified. + +## Overall architecture + +```text +application go.mod / go.work + | + | effective Go graph + //xgo:class + v +framework gox.mod ---- driver v1 + | + v +XGo resolver + - resolve target + - resolve class metadata and provenance + - validate the declaring module's XGo requirement + - build driver + | + | driver protocol v1 + v +SPX driver + - validate request and live file identities + - acquire source runtime or the published driver bundle + - build the bridge in source mode + - run: create an isolated session, then interpret + - build: generate a launcher with the complete payload embedded +``` + +The responsibility boundary across the three repositories is: + +| Component | Responsibility | +| --- | --- | +| `goplus/mod` | `driver` metadata, resolved module provenance, shared typed request, and argv codec | +| `goplus/xgo` | graph/target resolution, driver discovery and build, protocol invocation, signal forwarding, and build/install output transactions | +| `goplus/spx` | SPX protocol adapter, Engine/bridge/project bundles, interpretation, caching, and self-contained launcher | + +The shared layer defines only driver-neutral data. It does not provide a driver lifecycle SDK or contain SPX domain rules. + +## Metadata and version boundaries + +SPX uses the following declaration form: + +```text +xgo 1.8.0 + +project main.spx Game github.com/goplus/spx/v3 math +driver v1 github.com/goplus/spx/v3/cmd/xgodriver + +class -embed *.spx SpriteImpl +pack assets index.json +``` + +`driver` applies to the nearest preceding `project`; each project may declare at most one. The syntax must be: + +```text +driver +``` + +- `protocol` currently supports only `v1`; +- the driver must be a valid Go import path; relative paths, absolute paths, and `path@version` are not allowed; +- a known but malformed `driver` directive is an error in both strict and lax parsing; +- the driver module version is not written to `gox.mod`; it is determined uniquely by the application's effective Go graph. + +The five version and capability dimensions are independent and are combined only for compatibility checks: + +| Identity | Meaning | Source of truth | +| --- | --- | --- | +| Driver protocol `v1` | Version of the invocation contract between XGo and the driver | `driver` directive | +| Project-driver v1 capability baseline | Earliest XGo version that understands and dispatches `driver v1`, currently `1.8.0` | XGo protocol implementation and compatibility policy | +| Declaring module's XGo requirement | Minimum XGo version needed by that framework release for its metadata or tool features | `xgo` directive in the declaring `gox.mod`/`gop.mod` | +| SPX version/source | Code identity of the driver and bridge | effective Go graph in source mode; module-pinned `driver-manifest.json` in published mode | +| Engine Runtime/ABI | Engine, PCK, and interface compatibility | SPX runtime lock and release manifest | + +The `driver v1` and `xgo` directives do not redefine each other. If a later SPX release raises `xgo` to `1.9.0`, only that SPX release requires XGo 1.9.0 features. The v1 protocol baseline remains 1.8.0, while the effective minimum for that SPX project is `max(1.8.0, 1.9.0) = 1.9.0`; this must not be described as “driver v1 requires XGo 1.9.0.” + +Therefore, an XGo `1.7.5` recorded in the runtime lock only identifies the toolchain used to build that Engine Runtime; it does not mean that XGo `1.7.5` supports project drivers. + +## Effective graph and source identity + +XGo uses the standard Go toolchain to obtain the effective build list and stores logical selection separately from replacement source: + +- `Selected` is the module path/version selected by MVS; +- `Replace` is the source actually read; +- `Effective` is the final source identity used to read metadata and build the driver. + +The same supported graph policy must flow through metadata discovery, driver validation, and driver build, including `GOWORK`, `-mod`, and `-modfile`. Dependencies must not be calculated by hand from the original `go.mod`, and the driver must not independently resolve a different graph. If the caller's `GOFLAGS`/`GOWORK` cannot produce one trustworthy graph policy, discovery fails before classification; it must not construct a substitute graph or fall back to the legacy path. Project-driver v1 does not define an overlay-aware project snapshot, so `-overlay` is rejected only after a target is confirmed as driver-backed; ordinary projects retain their legacy behavior. + +The order and identity of class modules come from the `//xgo:class` markers in the application's effective modfile. Only the main or workspace module containing the target, or a class dependency explicitly marked by the application, can provide a driver. + +Resolved metadata carries all of the following: + +- the module provenance that declared the project/driver; +- the canonical path and SHA-256 of the declaring `gox.mod`/`gop.mod`; +- the declaring module's XGo version requirement; +- the path and content digest of the target modfile. + +When importing resolved class metadata, XGo validates the target modfile snapshot. Before execution, the driver revalidates the declaration, module source, and every other path it actually uses, but does not reinterpret the metadata. This prevents metadata and the driver from coming from different versions, and prevents critical files from being replaced after discovery. + +Vendor mode currently cannot provide equivalent module provenance. When the active module has no external class markers, XGo can still classify the target from that module's own metadata: a non-driver target continues through the legacy path, while a driver match fails explicitly as unsupported. If the effective modfile contains an external class marker, v1 fails closed before classification because standard Go vendor data may omit the dependency's `gox.mod`/`gop.mod`; consulting a live replacement would violate vendor snapshot identity. Preserving legacy behavior for those graphs requires a future XGo-owned vendor manifest with complete metadata identities and digests. + +## Target discovery and dispatch + +Driver discovery occurs after target resolution and before any Dir/PkgPath/Files branch or GenGo invocation. + +Once a target is confirmed as driver-backed, the driver owns any driver-specific code generation in a private isolated work directory; v1 defines no XGo pre-generated artifact or generated-output handoff protocol. + +| Target | v1 behavior | +| --- | --- | +| directory | Supported; scan for a top-level project file | +| single project file | Supported; it must be the only project file in its directory | +| import/package path | Supported; locate it from the caller's effective graph | +| multi-file target | Rejected for driver-backed projects | +| `...` pattern | Rejected for driver-backed projects | +| `pkg@version` | Legacy targets remain unhandled; driver matches are rejected after classification; versions can come only from the current graph | + +A directory must correspond to exactly one project file. If no class project exists, or if the project does not declare a driver, discovery returns `NotHandled`; only that result allows XGo to call the legacy implementation. Any other graph, metadata, protocol, driver-build, or driver-execution error is returned directly to the user. + +`run` preserves application argument element boundaries and ordering. When needed, the first `--` after the target acts only as XGo's source/argument separator and is removed by XGo; everything after it is passed to the application unchanged. + +`install` reuses `build` semantics and installs into the effective `GOBIN`; when `GOBIN` is empty, it uses `bin` under the first `GOPATH` entry. Driver v1 accepts only one install target at a time. + +## Driver protocol v1 + +The protocol uses a shared typed request encoded as deterministic argv; it does not use a JSON request file or consume `stdin`. `stdin`, `stdout`, and `stderr` are inherited directly, so interactive and pipeline behavior matches an ordinary command. + +The request contains five groups of information: + +1. action: `run` or `build`; +2. project snapshot: directory, project file, module root, extension, and optional pack metadata; +3. driver identity: package, selected/replacement provenance, declaration path, and digest; +4. graph/build policy: Go command, work directory, workspace, and allowed flags; +5. action payload: application arguments for `run`, or staging/final output for `build`. + +The codec consistently rejects unknown, repeated, missing, incomplete option groups, or action-inapplicable fields. The shared layer performs structural path validation; the driver binds paths to real file identities. If the total protocol argv and environment exceed the platform safety budget, startup fails before the driver is launched. + +XGo currently passes only these policies to the project driver: + +| Type | Supported range | +| --- | --- | +| Graph | `-mod=mod|readonly`, `-modfile`; `-overlay` is used only to produce an explicit unsupported error after a driver match; `-mod=vendor` permits conservative discovery from the active module only and rejects driver matches or indeterminate external class metadata | +| Build | `-v`, `-x`, `-work`, `-trimpath=true`, `-buildvcs=false` | + +Other flags are reported only after the target has been confirmed as driver-backed, so ordinary projects retain their existing behavior. + +## Driver build and process boundaries + +Before building the driver, XGo verifies that: + +- the package is `main`; +- the package is inside the effective module that declared the driver; +- the package's selected/replacement/source identity exactly matches the metadata; +- the current XGo satisfies the declaring module's `xgo` version requirement; +- `GOOS/GOARCH` match the host, so the driver cannot be used for cross-compilation. + +The driver is built in a private temporary directory under the same graph policy, while preserving the caller's CGO selection. The driver inherits the standard streams. Each process has one command boundary that owns host signals; inner supervisors consume cancellation, including the original signal as its cause, and clean up the entire child process tree without subscribing to the same signals again. Once cancellation has been observed, a child that exits successfully during shutdown does not turn the request into success. On Unix, the normal exit code or original signal is preserved. On Windows, a Job Object manages the process tree and interrupts are represented as exit codes. Project-driver v1 rejects every nested driver dispatch, including dispatch to a different driver. + +`XGO_DRIVER=off` is an explicit disable switch, but its semantics are "report an error and stop", not "fall back to GenGo". + +## SPX source mode + +The SPX driver currently accepts these source identities: + +- the application itself is the SPX main module; +- SPX is in the current workspace; +- SPX is introduced through an unversioned local replacement. + +These identities always use source mode. A main/workspace module or an unversioned local replacement does not switch to published mode merely because the selected module has a version. + +The portable driver snapshot contains only project-rooted files. It therefore rejects legacy `extasset` configuration explicitly. This restriction is scoped to the project-driver path; existing SPX run, native, export, and pack commands retain their legacy external-asset behavior. + +The `.config` contract is bound to the bytes actually consumed. The driver snapshots its absence or presence and SHA-256, revalidates the original path before handoff, and then supplies run/build only the captured bytes instead of reopening the project copy. + +In source mode, each request builds the interpreter bridge from that effective source with host `CGO_ENABLED=1`, and verifies that the build output still comes from the same module identity. Go build cache reuse is allowed, but the bridge file itself is not persistently cached. + +Published mode never builds or borrows a bridge from the effective graph. It accepts only the canonical module `github.com/goplus/spx/v3` at an exact canonical release version with no replacement. Pseudo-versions, versioned replacements, and foreign module paths fail closed. The driver reads that module's pinned `driver-manifest.json`, derives the immutable `driver-v` release URL, and downloads the independent driver-release host ZIP, not an existing standalone runtime ZIP. The manifest and ZIP are strictly validated for schema, derived release identity, host, entry names, sizes, and SHA-256; each host ZIP must contain exactly Engine, PCK, and bridge, with missing, extra, duplicate, or mismatched entries rejected. A verified ZIP is reused through the content-addressed cache; offline mode succeeds only on a complete verified cache hit. + +Runtime inputs are selected by mode: + +1. source mode: an explicit local override wins; otherwise the pinned `runtime-v` release is tried first, and an exact-version local source/GOPATH runtime is used only when that release is unavailable; +2. published mode: the module-pinned `driver-manifest.json` selects the immutable `driver-v` host ZIP containing all three components. It does not select a driver bundle by `runtime_version` or perform a separate Engine/PCK lookup. + +Changing unreleased SPX source does not create a driver-release dependency. Main/workspace/local-replace projects remain in source mode, so an unchanged `runtime_version` keeps reusing the same published Engine/PCK while the bridge is rebuilt from the current source. Only an exact published module version requires its driver manifest and host bundle; release CI must prevent that module tag from being published first. + +Here “source mode” means only main/workspace/local replace. An external demo with `require github.com/goplus/spx/v3 vX.Y.Z //xgo:class` remains in published mode even after Go has downloaded its source into the module cache, and therefore uses `driver-vX.Y.Z`. The module cache is not a mutable source workspace or an implicit bridge-build fallback. + +Every selected manifest is pinned or otherwise bound to its declared source. A missing pin, malformed manifest, size mismatch, digest mismatch, host mismatch, or source mismatch fails closed before any runtime asset is used. + +`$GOPATH/bin` is not used to satisfy published mode. A file name, existence check, or file size does not establish runtime identity. When local artifacts are absent, a clean source checkout can download and verify the published Engine/PCK without running an install workflow first; published mode downloads and verifies its combined driver ZIP. Offline mode permits only a complete cache hit whose verification succeeds. + +SPX interpretation uses three independent roots: + +| Root | Purpose | +| --- | --- | +| `ProjectDir` | User source and project-level references, read-only | +| `AssetDir` | Project resource root selected by `pack`, read-only | +| `SessionDir` | Engine cwd, temporary configuration, and runtime state; disposable | + +The driver controls the `--path` value for `SessionDir`; users cannot override it. Each run creates a new session, prepares the bridge/Engine configuration, and then starts the Engine. The project directory must remain unchanged on both success and failure paths. + +## Self-contained build + +`xgo build` generates a Go launcher and embeds the complete payload with `go:embed` before linking. The payload contains: + +- the Engine executable and PCK; +- the source-mode bridge built from the current graph, or the verified bridge from the published driver bundle; +- the canonical project bundle; +- SPX/source identity, host platform, runtime/ABI, component digests, and the complete entry table. + +The project bundle uses an allowlist rather than traversing the entire repository: it collects only top-level project source files, optional `.config`, the complete pack directory, and files explicitly referenced by the resource index that remain inside `ProjectDir`. Symlinks, special files, path escapes, oversized inputs, and case/Unicode collisions are rejected. Fixed ordering, timestamps, permissions, and compression policy ensure that identical inputs produce the same project bundle digest. + +The generated launcher depends on SPX's public launcher package because generated code is compiled in the user's module graph and cannot import an SPX `internal` package. The launcher itself only validates the payload, materializes components, creates a session, starts the Engine, and reproduces its exit status. + +The Darwin payload is finalized before linking; the linked executable is then ad-hoc signed and is not appended to or modified afterward. This signing guarantees Mach-O integrity, not Developer ID signing or notarization. + +The driver may write only to the private staging path allocated by XGo. On success, XGo verifies that the artifact is a non-empty, non-symlink host executable, then commits the final output with a same-filesystem replacement. Before that commit point, driver failure leaves an existing target unchanged. Atomic visibility and replacement of an existing target are guaranteed only to the extent provided by the host platform and filesystem; real Windows replacement and crash/recovery behavior remains a host-CI requirement. `install` uses the same transaction and changes only the final directory. + +The built launcher does not require Go, XGo, SPX, or a network connection. It first validates the payload and host platform, then materializes the embedded Engine, bridge, and project, and finally runs them in a new session. + +## Caching and reuse + +The component materialization cache is addressed by `namespace + full digest`; driver, Engine, bridge, and project use separate namespaces: + +Published acquisition first verifies and caches the complete combined ZIP in the driver namespace. Only after that verification may launcher execution materialize and reuse Engine, bridge, and project by component digest; the component cache never bypasses the bundle trust boundary. + +- different projects using the same runtime reuse the same Engine; +- different launchers can reuse the same bridge or project bundle at execution time; +- different content is never shared even when file names are identical; +- source mode produces a fresh temporary driver binary and bridge each time, while compilation naturally reuses the Go build cache; published mode reuses only verified bundle components. + +Downloaded files and materialized directories are revalidated on every cache hit against the manifest, type, size, and SHA-256. Tampering that preserves file size is rejected; a damaged entry is repaired under an exclusive lock. First materialization uses a sibling temporary path, complete verification, and a same-filesystem rename. Atomic publication is relied on only within the guarantees of the host platform and filesystem; real Windows publish/repair and crash-recovery scenarios remain host-CI requirements. Shared/exclusive leases prevent concurrent processes from observing partial state or deleting an entry that is in use. + +All launcher resources come from the embedded payload, so the first run does not download anything even when the cache is empty. Automatic quota reclamation is not currently implemented; any future GC must continue to honor leases and must not delete components in use by a running process. + +## Trust and failure model + +The following inputs are all untrusted: module/workspace metadata, driver argv, environment variables, release manifests, ZIP/payload data, project resource indexes, cache contents, and existing output paths. + +All boundaries follow these rules: + +- validate canonical paths together with real file identities, rather than comparing strings only; +- parse driver requests and all manifest formats strictly, rejecting unknown or repeated fields; +- reject ZIP files containing absolute paths, `..`, backslash traversal, duplicates/collisions, symlinks, device files, or compression bombs; +- verify file identity before and after reading; consume critical large files through an already-open handle; +- terminate on any identity, digest, runtime, ABI, platform, or source mismatch; +- manage driver, Engine, and launcher child processes under a supervisor; cancellation must not leave child processes behind; +- never fall back from the driver path to native/GenGo. + +## Current boundaries + +- XGo `1.8.0` is the project-driver capability baseline; earlier versions are unsupported and cannot rely on the old parser to provide a reliable upgrade hint; +- host desktop only: Darwin amd64/arm64, Linux amd64, and Windows amd64; +- `xgo test`, Web, Android, iOS, and `GOOS/GOARCH` cross-compilation are unsupported; +- vendor mode, overlays, multiple driver-backed targets, and arbitrary Go build flags are unsupported; +- SPX requires a separate project pack directory; +- published mode accepts only the canonical SPX module at an exact release version with a valid pinned `driver-manifest.json` and immutable `driver-v` host bundle; missing or invalid release inputs fail closed. The current `v3.2.4` module has no generated driver pin yet, so implementation does not enable published mode until that pin and release artifact exist; +- the launcher contains project source and resources and does not provide source confidentiality; +- launcher size is dominated by Engine/PCK/bridge, and the complete executable is not guaranteed to be bit-for-bit reproducible; +- XGo's public `tool.RunDir/BuildDir/InstallDir` APIs retain their existing semantics; driver dispatch is currently a CLI capability. + +## Release order + +`goplus/mod` metadata/provenance/codec and XGo project-driver support are prerequisites. The referenced runtime release and its module pin must already exist. SPX publication then has two stages so the driver bundle is not coupled to `runtime_version`: + +1. **Driver bundle stage**: build one ZIP per supported host from the exact SPX release source. Each ZIP must contain exactly Engine, PCK, and bridge. Publish the ZIPs under the immutable `driver-v` tag and freeze their names, sizes, and SHA-256 values. +2. **Module stage**: commit the module pin for that exact manifest and its host digests, then publish the canonical SPX module at its exact release tag/version. Before release, validate exact-module download, strict manifest/ZIP verification, cache/offline behavior, source mode, and the self-contained launcher. + +The unified `publish-release` state machine runs the driver-bundle stage and emits the pin handoff automatically; driver publication is not dispatched separately. On the clean frozen branch, the operator runs `make release-pin`: it detects whether the runtime or driver pin is missing, downloads and strictly verifies the canonical public release, and writes exactly one pin. Runtime reuse is bound to the current build identity; driver pinning additionally requires the exact producer commit. After review, commit that file and continue from the new commit. The CI artifact is only an equivalent fallback when the local command is unavailable. + +The driver bundle URL and identity come only from the pinned manifest and `driver-v`, never from `runtime_version`. Until a module has a valid pin and available immutable artifacts, published mode must fail explicitly; it must not borrow a local bridge or infer compatibility from runtime file names. + +## Acceptance criteria + +- run/build/install behavior for ordinary XGo projects remains unchanged; +- directory, single-file, and package targets for driver-backed projects do not execute GenGo after discovery; +- metadata, driver, and bridge for workspace and local-replacement projects always come from the same effective graph; +- a clean SPX checkout can run and build without preinstalled resources in `$GOPATH/bin`; +- release validation must cover published-bundle cache miss/hit, offline, concurrency, kill-recovery, and same-size tampering; Windows host CI is a prerequisite for real publish/replace and crash-recovery coverage; +- a canonical released SPX module downloads its pinned `driver-manifest.json` and exact host ZIP, and rejects pseudo-versions, versioned replacements, foreign modules, malformed manifests, and ZIPs other than exactly Engine/PCK/bridge; +- run fully preserves argv, stdin/stdout/stderr, and platform exit semantics; Unix signals are reproduced, Windows interrupts return 130, and the project is not modified; +- build/install failures do not corrupt an existing output; +- the self-contained launcher embeds Engine, PCK, bridge, and project, and completes its first run with an empty cache, no toolchain, and no network; +- real Darwin, Linux, and Windows host artifacts pass platform smoke tests before publication. diff --git a/docs/zh/dev/engine/release.md b/docs/zh/dev/engine/release.md index 048a7f2ba..afdf676e5 100644 --- a/docs/zh/dev/engine/release.md +++ b/docs/zh/dev/engine/release.md @@ -65,7 +65,7 @@ git diff --check - 录屏 live/offline、音频、SVG/复杂字体回归;Android/iOS 真机 smoke。 - Windows 发布要求 ANGLE 时,确认 ANGLE 下载失败会使构建失败,而不是静默降级。 -## runtime 感知的 CI 与三阶段自举 +## runtime 感知的 CI 与 runtime→driver→SPX 发布自举 普通 CI 会在启动 runtime consumer 前解析 lock 对应的 runtime release。resolver 只读取 release metadata 与 manifest,不下载全部 runtime 资产;它会校验精确的资产名集合、lock、module tree、runtime-pack source digest 与 build-recipe digest。 @@ -77,25 +77,29 @@ git diff --check canonical-ref ancestry 规则刻意只用于 release。普通 runner 与 module-integration workflow 可以测试 lock 中精确的 candidate SHA,不要求它已经进入 `godot.ref`。不存在可复用的公开 runtime 时,release setup 会调用共享 verifier:只有 verifier 已确认 exact commit 可从 canonical repo 获取、canonical ref tip 是该 commit 的祖先且反向尚不成立时,`dry-run` 才能以 pre-merge candidate 继续,并在 workflow summary 明确标记 candidate-only;两种 publish 操作都必须先证明 ancestry 才会开始构建。ref 查询失败、ref 歧义、网络异常或比较失败会阻断所有 release 操作,不能伪装成 candidate 结果。新 runtime 构建结束后,publish job 还会在发布前立即复验。如果 resolver 已完整校验不可变的公开 runtime,summary 会标记 source ancestry 无需检查,release 不再依赖历史 ref。 -三阶段自举仍在 `goplus/spx` 的冻结发布分支上执行: +正式自举仍在 `goplus/spx` 的冻结发布分支上执行: | `operation` | 结果 | `platforms` | | --- | --- | --- | | `dry-run` | 构建并校验精确的锁定 candidate,但不发布;报告 canonical-ref ancestry | 通常为 `all` | | `publish-runtime` | 只发布不可变的 `runtime-v*` 资产 | 忽略 | -| `publish-release` | 发布 runtime、SPX 产品与 npm | 必须为 `all` | +| `publish-release` | 依次推进 runtime pin、driver pin 和最终 SPX/npm 发布 | 必须为 `all` | `release_tag` 必须精确等于所选 commit 声明的 SPX tag。这里的产品 `platforms=all` 仅指 Web、macOS、Windows、Linux 包;Android/iOS 属于完整 runtime 资产矩阵和真机 smoke,不是 SPX 产品 target。 1. 先对 lock 中精确的 Godot candidate 使用 `release_tag=<当前声明的-SPX-tag>`、`platforms=all`、`operation=dry-run`。检查 workflow summary:pre-merge candidate 在此阶段可以继续,但会明确标记为尚不可发布。下载并检查所有 runtime/product artifacts,完成安装与 demo smoke。 -2. 将该 Godot commit 原样提升到 canonical `godot.ref` 并通过严格 ancestry verifier,再对同一个 SPX commit 设置 `operation=publish-runtime`。没有可复用版本时,该模式会构建、校验并公开全部 runtime 资产,但跳过 SPX 产品包、SPX release 和 npm。 -3. 让普通 CI 自动切换到已公开 runtime 路径,并通过 Web normal 产品 smoke;合并时不得改变 module/pack/recipe 身份输入。随后在最终 SPX commit 上使用 `platforms=all`、`operation=publish-release`,流程会验证并复用同一 runtime,再发布 SPX 产品与 npm。 +2. 将该 Godot commit 原样提升到 canonical `godot.ref` 并通过严格 ancestry verifier。随后从冻结发布分支运行 `operation=publish-release`;流程会先发布或复用 `runtime-v`。若 module 尚无 runtime pin,流程生成 `spx-runtime-pin-` artifact 后在 handoff gate 停止。在干净的冻结发布分支运行 `make release-pin`;命令会识别缺失的 runtime pin,下载公开 release,校验其精确 tag 与当前 runtime 构建身份,然后只生成 `internal/release/runtime_manifest_pins/.json`。审查后将该文件作为唯一改动提交,再运行同一操作。CI artifact 可作为无法本地生成时的等价备选。 +3. runtime pin 已存在而 driver pin 缺失时,同一 `publish-release` 自动调用 reusable driver workflow:四个原生 host 复用公开 Engine/PCK、从当前 SPX producer commit 构建 bridge,并发布 `driver-v` 以及 `spx-driver-pin-` artifact。若不可变 release 已存在,workflow 会校验它并重新生成 pin,不会重建或覆盖。`release_driver.yml` 不再是人工发布入口。 +4. 再次从精确 producer commit 运行 `make release-pin`;命令此时会识别缺失的 driver pin,校验公开 manifest、四个平台 ZIP 与 producer SHA,并只生成 `internal/driverbundle/pins/.json`。将其作为唯一改动提交,再运行同一 `publish-release`。最终 SPX commit 必须从 driver producer commit 派生;`driver-verify --verify-lineage` 会拒绝 pin 之外的改动。 +5. runtime 与 driver pin 都存在时,流程下载并校验公开 driver release,随后才构建并发布 SPX 产品与 npm。只升级 SPX、复用已有 runtime 时会直接从 driver handoff 开始,不重新构建 Godot Engine。 -runtime manifest、`SHA256SUMS` 和 lock 的 required asset 集合必须完全一致;已公开 tag 的来源或资产不同会直接失败,不能覆盖。未公开的 runtime/SPX draft tag 必须指向当前 `GITHUB_SHA`;已公开 runtime 可来自前一阶段的 candidate commit,但只有完整复用契约一致时才能用于最终 SPX commit。SPX tag 始终指向最终 commit。如果合并修改了任一 runtime 身份输入,最终运行会拒绝复用,此时必须重新冻结并提升 `runtime_version`。 +handoff gate 会有意失败并给出 `make release-pin`、artifact 与目标路径,避免把“已生成 pin”误报成“SPX 已发布”。本地命令要求干净 worktree,固定当前 HEAD,拒绝 draft、错误 producer、异常资产集合、symlink 以及并发变化;它不会 commit、push 或触发 workflow。提交 pin 后必须从新 commit 重新运行,不能 rerun 旧 SHA。 + +runtime manifest、`SHA256SUMS` 和 lock 的 required asset 集合必须完全一致;已公开 tag 的来源或资产不同会直接失败,不能覆盖。runtime、driver、SPX draft tag 都必须指向当前 `GITHUB_SHA`;已公开 driver release 不能替换。SPX tag 始终指向最终 pin commit。如果合并修改了任一 runtime 身份输入,最终运行会拒绝复用,此时必须重新冻结并提升 `runtime_version`。 ## 开发版 npm 包 -`publish-dev-npm` 是独立的按需操作,不属于上述三阶段正式发版。它只允许从 canonical `goplus/spx` 的 `dev` 分支触发;`release_tag` 必须留空,`platforms` 会被忽略: +`publish-dev-npm` 是独立的按需操作,不属于正式发布流程。它只允许从 canonical `goplus/spx` 的 `dev` 分支触发;`release_tag` 必须留空,`platforms` 会被忽略: ```sh gh workflow run release.yml \ diff --git a/docs/zh/dev/engine/xgo-project-driver-proposal-issue.md b/docs/zh/dev/engine/xgo-project-driver-proposal-issue.md new file mode 100644 index 000000000..a886b226f --- /dev/null +++ b/docs/zh/dev/engine/xgo-project-driver-proposal-issue.md @@ -0,0 +1,324 @@ +# [Proposal] XGo Project Driver v1 与 SPX 运行时集成 + +> 状态:source mode 与 published bundle 路径已实现;每个 SPX 版本只有在 driver 资产和内置 pin 通过发布校验后才启用 +> +> 范围:`goplus/mod`、`goplus/xgo`、`goplus/spx` + +## 摘要 + +为 XGo class project 引入 framework-owned、project-scoped、版本化的 project driver。framework 在自己的 `gox.mod` 中声明 driver;XGo 根据应用的有效 Go module/workspace graph 找到并构建它,然后把 `run` 或 transactional `build` 委托给 driver;`install` 复用 `build`。 + +XGo 只实现通用的发现、身份校验、进程管理和输出事务,不包含 SPX、Godot、Engine、PCK 或资源格式特判。SPX driver 负责解释执行、运行时资源、项目打包和自包含 launcher。 + +当前实现完成了 main module、workspace module 和 local replace 的 source mode。published mode 采用不可变的 combined driver bundle v1:canonical SPX module 在模块内 pin `driver-manifest.json`,每个 host ZIP 恰好包含 Engine、PCK 和 interpreter bridge。该 ZIP 是独立的 driver release artifact,不是现有 standalone Engine/PCK runtime ZIP。bundle 以 `driver-v` 发布,并校验大小与 SHA-256;其身份和 URL 不从 `runtime_version` 推导。 + +## 问题与目标 + +SPX 的运行模型不是普通的“生成 Go 再执行”:它需要匹配的 Engine Runtime、PCK、解释器 bridge、项目资源目录以及隔离的运行 session。把这些规则写入 XGo 会让通用工具链依赖具体 framework,也无法保证 SPX module、bridge 和 Engine ABI 来自一致版本。 + +本设计提供以下用户能力: + +```bash +xgo run ./game --headless +xgo build -o ./bin/game ./game +xgo install ./game +``` + +并满足以下约束: + +- XGo 不感知任何 SPX/Godot 实现细节; +- project driver 与 class metadata 来自同一份有效 Go graph; +- `xgo run` 不生成 `xgo_autogen.go`,也不向项目写入 `.temp`、`.godot` 或构建中间物; +- `xgo build` 生成可离线运行的 host 平台单文件程序; +- 未声明 driver 的项目完全沿用现有 GenGo 路径; +- 一旦匹配 driver,后续错误必须终止,不得静默回退。 + +## 设计原则 + +1. **Framework owns policy**:framework 决定如何运行和打包自己的项目,XGo 只提供生命周期。 +2. **One graph, one identity**:metadata 与 driver package 来自同一有效 module/workspace graph;source mode 的 bridge 也从该 graph 构建,published mode 则使用模块内 pin 的 bundle。 +3. **Fail closed after match**:只有明确未匹配 driver 时才允许进入旧路径。 +4. **Immutable inputs**:关键 metadata、发布 manifest、bundle 和输出都绑定内容摘要或文件身份。 +5. **Project remains read-only**:运行状态与用户源码、资源严格分离。 +6. **Content-addressed reuse**:跨项目复用相同 Engine/bridge bundle,同时验证每次 cache hit。 + +## 总体架构 + +```text +应用 go.mod / go.work + | + | 有效 Go graph + //xgo:class + v +framework gox.mod ---- driver v1 + | + v +XGo resolver + - 解析 target + - 解析 class metadata 与来源 + - 校验声明模块的 XGo 版本要求 + - 构建 driver + | + | driver protocol v1 + v +SPX driver + - 校验请求与实时文件身份 + - 获取 source runtime 或 published driver bundle + - source mode 构建 bridge + - run: 创建隔离 session 后解释执行 + - build: 生成内嵌完整 payload 的 launcher +``` + +三仓职责边界如下: + +| 组件 | 职责 | +| --- | --- | +| `goplus/mod` | `driver` metadata、resolved module provenance、共享的 typed request 与 argv codec | +| `goplus/xgo` | graph/target 解析、driver 发现与构建、协议调用、信号转发、build/install 输出事务 | +| `goplus/spx` | SPX 协议适配、Engine/bridge/project bundle、解释执行、缓存和自包含 launcher | + +共享层只定义 driver-neutral 数据,不提供 driver lifecycle SDK,也不包含 SPX domain rule。 + +## Metadata 与版本边界 + +SPX 的声明形式为: + +```text +xgo 1.8.0 + +project main.spx Game github.com/goplus/spx/v3 math +driver v1 github.com/goplus/spx/v3/cmd/xgodriver + +class -embed *.spx SpriteImpl +pack assets index.json +``` + +`driver` 作用于它前面最近的 `project`,每个 project 最多声明一个。语法必须是: + +```text +driver +``` + +- `protocol` 当前只执行 `v1`; +- driver 必须是合法 Go import path,不允许相对路径、绝对路径或 `path@version`; +- 已知但格式错误的 `driver` 在 strict/lax 解析中都报错; +- driver module version 不写入 `gox.mod`,由应用的有效 Go graph 唯一决定。 + +五个版本与能力维度各自独立,只在兼容性检查时组合: + +| 身份 | 含义 | 真相来源 | +| --- | --- | --- | +| Driver protocol `v1` | XGo 与 driver 的调用契约代际 | `driver` directive | +| Project-driver v1 能力基线 | 首个理解并调度 `driver v1` 的 XGo 版本,当前为 `1.8.0` | XGo 协议实现与兼容策略 | +| 声明模块的 XGo 要求 | 该 framework 版本使用 metadata 或工具特性所需的最低 XGo 版本 | declaring `gox.mod`/`gop.mod` 的 `xgo` directive | +| SPX version/source | driver 与 bridge 的代码身份 | source mode 的有效 Go graph;published mode 的模块内 `driver-manifest.json` | +| Engine Runtime/ABI | Engine、PCK 与接口兼容性 | SPX runtime lock 与 release manifest | + +`driver v1` 与 `xgo` directive 不会互相改写。例如后续 SPX 将 `xgo` 提升为 `1.9.0`,只表示该 SPX 版本需要 XGo 1.9.0 的能力;v1 协议基线仍是 1.8.0,而该 SPX 项目的有效下限是 `max(1.8.0, 1.9.0) = 1.9.0`,不能反述为“driver v1 最低要求 XGo 1.9.0”。 + +因此,runtime lock 中记录的 XGo `1.7.5` 只表示该 Engine Runtime 的构建工具链;它不表示 XGo `1.7.5` 具备 project-driver 能力。 + +## 有效 Graph 与来源身份 + +XGo 使用标准 Go toolchain 得到有效 build list,并把 logical selection 与 replacement source 分开保存: + +- `Selected` 表示 MVS 选择的 module path/version; +- `Replace` 表示实际读取的替代来源; +- `Effective` 表示最终用于读取 metadata 和构建 driver 的源码身份。 + +同一份受支持 graph policy 必须贯穿 metadata discovery、driver 校验和 driver build,包括 `GOWORK`、`-mod` 与 `-modfile`。不能从原始 `go.mod` 手算依赖,也不能在 driver 侧重新解析出另一张 graph。如果调用方的 `GOFLAGS`/`GOWORK` 无法形成唯一可信的 graph policy,discovery 必须在分类前失败,不能构造替代 graph 或回退到 legacy 路径。project-driver v1 尚未定义 overlay-aware 的项目快照,因此 `-overlay` 只在 target 确认声明了 driver 后明确拒绝;普通项目继续保持 legacy 行为。 + +class module 的顺序和身份来自应用实际生效的 modfile 中的 `//xgo:class` 标记。只有 target 所属 main/workspace module,或被应用显式标记的 class dependency,能够提供 driver。 + +resolved metadata 同时携带: + +- 声明 project/driver 的 module provenance; +- declaring `gox.mod`/`gop.mod` 的 canonical path 与 SHA-256; +- declaring module 的 XGo 版本要求; +- target modfile 的 path 与内容摘要。 + +XGo 在导入 resolved class metadata 时校验 target modfile snapshot;driver 在执行前重新校验 declaration、module source 和其他实际使用的路径,但不重新解释 metadata。这样既避免 metadata/driver 来自不同版本,也避免 discovery 后关键文件被替换。 + +当前 vendor mode 无法提供等价的 module provenance。active module 没有外部 class marker 时,XGo 仍可只依靠该 module 自身的 metadata 分类:非 driver target 继续走旧路径,匹配 driver 后明确报不支持。如果实际生效的 modfile 含外部 class marker,v1 会在分类前 fail closed,因为标准 Go vendor 数据可能省略依赖的 `gox.mod`/`gop.mod`;改读 live replacement 又会破坏 vendor snapshot identity。若要让这类 graph 的 legacy 行为继续兼容,后续必须由 XGo 自有 vendor manifest 固化完整 metadata 身份与摘要。 + +## Target 发现与分发 + +driver discovery 位于 target 解析之后、所有 Dir/PkgPath/Files 分支及 GenGo 之前。 + +一旦确认目标声明了 driver,后续 driver-specific 代码生成由 driver 在自行创建的私有隔离工作目录中完成;v1 不定义 XGo 预生成代码或生成产物交接协议。 + +| target | v1 行为 | +| --- | --- | +| directory | 支持;扫描目录顶层 project file | +| 单个 project file | 支持;必须是目录内唯一 project file | +| import/package path | 支持;基于调用方有效 graph 定位 | +| 多文件 target | driver 项目拒绝 | +| `...` pattern | driver 项目拒绝 | +| `pkg@version` | 普通目标继续走 legacy;确认匹配 driver 后拒绝;版本只能来自当前 graph | + +一个目录必须恰好对应一个 project file。没有 class project 或 project 未声明 driver 时返回 `NotHandled`;只有这个结果允许 XGo 调用旧实现。graph、metadata、协议、driver build 或 driver execution 的任何其他错误都直接返回用户。 + +`run` 保留应用参数的元素边界和顺序。必要时,target 后的第一个 `--` 仅作为 XGo 的 source/argument 分隔符并由 XGo 移除;后续内容原样交给应用。 + +`install` 复用 `build` 语义并安装到有效 `GOBIN`;`GOBIN` 为空时使用 `GOPATH` 第一项下的 `bin`。driver v1 一次只接受一个 install target。 + +## Driver Protocol v1 + +协议使用共享的 typed request,并编码为确定性的 argv;不使用 JSON request file,也不占用 stdin。stdin、stdout、stderr 直接继承,因此交互和管道行为与普通命令一致。 + +请求包含五组信息: + +1. action:`run` 或 `build`; +2. project snapshot:目录、project file、module root、扩展名和可选 pack metadata; +3. driver identity:package、selected/replacement provenance、declaration path 与摘要; +4. graph/build policy:Go command、work directory、workspace 与允许的 flags; +5. action payload:run 的应用参数,或 build 的 staging/final output。 + +codec 对未知、重复、缺失、组合不完整或 action 不适用的字段统一报错。路径在共享层做结构校验,在 driver 层绑定真实文件身份。协议 argv 与环境总大小超过平台安全预算时,在启动 driver 前失败。 + +XGo 当前只向 project driver 传递以下策略: + +| 类型 | 支持范围 | +| --- | --- | +| Graph | `-mod=mod|readonly`、`-modfile`;`-overlay` 只在 driver match 后用于给出明确的不支持错误;`-mod=vendor` 只允许基于 active module 做保守 discovery,driver match 或外部 class metadata 无法确定时明确拒绝 | +| Build | `-v`、`-x`、`-work`、`-trimpath=true`、`-buildvcs=false` | + +其他 flag 只在 target 确认声明了 driver 后报错,避免改变普通项目的既有行为。 + +## Driver 构建与进程边界 + +XGo 在构建 driver 前确认: + +- package 是 `main`; +- package 位于声明 driver 的有效 module 内; +- package 的 selected/replacement/source identity 与 metadata 完全一致; +- 当前 XGo 满足 declaring module 的 `xgo` 版本要求; +- `GOOS/GOARCH` 等于 host,不允许借 driver 做交叉构建。 + +driver 构建在私有临时目录完成,沿用同一 graph policy,并保留调用环境的 CGO 选择。driver 进程继承标准流。每个进程只有一个 command boundary 持有宿主信号;内层 supervisor 只消费 cancellation(包含作为 cause 传入的原始信号),不再重复订阅相同信号,并负责清理整个子进程树。一旦观察到 cancellation,即使子进程在关闭期间以 0 退出,也不能把请求报告为成功。Unix 保留正常退出码或原始信号,Windows 使用 Job Object 管理进程树并将中断表示为退出码。project-driver v1 拒绝任意嵌套 driver dispatch,包括转入另一个 driver。 + +`XGO_DRIVER=off` 是显式禁用开关,但语义是“报错并停止”,不是回退到 GenGo。 + +## SPX Source Mode + +SPX driver 当前接受以下源码身份: + +- 应用本身是 SPX main module; +- SPX 位于当前 workspace; +- SPX 通过无版本的 local replace 引入。 + +这些身份始终使用 source mode。即使 selected module 带有版本,main/workspace module 或无版本 local replace 也不会切换到 published mode。 + +portable driver snapshot 只包含 project root 内的文件,因此会明确拒绝 legacy `extasset` 配置。该限制只属于 project-driver 路径;SPX 现有的 run、native、export 与 pack 命令继续保持 legacy 外部资源行为。 + +`.config` 合约绑定到实际消费的字节。driver 快照其不存在/存在状态与 SHA-256,在交接前重新校验原路径,之后 run/build 只接收已捕获字节,不再重新打开项目副本。 + +source mode 中,driver 每次以 host `CGO_ENABLED=1` 从该有效源码构建 interpreter bridge,并校验构建产物仍来自同一 module identity;编译可复用 Go build cache,但 bridge 文件本身不做持久缓存。 + +published mode 不从有效 graph 构建或借用 bridge,只接受 canonical module `github.com/goplus/spx/v3` 的 exact canonical release version,且不得有 replacement。pseudo-version、versioned replacement 和 foreign module path 都 fail closed。driver 读取该 module 内 pin 的 `driver-manifest.json`,推导不可变的 `driver-v` release URL 并下载独立的 driver release host ZIP,而不是现有 standalone runtime ZIP。manifest 与 ZIP 必须严格校验 schema、推导出的 release identity、host、entry 名称、大小和 SHA-256;每个 host ZIP 必须恰好包含 Engine、PCK 和 bridge,缺失、额外、重复或不匹配的 entry 都 fail closed。已验证 ZIP 可通过 content-addressed cache 复用;offline mode 只有在完整且校验通过的 cache hit 时成功。 + +Runtime 输入按 mode 选择: + +1. source mode:显式 local override 优先;否则先获取 pin 的 `runtime-v` release,只有发布资源不可用时才使用 exact-version 的 source/GOPATH local runtime; +2. published mode:模块内 pin 的 `driver-manifest.json` 选择包含三个组件的不可变 `driver-v` host ZIP,不按 `runtime_version` 选择 driver bundle,也不单独查找 Engine/PCK。 + +尚未发布的 SPX 源码升级不会引入 driver release 依赖。main/workspace/local replace 始终保持 source mode;只要 `runtime_version` 未变,就继续复用同一个 published Engine/PCK,并从当前源码重新构建 bridge。只有 exact published module version 才要求对应的 driver manifest 与 host bundle,release CI 必须阻止 module tag 抢先发布。 + +这里的“源码模式”只指 main/workspace/local replace。外部 demo 的 `require github.com/goplus/spx/v3 vX.Y.Z //xgo:class` 即使已把源码下载到 Go module cache,仍属于 published mode,并使用 `driver-vX.Y.Z`;module cache 不是可变源码工作区,也不作为本机构建 bridge 的隐式后门。 + +每个被选中的 manifest 都必须有 pin 或绑定到其声明的来源。缺少 pin、manifest 格式错误、大小或摘要不匹配、host 或来源不匹配时,在使用任何 runtime 资源前 fail closed。 + +published mode 不使用 `$GOPATH/bin` 兜底。文件名、存在性或大小都不能作为 runtime 身份。无本地产物时,干净 source checkout 可以下载并校验已发布 Engine/PCK;published mode 下载并校验 combined driver ZIP。offline mode 只允许命中完整且校验通过的缓存。 + +SPX 解释运行使用三个独立根: + +| 根 | 用途 | +| --- | --- | +| `ProjectDir` | 用户源码与项目级引用,只读 | +| `AssetDir` | `pack` 指定的项目资源根,只读 | +| `SessionDir` | Engine cwd、临时配置和运行状态,可丢弃 | + +driver 保留 `SessionDir` 的 `--path` 控制权,用户不能覆盖它。每次 run 创建新 session,准备 bridge/Engine 配置后启动 Engine;项目目录在成功和失败路径中都不应发生变化。 + +## 自包含 Build + +`xgo build` 生成一个 Go launcher,并在链接前通过 `go:embed` 写入完整 payload。payload 包含: + +- Engine executable 与 PCK; +- source mode 当前 graph 构建的 bridge,或 published driver bundle 中已验证的 bridge; +- canonical project bundle; +- SPX/source、host platform、runtime/ABI、component digest 和完整 entry table。 + +project bundle 采用 allowlist,而不是遍历整个仓库:只收集顶层项目源码、可选 `.config`、完整 pack 目录以及资源索引显式引用且仍位于 `ProjectDir` 内的文件。symlink、特殊文件、路径逃逸、大小超限和大小写/Unicode collision 都拒绝。固定排序、时间、权限与压缩策略使相同输入得到相同 project bundle digest。 + +生成的 launcher 依赖 SPX 的公开 launcher package,因为生成代码在用户 module graph 中编译,不能导入 SPX `internal` package。launcher 自身只负责校验 payload、物化组件、创建 session、启动 Engine 和复现退出状态。 + +Darwin payload 在 link 前已经固定,link 后执行 ad-hoc signing;签名后不再追加或修改 executable。该签名保证 Mach-O 完整性,不代表 Developer ID 或 notarization。 + +driver 只能写 XGo 分配的私有 staging path。返回成功后,XGo 验证产物是非空、非 symlink 的 host executable,再通过同文件系统替换提交最终输出;在该提交点之前,driver 失败不会改变已有目标。原子可见性与已有目标替换只在 host platform/filesystem 提供保证的范围内成立;Windows 的真实替换及 crash/recovery 行为仍需 host CI 验证。`install` 使用相同事务,只改变最终目录。 + +构建后的 launcher 不需要 Go、XGo、SPX 或网络。它先校验 payload 及 host platform,再从内嵌数据物化 Engine、bridge 和 project,最后在全新 session 中运行。 + +## 缓存与复用 + +组件物化缓存按 `namespace + full digest` 寻址,driver、Engine、bridge 和 project 使用独立 namespace: + +published acquisition 先在 driver namespace 中完整校验并缓存 combined ZIP;只有整包校验通过后,launcher 执行阶段才按各组件摘要物化和复用 Engine、bridge 与 project,组件缓存不能绕过整包信任边界。 + +- 相同 runtime 的不同项目复用同一 Engine; +- 不同 launcher 在执行时可以复用相同 bridge 或 project bundle; +- 不同内容即使文件名相同也不会共用; +- source mode 每次写入新的临时 driver 与 bridge,编译过程自然复用 Go build cache;published mode 只复用已验证的 bundle 组件。 + +下载文件和已物化目录在 cache hit 时都会重新校验 manifest、类型、大小和 SHA-256。同大小篡改不会被接受;损坏 entry 在独占锁下修复。首次物化使用 sibling temp、完整校验和同文件系统 rename;原子发布只在 host platform/filesystem 提供保证的范围内依赖,Windows 的真实 publish/repair 与 crash-recovery 场景仍需 host CI 验证。多进程通过 shared/exclusive lease 避免观察 partial state 或删除正在使用的 entry。 + +launcher 的全部资源来自内嵌 payload,因此第一次运行即使 cache 为空也不会下载。当前不自动执行配额回收;后续 GC 必须继续服从 lease,不能删除正在运行的组件。 + +## 信任与失败模型 + +以下输入都视为不可信:module/workspace metadata、driver argv、环境变量、release manifest、ZIP/payload、项目资源索引、cache 内容和已有输出路径。 + +边界统一遵循: + +- canonical path 与真实文件身份同时校验,不只比较字符串; +- driver request 与各类 manifest 使用严格解析,拒绝未知或重复字段; +- ZIP 拒绝 absolute path、`..`、反斜线穿越、duplicate/collision、symlink、device 和压缩炸弹; +- 文件在读取前后校验身份,关键大文件通过已打开的 handle 消费; +- 任一身份、digest、runtime、ABI、platform 或来源不匹配都终止; +- driver、Engine 和 launcher 的子进程由 supervisor 管理,取消后不得遗留子进程; +- driver 路径绝不回退到 native/GenGo。 + +## 当前边界 + +- XGo `1.8.0` 是 project-driver 能力基线;更早版本不受支持,也不能依赖旧 parser 给出可靠升级提示; +- 只支持 host desktop:Darwin amd64/arm64、Linux amd64、Windows amd64; +- 不支持 `xgo test`、Web、Android、iOS 和 `GOOS/GOARCH` 交叉构建; +- 不支持 vendor mode、overlay、多个声明 driver 的 target 或任意 Go build flag; +- SPX 要求独立的 project pack directory; +- published mode 只接受带有有效 pin 的 canonical SPX exact release module 以及不可变 `driver-v` host bundle;缺少或无效的发布输入必须 fail closed。当前 `v3.2.4` module 尚未生成 driver pin,因此实现完成后也要等该 pin 与 release artifact 存在才启用 published mode; +- launcher 包含项目源码与资源,不提供源码保密; +- launcher 体积主要由 Engine/PCK/bridge 决定,不承诺整个 executable bit-for-bit reproducible; +- XGo 的公开 `tool.RunDir/BuildDir/InstallDir` API 维持原语义;driver dispatch 当前是 CLI 能力。 + +## 发布顺序 + +`goplus/mod` 的 metadata/provenance/codec 与 XGo project-driver 支持是前置条件;所引用的 runtime release 及其模块内 pin 也必须已经存在。SPX 随后分两阶段发布,使 driver bundle 不与 `runtime_version` 绑定: + +1. **Driver bundle 阶段**:从 exact SPX release source 为每个支持的 host 构建一个 ZIP。每个 ZIP 必须恰好包含 Engine、PCK 和 bridge;以不可变的 `driver-v` tag 发布,并冻结名称、大小和 SHA-256。 +2. **Module 阶段**:提交该 exact manifest 及各 host digest 的模块内 pin,然后以 exact release tag/version 发布 canonical SPX module。发布前验证 exact-module 下载、manifest/ZIP 严格校验、cache/offline、source mode 与自包含 launcher。 + +统一的 `publish-release` 状态机会自动执行 Driver bundle 阶段并生成 pin handoff,不单独手工发布 driver。发布人在干净的冻结分支运行 `make release-pin` 即可:命令自动判断缺少 runtime pin 还是 driver pin,从 canonical 公开 release 下载并严格校验资产,且每次只写一个 pin;runtime 复用绑定当前构建身份,driver pin 还要求精确 producer commit。审查并提交该文件后从新 commit 继续。CI artifact 仅作为本地命令不可用时的等价备选。 + +driver bundle 的 URL 与身份只来自 pinned manifest 和 `driver-v`,绝不从 `runtime_version` 推导。module 有效 pin 和不可变产物尚未齐备时,published mode 必须显式失败,不能借用本机 bridge 或只凭 runtime 文件名猜测兼容性。 + +## 验收条件 + +- 普通 XGo 项目的 run/build/install 与变更前一致; +- 声明 driver 的 directory、单文件和 package target 在 discovery 后不执行 GenGo; +- workspace/local replace 的 metadata、driver 与 bridge 始终来自同一有效 graph; +- 干净 SPX checkout 无需 `$GOPATH/bin` 预装资源即可 run/build; +- 发布验证必须覆盖 published bundle 的 cache miss/hit、offline、并发、kill-recovery 和同大小篡改;Windows host CI 对真实 publish/replace 与 crash-recovery 的覆盖是发布前条件; +- canonical released SPX module 能下载模块内 pin 的 `driver-manifest.json` 与 exact host ZIP,并拒绝 pseudo-version、versioned replacement、foreign module、格式错误 manifest 以及不是恰好 Engine/PCK/bridge 的 ZIP; +- run 完整保留 argv、stdin/stdout/stderr 与平台退出语义,Unix 复现信号、Windows 中断返回 130,并且不修改项目; +- build/install 失败不破坏已有输出; +- 自包含 launcher 内嵌 Engine、PCK、bridge 和 project,在空 cache、无工具链、无网络环境完成首次运行; +- Darwin、Linux、Windows 的真实 host artifact 在发布前通过平台 smoke test。 diff --git a/gox.mod b/gox.mod index bac6d22c8..2801a938a 100644 --- a/gox.mod +++ b/gox.mod @@ -1,6 +1,7 @@ -xgo 1.7.5 +xgo 1.8.0 project main.spx Game github.com/goplus/spx/v3 math +driver v1 github.com/goplus/spx/v3/cmd/xgodriver class -embed *.spx SpriteImpl diff --git a/internal/cmd/buildctl/engine/api.go b/internal/cmd/buildctl/engine/api.go index 9a707e8db..9f74b1959 100644 --- a/internal/cmd/buildctl/engine/api.go +++ b/internal/cmd/buildctl/engine/api.go @@ -17,12 +17,13 @@ package engine type DownloadConfig struct { - Runtime bool - SkipRuntimePack bool - Platform string - Mode string - AssetDir string - SameRunArtifacts bool + Runtime bool + SkipRuntimePack bool + VerifyManifestPin bool + Platform string + Mode string + AssetDir string + SameRunArtifacts bool } type BuildConfig struct { @@ -32,7 +33,7 @@ type BuildConfig struct { } func DownloadEngineAssets(cfg DownloadConfig, repoRoot string) error { - return downloadEngineAssets(engineDownloadConfig{runtime: cfg.Runtime, skipRuntimePack: cfg.SkipRuntimePack, platform: cfg.Platform, mode: cfg.Mode, assetDir: cfg.AssetDir, sameRunArtifacts: cfg.SameRunArtifacts}, repoRoot) + return downloadEngineAssets(engineDownloadConfig{runtime: cfg.Runtime, skipRuntimePack: cfg.SkipRuntimePack, verifyManifestPin: cfg.VerifyManifestPin, platform: cfg.Platform, mode: cfg.Mode, assetDir: cfg.AssetDir, sameRunArtifacts: cfg.SameRunArtifacts}, repoRoot) } func ShouldRefreshPreparedAssets() bool { diff --git a/internal/cmd/buildctl/engine/cmd.go b/internal/cmd/buildctl/engine/cmd.go index 0ca59a032..521f63230 100644 --- a/internal/cmd/buildctl/engine/cmd.go +++ b/internal/cmd/buildctl/engine/cmd.go @@ -31,12 +31,13 @@ var osStderr = os.Stderr var errUsage = shared.ErrUsage type engineDownloadConfig struct { - runtime bool - skipRuntimePack bool - platform string - mode string - assetDir string - sameRunArtifacts bool + runtime bool + skipRuntimePack bool + verifyManifestPin bool + platform string + mode string + assetDir string + sameRunArtifacts bool } func Run(args []string) error { diff --git a/internal/cmd/buildctl/engine/download.go b/internal/cmd/buildctl/engine/download.go index ad0c23ace..6ed40ccaa 100644 --- a/internal/cmd/buildctl/engine/download.go +++ b/internal/cmd/buildctl/engine/download.go @@ -30,6 +30,7 @@ func downloadEngineAssets(cfg engineDownloadConfig, repoRoot string) error { return err } } + env.verifyManifestPin = cfg.verifyManifestPin if env.verifyManifest { if err := loadEngineAssetManifest(&env); err != nil { return err diff --git a/internal/cmd/buildctl/engine/download_linux_pack.go b/internal/cmd/buildctl/engine/download_linux_pack.go index 7368c5791..1c84081cf 100644 --- a/internal/cmd/buildctl/engine/download_linux_pack.go +++ b/internal/cmd/buildctl/engine/download_linux_pack.go @@ -42,6 +42,7 @@ type engineDownloadEnv struct { runtimePackAsset string assetDir string verifyManifest bool + verifyManifestPin bool allowMissingManifest bool manifest *release.RuntimeManifest } diff --git a/internal/cmd/buildctl/engine/download_local.go b/internal/cmd/buildctl/engine/download_local.go index 7ce86e26d..ecbcf20fd 100644 --- a/internal/cmd/buildctl/engine/download_local.go +++ b/internal/cmd/buildctl/engine/download_local.go @@ -30,6 +30,8 @@ import ( "github.com/goplus/spx/v3/internal/release" ) +var runtimeManifestPinForLock = release.RuntimeManifestPinForLock + func fetchEngineAsset(env engineDownloadEnv, name, url, dst string) error { if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return err @@ -119,7 +121,20 @@ func loadEngineAssetManifest(env *engineDownloadEnv) error { manifestPath = src } - manifest, err := release.LoadRuntimeManifest(manifestPath) + data, err := os.ReadFile(manifestPath) + if err != nil { + return fmt.Errorf("read runtime manifest: %w", err) + } + if env.verifyManifestPin { + pin, err := runtimeManifestPinForLock(lock) + if err != nil { + return fmt.Errorf("resolve runtime manifest pin: %w", err) + } + if err := pin.Verify(data); err != nil { + return fmt.Errorf("verify runtime manifest pin: %w", err) + } + } + manifest, err := release.ParseRuntimeManifest(data) if err != nil { return err } diff --git a/internal/cmd/buildctl/engine/download_test.go b/internal/cmd/buildctl/engine/download_test.go index 07394adfa..4043475dc 100644 --- a/internal/cmd/buildctl/engine/download_test.go +++ b/internal/cmd/buildctl/engine/download_test.go @@ -173,6 +173,35 @@ func TestLoadEngineAssetManifestKeepsNonNotFoundFailuresClosed(t *testing.T) { } } +func TestLoadEngineAssetManifestRejectsBytesOutsidePin(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("tampered")) + })) + defer server.Close() + + lock := release.DefaultRuntimeLock() + oldPin := runtimeManifestPinForLock + runtimeManifestPinForLock = func(got release.RuntimeLock) (release.RuntimeManifestPin, error) { + if got.RuntimeVersion != lock.RuntimeVersion { + t.Fatalf("pin lock version = %q, want %q", got.RuntimeVersion, lock.RuntimeVersion) + } + return release.RuntimeManifestPin{ + Schema: 1, RuntimeVersion: lock.RuntimeVersion, Name: lock.Manifest, + Size: 8, SHA256: strings.Repeat("0", 64), + }, nil + } + t.Cleanup(func() { runtimeManifestPinForLock = oldPin }) + + env := engineDownloadEnv{ + version: lock.RuntimeVersion, cacheDir: t.TempDir(), urlPrefix: server.URL + "/", + verifyManifestPin: true, + } + err := loadEngineAssetManifest(&env) + if err == nil || !strings.Contains(err.Error(), "verify runtime manifest pin") { + t.Fatalf("loadEngineAssetManifest error = %v, want pinned-byte rejection", err) + } +} + func TestLinkOrCopyFilePrefersHardLinkWhenAvailable(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("hard link behavior varies on Windows") diff --git a/internal/cmd/buildctl/prepare.go b/internal/cmd/buildctl/prepare.go index 3794cc38d..45cba4825 100644 --- a/internal/cmd/buildctl/prepare.go +++ b/internal/cmd/buildctl/prepare.go @@ -65,10 +65,11 @@ func prepareRuntimeAssets(runner shared.ScriptRunner, assetDir string, published // product consumes the bytes that will be published in runtime-v*. useLockedRuntimePack := assetDir != "" || publishedRuntime if err := downloadEngineAssets(engine.DownloadConfig{ - Runtime: true, - SkipRuntimePack: !useLockedRuntimePack, - AssetDir: assetDir, - SameRunArtifacts: assetDir != "", + Runtime: true, + SkipRuntimePack: !useLockedRuntimePack, + VerifyManifestPin: publishedRuntime, + AssetDir: assetDir, + SameRunArtifacts: assetDir != "", }, runner.RepoRootDir()); err != nil { return err } diff --git a/internal/cmd/buildctl/prepare_test.go b/internal/cmd/buildctl/prepare_test.go index ae7b113ab..e198cefcf 100644 --- a/internal/cmd/buildctl/prepare_test.go +++ b/internal/cmd/buildctl/prepare_test.go @@ -240,6 +240,24 @@ func TestSetupAssetsHostUsesPublishedPack(t *testing.T) { } } +func TestPublishedRuntimeRequiresManifestPin(t *testing.T) { + runner := newRuntimeFixtureRunner(t) + oldDownload := downloadEngineAssets + var got enginepkg.DownloadConfig + downloadEngineAssets = func(cfg enginepkg.DownloadConfig, _ string) error { + got = cfg + return nil + } + t.Cleanup(func() { downloadEngineAssets = oldDownload }) + + if err := prepareRuntimeAssets(runner, "", true); err != nil { + t.Fatal(err) + } + if !got.VerifyManifestPin { + t.Fatal("published runtime download did not require its embedded manifest pin") + } +} + func TestSetupAssetsWeb(t *testing.T) { runner := newRuntimeFixtureRunner(t) installFakeEngineDownload(t) diff --git a/internal/driverbundle/dependency_test.go b/internal/driverbundle/dependency_test.go new file mode 100644 index 000000000..e362b45a3 --- /dev/null +++ b/internal/driverbundle/dependency_test.go @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package driverbundle + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestPinsStayOutsideBridgeDependencies(t *testing.T) { + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + command := exec.Command("go", "list", "-deps", "./cmd/ispxnative") + command.Dir = root + command.Env = appendWithoutKey(os.Environ(), "GOWORK", "GOWORK=off") + output, err := command.Output() + if err != nil { + t.Fatal(err) + } + for _, dependency := range strings.Fields(string(output)) { + if dependency == SPXModulePath+"/internal/driverbundle" { + t.Fatal("driver pin package is part of the bridge dependency graph") + } + } +} + +func appendWithoutKey(env []string, key, value string) []string { + prefix := key + "=" + out := make([]string, 0, len(env)+1) + for _, item := range env { + if !strings.HasPrefix(item, prefix) { + out = append(out, item) + } + } + return append(out, value) +} diff --git a/internal/driverbundle/digest.go b/internal/driverbundle/digest.go new file mode 100644 index 000000000..fef75e167 --- /dev/null +++ b/internal/driverbundle/digest.go @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package driverbundle + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" +) + +// ComputeEngineInterfaceDigest identifies an Engine/PCK pair. +func ComputeEngineInterfaceDigest(engine, pack []byte) string { + engineDigest := sha256.Sum256(engine) + packDigest := sha256.Sum256(pack) + return computeEngineInterfaceDigest(engineDigest, packDigest) +} + +// ComputeEngineInterfaceDigestFromSHA256 identifies an Engine/PCK pair from +// their verified content digests. +func ComputeEngineInterfaceDigestFromSHA256(engine, pack string) (string, error) { + engineDigest, err := decodeSHA256(engine) + if err != nil { + return "", fmt.Errorf("invalid Engine SHA-256: %w", err) + } + packDigest, err := decodeSHA256(pack) + if err != nil { + return "", fmt.Errorf("invalid PCK SHA-256: %w", err) + } + return computeEngineInterfaceDigest(engineDigest, packDigest), nil +} + +func decodeSHA256(value string) ([sha256.Size]byte, error) { + var digest [sha256.Size]byte + if err := validateSHA256(value); err != nil { + return digest, err + } + _, err := hex.Decode(digest[:], []byte(value)) + return digest, err +} + +func computeEngineInterfaceDigest(engine, pack [sha256.Size]byte) string { + hasher := sha256.New() + hasher.Write([]byte(EngineInterfaceDigestDomain)) + hasher.Write(engine[:]) + hasher.Write(pack[:]) + return hex.EncodeToString(hasher.Sum(nil)) +} diff --git a/internal/driverbundle/identity.go b/internal/driverbundle/identity.go new file mode 100644 index 000000000..6c6e96ba8 --- /dev/null +++ b/internal/driverbundle/identity.go @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package driverbundle + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "strings" + + "golang.org/x/mod/semver" +) + +func validateSPXVersion(value string) error { + if !strings.HasPrefix(value, "v") || !semver.IsValid(value) || semver.Canonical(value) != value { + return fmt.Errorf("invalid SPX version %q", value) + } + return nil +} + +func validateRuntimeVersion(value string) error { + if !runtimeVersionPattern.MatchString(value) || !semver.IsValid("v"+value) { + return fmt.Errorf("invalid runtime version %q", value) + } + return nil +} + +func expectedBundleName(goos, goarch string) string { + return "spx-driver-" + goos + "-" + goarch + ".zip" +} + +func expectedFileNames(runtimeVersion, goos, goarch string) [3]string { + engine := "gdspxrt" + runtimeVersion + if goos == "windows" { + engine += ".exe" + } + extension := map[string]string{"darwin": ".dylib", "linux": ".so", "windows": ".dll"}[goos] + return [3]string{engine, "gdspxrt" + runtimeVersion + ".pck", "gdspx-" + goos + "-" + goarch + extension} +} + +func validateSHA256(value string) error { + if len(value) != sha256.Size*2 || value != strings.ToLower(value) { + return errors.New("must be a lower-case 64-hex-character digest") + } + if _, err := hex.DecodeString(value); err != nil { + return errors.New("must be a lower-case 64-hex-character digest") + } + return nil +} + +func validateBundleName(name string) error { + if err := validateBaseName(name); err != nil || !strings.HasSuffix(name, ".zip") { + return fmt.Errorf("invalid bundle name %q", name) + } + return nil +} + +func validateBaseName(name string) error { + if name == "" || name == "." || name == ".." || strings.ContainsAny(name, "/\\\x00<>:\"|?*") || name != strings.TrimSpace(name) { + return errors.New("must be a portable basename") + } + for _, r := range name { + if r < 0x20 { + return errors.New("contains a control character") + } + } + return nil +} diff --git a/internal/driverbundle/manifest.go b/internal/driverbundle/manifest.go new file mode 100644 index 000000000..9e1a776d6 --- /dev/null +++ b/internal/driverbundle/manifest.go @@ -0,0 +1,173 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Package driverbundle describes published SPX project-driver bundles. +package driverbundle + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/goplus/spx/v3/internal/strictjson" +) + +const ( + ManifestSchema = 1 + PinSchema = 1 + ManifestName = "driver-manifest.json" + SPXModulePath = "github.com/goplus/spx/v3" + EngineInterfaceDigestDomain = "spx-engine-interface/v1\x00" + MaxManifestSize int64 = 16 << 20 +) + +var ( + ErrPinNotFound = errors.New("driverbundle: pin not found") + ErrBundleNotFound = errors.New("driverbundle: bundle not found") +) + +// Manifest identifies all platform bundles in one driver release. +type Manifest struct { + Schema int `json:"schema"` + SPXModule string `json:"spx_module"` + SPXVersion string `json:"spx_version"` + RuntimeVersion string `json:"runtime_version"` + RuntimeABI int `json:"runtime_abi"` + ReleaseRepository string `json:"release_repository"` + RuntimeLockSHA256 string `json:"runtime_lock_sha256"` + RuntimeManifestSHA256 string `json:"runtime_manifest_sha256"` + GoVersion string `json:"go_version"` + ProducerCommit string `json:"producer_commit"` + Bundles []Bundle `json:"bundles"` +} + +// Bundle identifies one Engine+PCK+bridge ZIP. +type Bundle struct { + GOOS string `json:"goos"` + GOARCH string `json:"goarch"` + Name string `json:"name"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` + EngineInterfaceDigest string `json:"engine_interface_digest"` + Files []File `json:"files"` +} + +// File identifies one regular file in a bundle. +type File struct { + Name string `json:"name"` + Mode uint32 `json:"mode"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` +} + +// Pin is the module-shipped trust root for one manifest. +type Pin struct { + Schema int `json:"schema"` + SPXModule string `json:"spx_module"` + SPXVersion string `json:"spx_version"` + RuntimeVersion string `json:"runtime_version"` + Name string `json:"name"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` +} + +// Parse strictly decodes and validates a manifest. +func Parse(data []byte) (Manifest, error) { + if int64(len(data)) > MaxManifestSize { + return Manifest{}, fmt.Errorf("driverbundle: manifest exceeds %d-byte limit", MaxManifestSize) + } + var manifest Manifest + if err := strictjson.Decode(data, &manifest); err != nil { + return Manifest{}, fmt.Errorf("driverbundle: decode manifest: %w", err) + } + if err := manifest.Validate(); err != nil { + return Manifest{}, err + } + return manifest, nil +} + +// JSON returns canonical, human-readable manifest bytes. +func (m Manifest) JSON() ([]byte, error) { + if err := m.Validate(); err != nil { + return nil, err + } + data, err := json.MarshalIndent(m, "", " ") + if err != nil { + return nil, fmt.Errorf("driverbundle: encode manifest: %w", err) + } + return append(data, '\n'), nil +} + +// ParseBundle strictly decodes one platform descriptor. +func ParseBundle(data []byte) (Bundle, error) { + if int64(len(data)) > MaxManifestSize { + return Bundle{}, fmt.Errorf("driverbundle: bundle descriptor exceeds %d-byte limit", MaxManifestSize) + } + var bundle Bundle + if err := strictjson.Decode(data, &bundle); err != nil { + return Bundle{}, fmt.Errorf("driverbundle: decode bundle: %w", err) + } + if err := bundle.Validate(); err != nil { + return Bundle{}, err + } + return bundle, nil +} + +// JSON returns canonical, human-readable bundle bytes. +func (b Bundle) JSON() ([]byte, error) { + if err := b.Validate(); err != nil { + return nil, err + } + data, err := json.MarshalIndent(b, "", " ") + if err != nil { + return nil, fmt.Errorf("driverbundle: encode bundle: %w", err) + } + return append(data, '\n'), nil +} + +// BundleFor returns the bundle for goos/goarch. +func (m Manifest) BundleFor(goos, goarch string) (Bundle, error) { + if err := m.Validate(); err != nil { + return Bundle{}, err + } + for _, bundle := range m.Bundles { + if bundle.GOOS == goos && bundle.GOARCH == goarch { + return bundle, nil + } + } + return Bundle{}, fmt.Errorf("%w: %s/%s", ErrBundleNotFound, goos, goarch) +} + +// DownloadURL returns an immutable bundle URL. +func (m Manifest) DownloadURL(name string) (string, error) { + if err := m.Validate(); err != nil { + return "", err + } + if err := validateBundleName(name); err != nil { + return "", err + } + return "https://github.com/" + m.ReleaseRepository + "/releases/download/" + m.ReleaseTag() + "/" + name, nil +} + +func (m Manifest) ReleaseTag() string { return "driver-" + m.SPXVersion } + +// ManifestURL returns the immutable URL for a pinned manifest. +func ManifestURL(pin Pin) (string, error) { + if err := pin.Validate(); err != nil { + return "", err + } + return "https://github.com/goplus/spx/releases/download/driver-" + pin.SPXVersion + "/" + pin.Name, nil +} diff --git a/internal/driverbundle/manifest_test.go b/internal/driverbundle/manifest_test.go new file mode 100644 index 000000000..7e7e3f39d --- /dev/null +++ b/internal/driverbundle/manifest_test.go @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package driverbundle + +import ( + "errors" + "strings" + "testing" + + "github.com/goplus/spx/v3/internal/release" +) + +const testDigest = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + +func testManifest() Manifest { + file := func(name string, mode uint32) File { + return File{Name: name, Mode: mode, Size: 10, SHA256: testDigest} + } + bundle := func(goos, goarch, name string) Bundle { + names := expectedFileNames("2.4.4", goos, goarch) + interfaceDigest, err := ComputeEngineInterfaceDigestFromSHA256(testDigest, testDigest) + if err != nil { + panic(err) + } + return Bundle{ + GOOS: goos, GOARCH: goarch, Name: name, Size: 100, SHA256: testDigest, + EngineInterfaceDigest: interfaceDigest, + Files: []File{file(names[0], 0o755), file(names[1], 0o644), file(names[2], 0o755)}, + } + } + return Manifest{ + Schema: ManifestSchema, SPXModule: SPXModulePath, SPXVersion: "v3.2.4", + RuntimeVersion: "2.4.4", RuntimeABI: 1, ReleaseRepository: "goplus/spx", + RuntimeLockSHA256: testDigest, RuntimeManifestSHA256: testDigest, + GoVersion: "1.25.8", + ProducerCommit: strings.Repeat("a", 40), + Bundles: []Bundle{ + bundle("darwin", "amd64", "spx-driver-darwin-amd64.zip"), + bundle("darwin", "arm64", "spx-driver-darwin-arm64.zip"), + bundle("linux", "amd64", "spx-driver-linux-amd64.zip"), + bundle("windows", "amd64", "spx-driver-windows-amd64.zip"), + }, + } +} + +func TestManifestRoundTripLookupAndURL(t *testing.T) { + want := testManifest() + data, err := want.JSON() + if err != nil { + t.Fatal(err) + } + got, err := Parse(data) + if err != nil { + t.Fatal(err) + } + if got.SPXVersion != want.SPXVersion || len(got.Bundles) != 4 { + t.Fatalf("parsed manifest = %#v", got) + } + bundle, err := got.BundleFor("linux", "amd64") + if err != nil || bundle.Name != "spx-driver-linux-amd64.zip" { + t.Fatalf("BundleFor = %#v, %v", bundle, err) + } + wantURL := "https://github.com/goplus/spx/releases/download/driver-v3.2.4/spx-driver-linux-amd64.zip" + if gotURL, err := got.DownloadURL("spx-driver-linux-amd64.zip"); err != nil || gotURL != wantURL { + t.Fatalf("DownloadURL = %q, %v, want %q", gotURL, err, wantURL) + } + if _, err := got.DownloadURL("../bundle.zip"); err == nil { + t.Fatal("DownloadURL accepted an unsafe bundle name") + } +} + +func TestBundleRoundTrip(t *testing.T) { + want := testManifest().Bundles[0] + data, err := want.JSON() + if err != nil { + t.Fatal(err) + } + got, err := ParseBundle(data) + if err != nil { + t.Fatal(err) + } + if got.Name != want.Name || len(got.Files) != 3 { + t.Fatalf("bundle = %#v", got) + } + duplicate := want + duplicate.Files[1] = duplicate.Files[0] + if err := duplicate.Validate(); err == nil { + t.Fatal("accepted duplicate bundle file") + } +} + +func TestManifestParseIsStrict(t *testing.T) { + data, err := testManifest().JSON() + if err != nil { + t.Fatal(err) + } + for name, mutate := range map[string]func(string) string{ + "unknown": func(value string) string { + return strings.TrimSuffix(value, "\n")[:len(strings.TrimSuffix(value, "\n"))-1] + `,"unknown":true}` + }, + "trailing": func(value string) string { return value + `{}` }, + "duplicate nested": func(value string) string { + return strings.Replace(value, `"name": "spx-driver-darwin-arm64.zip"`, `"name": "spx-driver-darwin-arm64.zip", "name": "spx-driver-darwin-arm64.zip"`, 1) + }, + } { + t.Run(name, func(t *testing.T) { + if _, err := Parse([]byte(mutate(string(data)))); err == nil { + t.Fatal("accepted ambiguous JSON") + } + }) + } + if _, err := Parse(make([]byte, MaxManifestSize+1)); err == nil { + t.Fatal("Parse accepted an oversized manifest") + } + if _, err := ParseBundle(make([]byte, MaxManifestSize+1)); err == nil { + t.Fatal("ParseBundle accepted an oversized descriptor") + } +} + +func TestManifestValidationMutations(t *testing.T) { + tests := []struct { + name string + mutate func(*Manifest) + }{ + {"schema", func(m *Manifest) { m.Schema++ }}, + {"module", func(m *Manifest) { m.SPXModule = "example.com/spx" }}, + {"version", func(m *Manifest) { m.SPXVersion = "v3.2" }}, + {"runtime", func(m *Manifest) { m.RuntimeVersion = "v2.4.4" }}, + {"repository", func(m *Manifest) { m.ReleaseRepository = "https://github.com/goplus/spx" }}, + {"digest", func(m *Manifest) { m.RuntimeLockSHA256 = strings.Repeat("A", 64) }}, + {"Go version", func(m *Manifest) { m.GoVersion = "" }}, + {"commit", func(m *Manifest) { m.ProducerCommit = "deadbeef" }}, + {"platform duplicate", func(m *Manifest) { m.Bundles[1].GOOS = m.Bundles[0].GOOS; m.Bundles[1].GOARCH = m.Bundles[0].GOARCH }}, + {"platform order", func(m *Manifest) { m.Bundles[0], m.Bundles[1] = m.Bundles[1], m.Bundles[0] }}, + {"bundle path", func(m *Manifest) { m.Bundles[0].Name = "../bundle.zip" }}, + {"file duplicate", func(m *Manifest) { m.Bundles[0].Files[1].Name = m.Bundles[0].Files[0].Name }}, + {"file mode", func(m *Manifest) { m.Bundles[0].Files[0].Mode = 0o100755 }}, + {"file size", func(m *Manifest) { m.Bundles[0].Files[0].Size = 0 }}, + {"file digest", func(m *Manifest) { m.Bundles[0].Files[0].SHA256 = "bad" }}, + {"file count", func(m *Manifest) { m.Bundles[0].Files = m.Bundles[0].Files[:2] }}, + {"interface digest", func(m *Manifest) { m.Bundles[0].EngineInterfaceDigest = "bad" }}, + {"interface identity", func(m *Manifest) { m.Bundles[0].Files[0].SHA256 = strings.Repeat("b", 64) }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + candidate := testManifest() + test.mutate(&candidate) + if err := candidate.Validate(); err == nil { + t.Fatal("accepted invalid manifest") + } + }) + } + if _, err := testManifest().BundleFor("linux", "arm64"); !errors.Is(err, ErrBundleNotFound) { + t.Fatalf("BundleFor missing error = %v", err) + } +} + +func TestManifestValidateForLockAndPin(t *testing.T) { + lock := release.DefaultRuntimeLock() + lockSHA, err := lock.SHA256() + if err != nil { + t.Fatal(err) + } + manifest := testManifest() + manifest.RuntimeVersion = lock.RuntimeVersion + manifest.RuntimeABI = lock.RuntimeABI + manifest.ReleaseRepository = lock.ReleaseRepository + manifest.RuntimeLockSHA256 = lockSHA + manifest.GoVersion = lock.Toolchain.Go + for i := range manifest.Bundles { + files := expectedFileNames(lock.RuntimeVersion, manifest.Bundles[i].GOOS, manifest.Bundles[i].GOARCH) + for j := range files { + manifest.Bundles[i].Files[j].Name = files[j] + } + } + pin := testPin(manifest.SPXVersion) + pin.RuntimeVersion = lock.RuntimeVersion + runtimePin := release.RuntimeManifestPin{ + Schema: 1, RuntimeVersion: lock.RuntimeVersion, Name: lock.Manifest, + Size: 123, SHA256: testDigest, + } + if err := manifest.ValidateFor(lock, pin, runtimePin); err != nil { + t.Fatal(err) + } + manifest.RuntimeLockSHA256 = testDigest + if err := manifest.ValidateFor(lock, pin, runtimePin); err == nil { + t.Fatal("ValidateFor accepted a mismatched lock digest") + } + manifest.RuntimeLockSHA256 = lockSHA + manifest.RuntimeManifestSHA256 = strings.Repeat("b", 64) + if err := manifest.ValidateFor(lock, pin, runtimePin); err == nil { + t.Fatal("ValidateFor accepted a mismatched runtime manifest digest") + } + manifest.RuntimeManifestSHA256 = runtimePin.SHA256 + manifest.GoVersion = "1.25.7" + if err := manifest.ValidateFor(lock, pin, runtimePin); err == nil { + t.Fatal("ValidateFor accepted a mismatched Go version") + } +} diff --git a/internal/driverbundle/pins.go b/internal/driverbundle/pins.go new file mode 100644 index 000000000..d88761e92 --- /dev/null +++ b/internal/driverbundle/pins.go @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package driverbundle + +import ( + "bytes" + "crypto/sha256" + "embed" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io/fs" + "path" + "sort" + "strings" + + "github.com/goplus/spx/v3/internal/strictjson" +) + +const pinDirectory = "pins" + +var ( + //go:embed pins + embeddedPins embed.FS + embeddedPinSet = mustLoadPins(embeddedPins) +) + +// ForVersion returns the embedded trust-root pin for an SPX module version. +func ForVersion(version string) (Pin, error) { + pin, ok := embeddedPinSet[version] + if !ok { + return Pin{}, fmt.Errorf("%w: %s", ErrPinNotFound, version) + } + return pin, nil +} + +// ParsePin strictly decodes and validates one pin. +func ParsePin(data []byte) (Pin, error) { + var pin Pin + if err := strictjson.Decode(data, &pin); err != nil { + return Pin{}, fmt.Errorf("driverbundle: decode pin: %w", err) + } + if err := pin.Validate(); err != nil { + return Pin{}, err + } + return pin, nil +} + +// NewPin pins the canonical bytes of a validated manifest. +func NewPin(manifest Manifest, data []byte) (Pin, error) { + canonical, err := manifest.JSON() + if err != nil { + return Pin{}, err + } + if !bytes.Equal(data, canonical) { + return Pin{}, errors.New("driverbundle: manifest is not canonical") + } + digest := sha256.Sum256(data) + pin := Pin{ + Schema: PinSchema, SPXModule: manifest.SPXModule, + SPXVersion: manifest.SPXVersion, RuntimeVersion: manifest.RuntimeVersion, + Name: ManifestName, Size: int64(len(data)), SHA256: hex.EncodeToString(digest[:]), + } + if err := pin.Validate(); err != nil { + return Pin{}, err + } + return pin, nil +} + +// JSON returns canonical, human-readable pin bytes. +func (p Pin) JSON() ([]byte, error) { + if err := p.Validate(); err != nil { + return nil, err + } + data, err := json.MarshalIndent(p, "", " ") + if err != nil { + return nil, fmt.Errorf("driverbundle: encode pin: %w", err) + } + return append(data, '\n'), nil +} + +// Validate checks one pin's identity and content digest. +func (p Pin) Validate() error { + if p.Schema != PinSchema { + return fmt.Errorf("driverbundle: pin schema = %d, want %d", p.Schema, PinSchema) + } + if p.SPXModule != SPXModulePath { + return fmt.Errorf("driverbundle: pin SPX module = %q, want %q", p.SPXModule, SPXModulePath) + } + if err := validateSPXVersion(p.SPXVersion); err != nil { + return fmt.Errorf("driverbundle: %w", err) + } + if err := validateRuntimeVersion(p.RuntimeVersion); err != nil { + return fmt.Errorf("driverbundle: %w", err) + } + if p.Name != ManifestName { + return fmt.Errorf("driverbundle: pin name = %q, want %q", p.Name, ManifestName) + } + if p.Size <= 0 || p.Size > MaxManifestSize { + return errors.New("driverbundle: pin size is outside the manifest limit") + } + if err := validateSHA256(p.SHA256); err != nil { + return fmt.Errorf("driverbundle: pin SHA-256: %w", err) + } + return nil +} + +// LoadPins loads JSON pins from an embedded or pins-rooted filesystem. +func LoadPins(fileSystem fs.FS) (map[string]Pin, error) { + if fileSystem == nil { + return nil, fmt.Errorf("driverbundle: nil pin filesystem") + } + files, err := fs.Glob(fileSystem, pinDirectory+"/*.json") + if err != nil { + return nil, fmt.Errorf("driverbundle: list pins: %w", err) + } + // A sub-filesystem rooted at pins/ is convenient for callers. Keep the + // normal embedded layout first, then accept that focused form. + if len(files) == 0 { + files, err = fs.Glob(fileSystem, "*.json") + if err != nil { + return nil, fmt.Errorf("driverbundle: list pins: %w", err) + } + } + sort.Strings(files) + pins := make(map[string]Pin, len(files)) + for _, file := range files { + data, err := fs.ReadFile(fileSystem, file) + if err != nil { + return nil, fmt.Errorf("driverbundle: read pin %q: %w", file, err) + } + pin, err := ParsePin(data) + if err != nil { + return nil, fmt.Errorf("driverbundle: parse pin %q: %w", file, err) + } + version := strings.TrimSuffix(path.Base(file), ".json") + if pin.SPXVersion != version { + return nil, fmt.Errorf("driverbundle: pin %q declares version %q", file, pin.SPXVersion) + } + if _, exists := pins[version]; exists { + return nil, fmt.Errorf("driverbundle: duplicate pin for %q", version) + } + pins[version] = pin + } + return pins, nil +} + +func mustLoadPins(fileSystem fs.FS) map[string]Pin { + pins, err := LoadPins(fileSystem) + if err != nil { + panic("driverbundle: invalid embedded pins: " + err.Error()) + } + return pins +} diff --git a/internal/driverbundle/pins/README b/internal/driverbundle/pins/README new file mode 100644 index 000000000..0eb10321a --- /dev/null +++ b/internal/driverbundle/pins/README @@ -0,0 +1,5 @@ +Pinned driver-manifest.json identities are added here when an SPX module +version publishes its immutable driver bundle release. + +The directory is intentionally embedded even while the current development +version has no published driver pin yet. diff --git a/internal/driverbundle/pins_test.go b/internal/driverbundle/pins_test.go new file mode 100644 index 000000000..d61d999d7 --- /dev/null +++ b/internal/driverbundle/pins_test.go @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package driverbundle + +import ( + "errors" + "strings" + "testing" + "testing/fstest" +) + +func testPin(version string) Pin { + return Pin{ + Schema: PinSchema, SPXModule: SPXModulePath, SPXVersion: version, + RuntimeVersion: "2.4.4", Name: ManifestName, Size: 123, SHA256: testDigest, + } +} + +func TestPinRoundTripAndMissingEmbeddedPin(t *testing.T) { + pin := testPin("v3.2.4") + data, err := pin.JSON() + if err != nil { + t.Fatal(err) + } + got, err := ParsePin(data) + if err != nil || got != pin { + t.Fatalf("ParsePin = %#v, %v", got, err) + } + if _, err := ForVersion("v9.9.9"); !errors.Is(err, ErrPinNotFound) { + t.Fatalf("ForVersion error = %v", err) + } + wantURL := "https://github.com/goplus/spx/releases/download/driver-v3.2.4/driver-manifest.json" + if gotURL, err := ManifestURL(pin); err != nil || gotURL != wantURL { + t.Fatalf("ManifestURL = %q, %v, want %q", gotURL, err, wantURL) + } + for _, invalid := range []string{ + strings.TrimSuffix(string(data), "\n") + ` {}`, + strings.Replace(string(data), `"name": "driver-manifest.json"`, `"name": "driver-manifest.json", "name": "driver-manifest.json"`, 1), + strings.TrimSuffix(string(data), "\n")[:len(strings.TrimSuffix(string(data), "\n"))-1] + `,"unknown":true}`, + } { + if _, err := ParsePin([]byte(invalid)); err == nil { + t.Fatalf("ParsePin accepted invalid JSON %q", invalid) + } + } +} + +func TestNewPinRequiresCanonicalManifest(t *testing.T) { + manifest := testManifest() + data, err := manifest.JSON() + if err != nil { + t.Fatal(err) + } + pin, err := NewPin(manifest, data) + if err != nil { + t.Fatal(err) + } + if pin.SPXVersion != manifest.SPXVersion || pin.RuntimeVersion != manifest.RuntimeVersion || pin.Size != int64(len(data)) { + t.Fatalf("NewPin = %#v", pin) + } + if _, err := NewPin(manifest, append(data, '\n')); err == nil { + t.Fatal("NewPin accepted non-canonical manifest bytes") + } +} + +func TestLoadPinsFilesystemAndDuplicates(t *testing.T) { + pin := testPin("v3.2.3") + data, err := pin.JSON() + if err != nil { + t.Fatal(err) + } + loaded, err := LoadPins(fstest.MapFS{"pins/v3.2.3.json": {Data: data}}) + if err != nil || len(loaded) != 1 || loaded["v3.2.3"] != pin { + t.Fatalf("LoadPins = %#v, %v", loaded, err) + } + empty, err := LoadPins(fstest.MapFS{"pins/README": {Data: []byte("empty")}}) + if err != nil || len(empty) != 0 { + t.Fatalf("empty LoadPins = %#v, %v", empty, err) + } + other := testPin("v3.2.3") + otherData, err := other.JSON() + if err != nil { + t.Fatal(err) + } + _, err = LoadPins(fstest.MapFS{ + "pins/v3.2.3.json": {Data: data}, + "pins/alias.json": {Data: otherData}, + }) + if err == nil { + t.Fatal("LoadPins accepted duplicate pin") + } +} + +func TestPinValidationMutations(t *testing.T) { + mutations := []func(*Pin){ + func(p *Pin) { p.Schema++ }, + func(p *Pin) { p.SPXModule = "example.com/spx" }, + func(p *Pin) { p.SPXVersion = "v3.2" }, + func(p *Pin) { p.RuntimeVersion = "v2.4.4" }, + func(p *Pin) { p.Name = "../driver-manifest.json" }, + func(p *Pin) { p.Size = 0 }, + func(p *Pin) { p.Size = MaxManifestSize + 1 }, + func(p *Pin) { p.SHA256 = strings.Repeat("A", 64) }, + } + for _, mutate := range mutations { + pin := testPin("v3.2.4") + mutate(&pin) + if err := pin.Validate(); err == nil { + t.Fatalf("Validate accepted %#v", pin) + } + } +} diff --git a/internal/driverbundle/validation.go b/internal/driverbundle/validation.go new file mode 100644 index 000000000..a8197c29d --- /dev/null +++ b/internal/driverbundle/validation.go @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package driverbundle + +import ( + "errors" + "fmt" + "regexp" + "strings" + + "github.com/goplus/spx/v3/internal/release" +) + +var supportedTargets = [...]struct{ goos, goarch string }{ + {"darwin", "amd64"}, + {"darwin", "arm64"}, + {"linux", "amd64"}, + {"windows", "amd64"}, +} + +var ( + runtimeVersionPattern = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$`) + repositoryPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$`) + platformPattern = regexp.MustCompile(`^[a-z][a-z0-9._-]*$`) + commitPattern = regexp.MustCompile(`^[0-9a-f]{40}$`) +) + +// Validate checks manifest identity and its four canonical host bundles. +func (m Manifest) Validate() error { + if m.Schema != ManifestSchema { + return fmt.Errorf("driverbundle: manifest schema = %d, want %d", m.Schema, ManifestSchema) + } + if m.SPXModule != SPXModulePath { + return fmt.Errorf("driverbundle: invalid SPX module %q", m.SPXModule) + } + if err := validateSPXVersion(m.SPXVersion); err != nil { + return fmt.Errorf("driverbundle: %w", err) + } + if err := validateRuntimeVersion(m.RuntimeVersion); err != nil { + return fmt.Errorf("driverbundle: %w", err) + } + if m.RuntimeABI <= 0 { + return errors.New("driverbundle: runtime ABI must be positive") + } + if !repositoryPattern.MatchString(m.ReleaseRepository) { + return fmt.Errorf("driverbundle: invalid release repository %q", m.ReleaseRepository) + } + for _, item := range []struct{ name, value string }{ + {"runtime lock", m.RuntimeLockSHA256}, {"runtime manifest", m.RuntimeManifestSHA256}, + } { + if err := validateSHA256(item.value); err != nil { + return fmt.Errorf("driverbundle: invalid %s SHA-256: %w", item.name, err) + } + } + if !commitPattern.MatchString(m.ProducerCommit) { + return fmt.Errorf("driverbundle: invalid producer commit %q", m.ProducerCommit) + } + if m.GoVersion == "" || strings.TrimSpace(m.GoVersion) != m.GoVersion { + return fmt.Errorf("driverbundle: invalid Go version %q", m.GoVersion) + } + if len(m.Bundles) != len(supportedTargets) { + return fmt.Errorf("driverbundle: bundles = %d, want %d", len(m.Bundles), len(supportedTargets)) + } + for i, bundle := range m.Bundles { + want := supportedTargets[i] + if bundle.GOOS != want.goos || bundle.GOARCH != want.goarch { + return fmt.Errorf("driverbundle: bundle %d target = %s/%s, want %s/%s", i, bundle.GOOS, bundle.GOARCH, want.goos, want.goarch) + } + if err := bundle.validateForRuntime(m.RuntimeVersion); err != nil { + return fmt.Errorf("driverbundle: bundle %d: %w", i, err) + } + } + return nil +} + +// ValidateFor binds a manifest to both module and runtime trust roots. +func (m Manifest) ValidateFor(lock release.RuntimeLock, pin Pin, runtimePin release.RuntimeManifestPin) error { + if err := lock.Validate(); err != nil { + return err + } + if err := pin.Validate(); err != nil { + return err + } + if err := runtimePin.ValidateForLock(lock); err != nil { + return err + } + if err := m.Validate(); err != nil { + return err + } + if m.SPXModule != pin.SPXModule || m.SPXVersion != pin.SPXVersion || m.RuntimeVersion != pin.RuntimeVersion { + return errors.New("driverbundle: manifest identity does not match pin") + } + if m.RuntimeVersion != lock.RuntimeVersion || m.RuntimeABI != lock.RuntimeABI { + return errors.New("driverbundle: manifest runtime identity does not match lock") + } + if m.ReleaseRepository != lock.ReleaseRepository { + return errors.New("driverbundle: manifest release repository does not match lock") + } + lockSHA, err := lock.SHA256() + if err != nil { + return err + } + if m.RuntimeLockSHA256 != lockSHA { + return errors.New("driverbundle: manifest runtime lock digest does not match lock") + } + if m.RuntimeManifestSHA256 != runtimePin.SHA256 { + return errors.New("driverbundle: manifest runtime manifest digest does not match pin") + } + if m.GoVersion != lock.Toolchain.Go { + return errors.New("driverbundle: manifest Go version does not match lock") + } + return nil +} + +// Validate checks a bundle's generic structure. +func (b Bundle) Validate() error { return b.validateForRuntime("") } + +// ValidateForRuntime checks the canonical target names and modes. +func (b Bundle) ValidateForRuntime(runtimeVersion string) error { + if err := validateRuntimeVersion(runtimeVersion); err != nil { + return fmt.Errorf("driverbundle: %w", err) + } + return b.validateForRuntime(runtimeVersion) +} + +func (b Bundle) validateForRuntime(runtimeVersion string) error { + if !platformPattern.MatchString(b.GOOS) || !platformPattern.MatchString(b.GOARCH) { + return fmt.Errorf("invalid bundle platform %q/%q", b.GOOS, b.GOARCH) + } + if err := validateBundleName(b.Name); err != nil { + return err + } + if runtimeVersion != "" && b.Name != expectedBundleName(b.GOOS, b.GOARCH) { + return fmt.Errorf("bundle name = %q, want %q", b.Name, expectedBundleName(b.GOOS, b.GOARCH)) + } + if b.Size <= 0 { + return fmt.Errorf("bundle %q size must be positive", b.Name) + } + if err := validateSHA256(b.SHA256); err != nil { + return fmt.Errorf("bundle %q SHA-256: %w", b.Name, err) + } + if err := validateSHA256(b.EngineInterfaceDigest); err != nil { + return fmt.Errorf("bundle %q engine interface digest: %w", b.Name, err) + } + if len(b.Files) != 3 { + return fmt.Errorf("bundle %q must contain exactly three files", b.Name) + } + seen := make(map[string]struct{}, len(b.Files)) + for i, file := range b.Files { + if err := file.Validate(); err != nil { + return fmt.Errorf("bundle %q file %d: %w", b.Name, i, err) + } + if _, ok := seen[file.Name]; ok { + return fmt.Errorf("bundle %q has duplicate file %q", b.Name, file.Name) + } + seen[file.Name] = struct{}{} + } + wantInterface, err := ComputeEngineInterfaceDigestFromSHA256(b.Files[0].SHA256, b.Files[1].SHA256) + if err != nil { + return fmt.Errorf("bundle %q Engine interface: %w", b.Name, err) + } + if b.EngineInterfaceDigest != wantInterface { + return fmt.Errorf("bundle %q Engine interface digest does not match Engine and PCK", b.Name) + } + if runtimeVersion == "" { + return nil + } + wantNames := expectedFileNames(runtimeVersion, b.GOOS, b.GOARCH) + wantModes := [...]uint32{0o755, 0o644, 0o755} + for i := range b.Files { + if b.Files[i].Name != wantNames[i] || b.Files[i].Mode != wantModes[i] { + return fmt.Errorf("bundle %q file %d identity does not match target", b.Name, i) + } + } + return nil +} + +// Validate checks one regular file record. +func (f File) Validate() error { + if err := validateBaseName(f.Name); err != nil { + return fmt.Errorf("file %q: %w", f.Name, err) + } + if f.Mode == 0 || f.Mode&^uint32(0o777) != 0 { + return fmt.Errorf("file %q has invalid mode %#o", f.Name, f.Mode) + } + if f.Size <= 0 { + return fmt.Errorf("file %q size must be positive", f.Name) + } + if err := validateSHA256(f.SHA256); err != nil { + return fmt.Errorf("file %q SHA-256: %w", f.Name, err) + } + return nil +} diff --git a/internal/launchpack/assets_verify.go b/internal/launchpack/assets_verify.go new file mode 100644 index 000000000..f5f6e6b6a --- /dev/null +++ b/internal/launchpack/assets_verify.go @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package launchpack + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + + "github.com/goplus/spx/v3/internal/driverbundle" +) + +// Verify checks the files represented by the asset set. Published assets are +// checked against their immutable component identity; source assets are only +// checked for regular, non-symlink paths. +func (a Assets) Verify() error { + for _, file := range []struct { + label string + path string + }{ + {"Engine", a.EnginePath}, {"runtime PCK", a.PackPath}, {"interpreter bridge", a.BridgePath}, + } { + if file.path == "" { + return fmt.Errorf("launchpack: %s path is required", file.label) + } + if err := validateRuntimeFile(file.path, file.label); err != nil { + return err + } + } + if a.Published == nil { + return nil + } + if err := a.Published.validate(); err != nil { + return err + } + engine, err := assetDigest("Engine", a.EnginePath, a.Published.EngineSHA256) + if err != nil { + return err + } + pack, err := assetDigest("runtime PCK", a.PackPath, a.Published.PackSHA256) + if err != nil { + return err + } + bridge, err := assetDigest("interpreter bridge", a.BridgePath, a.Published.BridgeSHA256) + if err != nil { + return err + } + interfaceDigest, err := driverbundle.ComputeEngineInterfaceDigestFromSHA256(engine, pack) + if err != nil { + return fmt.Errorf("launchpack: compute published Engine interface: %w", err) + } + return a.Published.verifyDigests(engine, pack, bridge, interfaceDigest) +} + +func (p PublishedDriverIdentity) validate() error { + for _, item := range []struct{ name, digest string }{ + {"manifest", p.ManifestSHA256}, {"bundle", p.BundleSHA256}, + {"Engine", p.EngineSHA256}, {"runtime PCK", p.PackSHA256}, + {"interpreter bridge", p.BridgeSHA256}, {"Engine interface", p.EngineInterfaceDigest}, + } { + if !validSHA256(item.digest) { + return fmt.Errorf("launchpack: published driver %s SHA-256 is invalid", item.name) + } + } + for _, item := range []struct{ name, value string }{ + {"bundle name", p.BundleName}, {"SPX version", p.SPXVersion}, {"producer commit", p.ProducerCommit}, + } { + if strings.TrimSpace(item.value) == "" { + return fmt.Errorf("launchpack: published driver %s is required", item.name) + } + } + return nil +} + +func (p PublishedDriverIdentity) verifyDigests(engine, pack, bridge, interfaceDigest string) error { + for _, values := range [][3]string{ + {"Engine", engine, p.EngineSHA256}, {"runtime PCK", pack, p.PackSHA256}, + {"interpreter bridge", bridge, p.BridgeSHA256}, + } { + name, got, want := values[0], values[1], values[2] + if got != want { + return fmt.Errorf("launchpack: published %s SHA-256 = %s, want %s", name, got, want) + } + } + if interfaceDigest != p.EngineInterfaceDigest { + return fmt.Errorf("launchpack: published Engine interface digest = %s, want %s", interfaceDigest, p.EngineInterfaceDigest) + } + return nil +} + +func assetDigest(label, path, want string) (string, error) { + size, digest, err := hashRuntimeFile(path) + if err != nil { + return "", fmt.Errorf("launchpack: hash %s: %w", label, err) + } + if digest != want { + return "", fmt.Errorf("launchpack: %s SHA-256 = %s, want %s (size %d)", label, digest, want, size) + } + return digest, nil +} + +func validSHA256(value string) bool { + if len(value) != sha256.Size*2 { + return false + } + _, err := hex.DecodeString(value) + return err == nil && value == strings.ToLower(value) +} diff --git a/internal/launchpack/assets_verify_test.go b/internal/launchpack/assets_verify_test.go new file mode 100644 index 000000000..9fecc48d0 --- /dev/null +++ b/internal/launchpack/assets_verify_test.go @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package launchpack + +import ( + "bytes" + "context" + "os" + "strings" + "testing" +) + +var publishedAssetPaths = []struct { + name string + path func(Assets) string +}{ + {"Engine", func(a Assets) string { return a.EnginePath }}, + {"PCK", func(a Assets) string { return a.PackPath }}, + {"bridge", func(a Assets) string { return a.BridgePath }}, +} + +func TestLauncherPayloadRejectsSameSizePublishedMutationAfterAcquire(t *testing.T) { + for _, test := range publishedAssetPaths { + t.Run(test.name, func(t *testing.T) { + fixture := newPublishedDriverFixture(t) + cfg, project := publishedPayloadConfig(t) + assets, err := acquirePublishedDriverWith(context.Background(), cfg, IO{}, fixture.lock, fixture.driverPin, fixture.dependencies(cfg.RuntimeCacheRoot, fixture.fetcher(nil, new(int)))) + if err != nil { + t.Fatal(err) + } + defer assets.Cleanup() + mutateSameSizeFile(t, test.path(assets)) + var payload bytes.Buffer + if _, _, err := writeLauncherPayload(t.TempDir(), &payload, cfg, assets, project, IO{}); err == nil || !strings.Contains(err.Error(), "SHA-256") { + t.Fatalf("same-size %s mutation error = %v", test.name, err) + } + }) + } +} + +func TestAssetsVerifyRejectsSameSizePublishedMutation(t *testing.T) { + fixture := newPublishedDriverFixture(t) + for _, test := range publishedAssetPaths { + t.Run(test.name, func(t *testing.T) { + assets := publishedPayloadAssets(t, fixture) + if err := assets.Verify(); err != nil { + t.Fatal(err) + } + mutateSameSizeFile(t, test.path(assets)) + if err := assets.Verify(); err == nil || !strings.Contains(err.Error(), "SHA-256") { + t.Fatalf("same-size %s mutation verification error = %v", test.name, err) + } + }) + } +} + +func mutateSameSizeFile(t *testing.T, path string) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + offset := len(data) / 2 + if len(data) == 0 { + t.Fatal("cannot mutate an empty file") + } + data[offset] ^= 1 + file, err := os.OpenFile(path, os.O_WRONLY, 0) + if err != nil { + t.Fatal(err) + } + _, writeErr := file.WriteAt(data[offset:offset+1], int64(offset)) + closeErr := file.Close() + if writeErr != nil || closeErr != nil { + t.Fatalf("mutate %s: write=%v close=%v", path, writeErr, closeErr) + } +} diff --git a/internal/launchpack/bridge_build.go b/internal/launchpack/bridge_build.go new file mode 100644 index 000000000..f489c7c4c --- /dev/null +++ b/internal/launchpack/bridge_build.go @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package launchpack + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" +) + +func buildSourceBridge(ctx context.Context, cfg Config, bridgeName string, streams IO) (string, func(), error) { + if err := cfg.validateGraphInputs(); err != nil { + return "", nil, err + } + if err := cfg.verifyGraph(ctx, "before source bridge build"); err != nil { + return "", nil, err + } + if cfg.BridgePackage == "" { + return "", nil, fmt.Errorf("launchpack: bridge package is required") + } + workDir, err := os.MkdirTemp("", "spx-launchpack-bridge-") + if err != nil { + return "", nil, fmt.Errorf("launchpack: create source bridge work directory: %w", err) + } + cleanup := func() { _ = os.RemoveAll(workDir) } + if hasBuildFlag(cfg.BuildFlags, "work") { + cleanup = func() {} + if streams.Stderr != nil { + _, _ = fmt.Fprintf(streams.Stderr, "SPXBRIDGEWORK=%s\n", workDir) + } + } + bridgePath := filepath.Join(workDir, bridgeName) + command := exec.CommandContext(ctx, cfg.GoCommand, sourceBridgeBuildArgs(cfg, bridgePath)...) + command.Dir, command.Env = cfg.WorkDir, sourceBridgeEnv(cfg, streams.Env) + command.Stdin, command.Stdout, command.Stderr = streams.Stdin, streams.Stdout, streams.Stderr + if err := command.Run(); err != nil { + cleanup() + return "", nil, fmt.Errorf("launchpack: build source interpreter bridge: %w", err) + } + if err := cfg.verifyGraph(ctx, "after source bridge build"); err != nil { + cleanup() + return "", nil, err + } + if err := validatePinnedFile("source interpreter bridge", bridgePath); err != nil { + cleanup() + return "", nil, err + } + return bridgePath, cleanup, nil +} + +func sourceBridgeBuildArgs(cfg Config, bridgePath string) []string { + return sourceBridgeBuildArgsForGOOS(cfg, bridgePath, runtime.GOOS) +} + +func sourceBridgeBuildArgsForGOOS(cfg Config, bridgePath, goos string) []string { + args := append([]string{"build"}, cfg.GraphFlags...) + args = append(args, normalizedGoBuildFlags(cfg.BuildFlags)...) + args = append(args, "-buildmode=c-shared") + if goos == "windows" { + args = append(args, "-ldflags=-extldflags=-Wl,--allow-multiple-definition") + } + return append(args, "-o", bridgePath, cfg.BridgePackage) +} + +func bridgeFileName(goos, goarch string) (string, error) { + extension := map[string]string{"darwin": ".dylib", "linux": ".so", "windows": ".dll"}[goos] + if extension == "" { + return "", fmt.Errorf("launchpack: host platform %s/%s is not supported", goos, goarch) + } + return "gdspx-" + goos + "-" + goarch + extension, nil +} diff --git a/internal/launchpack/driver_published.go b/internal/launchpack/driver_published.go new file mode 100644 index 000000000..37322dd23 --- /dev/null +++ b/internal/launchpack/driver_published.go @@ -0,0 +1,234 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package launchpack + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "runtime" + + "github.com/goplus/spx/v3/internal/driverbundle" + "github.com/goplus/spx/v3/internal/release" + "github.com/goplus/spx/v3/internal/runtimebundle" +) + +const driverAssetDirEnv = "SPX_DRIVER_ASSET_DIR" + +type driverAssetDependencies struct { + fetch runtimebundle.FetchFunc + cacheRoot func() string + driverPin func(string) (driverbundle.Pin, error) + runtimePin func(release.RuntimeLock) (release.RuntimeManifestPin, error) +} + +func defaultDriverAssetDependencies() driverAssetDependencies { + return driverAssetDependencies{ + fetch: fetchReleaseURL, + cacheRoot: runtimebundle.DefaultCacheRoot, + driverPin: driverbundle.ForVersion, + runtimePin: release.RuntimeManifestPinForLock, + } +} + +// AcquirePublishedDriver resolves one pinned combined Engine/PCK/bridge ZIP. +// Published mode never builds or borrows a bridge from the Go graph. +func AcquirePublishedDriver(ctx context.Context, cfg Config) (Assets, error) { + deps := defaultDriverAssetDependencies() + lock, pin, err := publishedDriverIdentity(cfg, deps) + if err != nil { + return Assets{}, err + } + return acquirePublishedDriverWith(ctx, cfg, cfg.IO, lock, pin, deps) +} + +func publishedDriverIdentity(cfg Config, deps driverAssetDependencies) (release.RuntimeLock, driverbundle.Pin, error) { + if err := validatePublishedSource(cfg.Source); err != nil { + return release.RuntimeLock{}, driverbundle.Pin{}, err + } + if deps.driverPin == nil { + return release.RuntimeLock{}, driverbundle.Pin{}, errors.New("launchpack: incomplete published driver dependencies") + } + pin, err := deps.driverPin(cfg.Source.SelectedVersion) + if err != nil { + return release.RuntimeLock{}, driverbundle.Pin{}, fmt.Errorf("launchpack: resolve published driver pin: %w", err) + } + if err := pin.Validate(); err != nil { + return release.RuntimeLock{}, driverbundle.Pin{}, fmt.Errorf("launchpack: validate published driver pin: %w", err) + } + lock := cfg.RuntimeLock + if lock.RuntimeVersion == "" { + lock, err = release.RuntimeLockForVersion(pin.RuntimeVersion) + if err != nil { + return release.RuntimeLock{}, driverbundle.Pin{}, fmt.Errorf("launchpack: resolve runtime lock for published driver: %w", err) + } + } else if lock, err = runtimeLock(cfg); err != nil { + return release.RuntimeLock{}, driverbundle.Pin{}, err + } + if pin.SPXModule != driverbundle.SPXModulePath || pin.SPXVersion != cfg.Source.SelectedVersion || pin.RuntimeVersion != lock.RuntimeVersion { + return release.RuntimeLock{}, driverbundle.Pin{}, errors.New("launchpack: published driver pin identity does not match selected module and runtime lock") + } + derived := cfg + derived.RuntimeLock = lock + lock, err = runtimeLock(derived) + if err != nil { + return release.RuntimeLock{}, driverbundle.Pin{}, err + } + return lock, pin, nil +} + +func acquirePublishedDriverWith(ctx context.Context, cfg Config, streams IO, lock release.RuntimeLock, pin driverbundle.Pin, deps driverAssetDependencies) (Assets, error) { + if ctx == nil { + return Assets{}, errors.New("launchpack: nil context") + } + if err := ctx.Err(); err != nil { + return Assets{}, err + } + if err := validatePublishedSource(cfg.Source); err != nil { + return Assets{}, err + } + if cfg.RuntimeSourceRoot != "" || cfg.RuntimeManifestPath != "" || cfg.RuntimeAssetDir != "" || cfg.BridgePackage != "" { + return Assets{}, errors.New("launchpack: published mode accepts only the combined driver asset inputs") + } + if deps.fetch == nil || deps.cacheRoot == nil || deps.runtimePin == nil { + return Assets{}, errors.New("launchpack: incomplete published driver dependencies") + } + env := publishedDriverEnvironment(cfg, streams.Env) + if _, found, duplicate := environmentValue(env, runtimeLocalManifestEnv); duplicate { + return Assets{}, fmt.Errorf("launchpack: duplicate %s", runtimeLocalManifestEnv) + } else if found { + return Assets{}, fmt.Errorf("launchpack: %s is not supported in published mode", runtimeLocalManifestEnv) + } + assetDir, assetDirSet, duplicate := environmentValue(env, driverAssetDirEnv) + if duplicate { + return Assets{}, fmt.Errorf("launchpack: duplicate %s", driverAssetDirEnv) + } + if assetDirSet { + if assetDir == "" { + return Assets{}, fmt.Errorf("launchpack: %s must not be empty", driverAssetDirEnv) + } + if !filepath.IsAbs(assetDir) || filepath.Clean(assetDir) != assetDir { + return Assets{}, fmt.Errorf("launchpack: %s must be an absolute clean path", driverAssetDirEnv) + } + } + cacheRoot, err := resolveRuntimeCacheRoot(env, func() string { return deps.cacheRoot() }) + if err != nil { + return Assets{}, err + } + offline, err := runtimeOffline(env) + if err != nil { + return Assets{}, err + } + offline = offline || cfg.RuntimeOffline + + if err := pin.Validate(); err != nil { + return Assets{}, fmt.Errorf("launchpack: validate published driver pin: %w", err) + } + if pin.SPXModule != driverbundle.SPXModulePath || pin.SPXVersion != cfg.Source.SelectedVersion || pin.RuntimeVersion != lock.RuntimeVersion { + return Assets{}, errors.New("launchpack: published driver pin identity does not match selected module and runtime lock") + } + runtimePin, err := deps.runtimePin(lock) + if err != nil { + return Assets{}, fmt.Errorf("launchpack: resolve runtime manifest pin for published driver: %w", err) + } + if err := runtimePin.ValidateForLock(lock); err != nil { + return Assets{}, fmt.Errorf("launchpack: validate runtime manifest pin for published driver: %w", err) + } + + releaseRoot := filepath.Join(cacheRoot, "downloads", "driver", pin.SHA256) + manifestURL, err := driverbundle.ManifestURL(pin) + if err != nil { + return Assets{}, fmt.Errorf("launchpack: build published driver manifest URL: %w", err) + } + var manifestData []byte + if assetDirSet { + manifestData, err = readRegularFile(filepath.Join(assetDir, pin.Name)) + if err == nil { + err = verifyDriverManifestPin(manifestData, pin) + } + } else { + manifestFile, acquireErr := acquireDriverFile(ctx, releaseRoot, pin.Name, pin.Size, pin.SHA256, manifestURL, "", offline, deps.fetch) + if acquireErr == nil { + manifestData, acquireErr = readPinnedDriverManifest(manifestFile, pin) + closeErr := manifestFile.Close() + if acquireErr == nil { + acquireErr = closeErr + } + } + err = acquireErr + } + if err != nil { + return Assets{}, fmt.Errorf("launchpack: acquire published driver manifest: %w", err) + } + manifest, err := driverbundle.Parse(manifestData) + if err != nil { + return Assets{}, err + } + if err := manifest.ValidateFor(lock, pin, runtimePin); err != nil { + return Assets{}, fmt.Errorf("launchpack: validate published driver manifest: %w", err) + } + bundle, err := manifest.BundleFor(runtime.GOOS, runtime.GOARCH) + if err != nil { + return Assets{}, err + } + + bundleURL, err := manifest.DownloadURL(bundle.Name) + if err != nil { + return Assets{}, fmt.Errorf("launchpack: build published driver bundle URL: %w", err) + } + cache := runtimebundle.NewCache(cacheRoot) + expected, err := expectedDriverBundle(bundle) + if err != nil { + return Assets{}, err + } + var bundleFile *runtimebundle.AcquiredFile + if assetDirSet { + bundleFile, err = acquireDriverFile(ctx, releaseRoot, bundle.Name, bundle.Size, bundle.SHA256, bundleURL, assetDir, offline, deps.fetch) + if err != nil { + return Assets{}, fmt.Errorf("launchpack: acquire published driver bundle: %w", err) + } + defer bundleFile.Close() + } + if materialized, found, err := cache.Lookup(ctx, runtimebundle.NamespaceDriver, &expected); err != nil { + return Assets{}, fmt.Errorf("launchpack: inspect cached published driver bundle: %w", err) + } else if found { + assets, err := publishedDriverAssets(materialized, bundle, manifest, pin, lock) + if err != nil { + _ = materialized.Close() + return Assets{}, err + } + return assets, nil + } + if bundleFile == nil { + bundleFile, err = acquireDriverFile(ctx, releaseRoot, bundle.Name, bundle.Size, bundle.SHA256, bundleURL, assetDir, offline, deps.fetch) + if err != nil { + return Assets{}, fmt.Errorf("launchpack: acquire published driver bundle: %w", err) + } + defer bundleFile.Close() + } + materialized, err := cache.Materialize(ctx, runtimebundle.NamespaceDriver, filepath.Join(releaseRoot, bundle.Name), &expected) + if err != nil { + return Assets{}, fmt.Errorf("launchpack: materialize published driver bundle: %w", err) + } + assets, err := publishedDriverAssets(materialized, bundle, manifest, pin, lock) + if err != nil { + _ = materialized.Close() + return Assets{}, err + } + return assets, nil +} diff --git a/internal/launchpack/driver_published_acquire_test.go b/internal/launchpack/driver_published_acquire_test.go new file mode 100644 index 000000000..80c7933ec --- /dev/null +++ b/internal/launchpack/driver_published_acquire_test.go @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package launchpack + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/goplus/spx/v3/internal/driverbundle" +) + +func TestAcquirePublishedDriverUsesOnePinnedCombinedBundle(t *testing.T) { + fixture := newPublishedDriverFixture(t) + cacheRoot := t.TempDir() + calls := 0 + assets, err := acquirePublishedDriverWith(context.Background(), publishedDriverTestConfig(cacheRoot), IO{}, fixture.lock, fixture.driverPin, fixture.dependencies(cacheRoot, fixture.fetcher(nil, &calls))) + if err != nil { + t.Fatalf("acquire published driver: %v", err) + } + if calls != 2 { + t.Fatalf("fetch count = %d, want pinned manifest and one combined bundle", calls) + } + if got, err := os.ReadFile(assets.EnginePath); err != nil || !bytes.Equal(got, fixture.engine) { + t.Fatalf("Engine = %q, err=%v", got, err) + } + if got, err := os.ReadFile(assets.PackPath); err != nil || !bytes.Equal(got, fixture.pack) { + t.Fatalf("PCK = %q, err=%v", got, err) + } + if got, err := os.ReadFile(assets.BridgePath); err != nil || !bytes.Equal(got, fixture.bridge) { + t.Fatalf("bridge = %q, err=%v", got, err) + } + if assets.Published == nil || assets.Published.ManifestSHA256 != fixture.driverPin.SHA256 || assets.Published.BundleSHA256 != fixture.bundle.SHA256 || assets.Published.BundleName != fixture.bundle.Name { + t.Fatalf("published provenance = %#v", assets) + } + assets.Cleanup() + + // A materialized cache hit is checked before the downloadable ZIP. + bundleCache := filepath.Join(cacheRoot, "downloads", "driver", fixture.driverPin.SHA256, fixture.bundle.Name) + if err := os.Remove(bundleCache); err != nil { + t.Fatal(err) + } + offlineCalls := 0 + offline, err := acquirePublishedDriverWith(context.Background(), publishedDriverTestConfig(cacheRoot), IO{Env: []string{runtimeOfflineEnv + "=1"}}, fixture.lock, fixture.driverPin, fixture.dependencies(cacheRoot, fixture.fetcher(nil, &offlineCalls))) + if err != nil { + t.Fatalf("offline materialized cache hit: %v", err) + } + offline.Cleanup() + if offlineCalls != 0 { + t.Fatalf("offline fetch count = %d, want 0", offlineCalls) + } +} + +func TestAcquirePublishedDriverUsesExplicitDriverMirrorOnly(t *testing.T) { + fixture := newPublishedDriverFixture(t) + assetDir := t.TempDir() + if err := os.WriteFile(filepath.Join(assetDir, fixture.driverPin.Name), fixture.manifestData, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(assetDir, fixture.bundle.Name), fixture.bundleData, 0o600); err != nil { + t.Fatal(err) + } + calls := 0 + cfg := publishedDriverTestConfig(t.TempDir()) + cfg.DriverAssetDir = assetDir + assets, err := acquirePublishedDriverWith(context.Background(), cfg, IO{Env: []string{"SPX_RUNTIME_ASSET_DIR=/must-not-be-used"}}, fixture.lock, fixture.driverPin, fixture.dependencies(cfg.RuntimeCacheRoot, fixture.fetcher(nil, &calls))) + if err != nil { + t.Fatalf("acquire from explicit driver mirror: %v", err) + } + assets.Cleanup() + if calls != 0 { + t.Fatalf("mirror fetch count = %d, want 0", calls) + } +} + +func TestAcquirePublishedDriverCacheBindsOuterBundleDigest(t *testing.T) { + fixture := newPublishedDriverFixture(t) + cacheRoot := t.TempDir() + firstCalls := 0 + first, err := acquirePublishedDriverWith(context.Background(), publishedDriverTestConfig(cacheRoot), IO{}, fixture.lock, fixture.driverPin, fixture.dependencies(cacheRoot, fixture.fetcher(nil, &firstCalls))) + if err != nil { + t.Fatal(err) + } + firstEnginePath := first.EnginePath + first.Cleanup() + if firstCalls != 2 { + t.Fatalf("first fetch count = %d, want 2", firstCalls) + } + + updated := fixture + bridgeName, err := bridgeFileName(updated.bundle.GOOS, updated.bundle.GOARCH) + if err != nil { + t.Fatal(err) + } + updated.bundleData = writeRuntimeZip(t, filepath.Join(t.TempDir(), "reencoded.zip"), + runtimeZipEntry{Name: updated.spec.PackName, Mode: 0o644, Data: updated.pack}, + runtimeZipEntry{Name: updated.spec.RuntimeName, Mode: 0o755, Data: updated.engine}, + runtimeZipEntry{Name: bridgeName, Mode: 0o755, Data: updated.bridge}, + ) + updated.bundle.Size = int64(len(updated.bundleData)) + updated.bundle.SHA256 = digestBytes(updated.bundleData) + if updated.bundle.SHA256 == fixture.bundle.SHA256 { + t.Fatal("re-encoded fixture unexpectedly kept the outer bundle digest") + } + updated.manifest.Bundles = append([]driverbundle.Bundle(nil), fixture.manifest.Bundles...) + for i, candidate := range updated.manifest.Bundles { + if candidate.GOOS == updated.bundle.GOOS && candidate.GOARCH == updated.bundle.GOARCH { + updated.manifest.Bundles[i] = updated.bundle + break + } + } + updated.manifestData, err = updated.manifest.JSON() + if err != nil { + t.Fatal(err) + } + updated.driverPin.Size = int64(len(updated.manifestData)) + updated.driverPin.SHA256 = digestBytes(updated.manifestData) + + secondCalls := 0 + second, err := acquirePublishedDriverWith(context.Background(), publishedDriverTestConfig(cacheRoot), IO{}, updated.lock, updated.driverPin, updated.dependencies(cacheRoot, updated.fetcher(nil, &secondCalls))) + if err != nil { + t.Fatalf("acquire updated published driver: %v", err) + } + defer second.Cleanup() + if secondCalls != 2 { + t.Fatalf("updated fetch count = %d, want manifest and new bundle", secondCalls) + } + if second.EnginePath == firstEnginePath { + t.Fatalf("updated outer digest reused materialized target %q", second.EnginePath) + } + if second.Published == nil || second.Published.BundleSHA256 != updated.bundle.SHA256 { + t.Fatalf("updated published provenance = %#v", second.Published) + } +} + +func TestAcquirePublishedDriverRejectsPinnedManifestAndBundleMismatch(t *testing.T) { + fixture := newPublishedDriverFixture(t) + cacheRoot := t.TempDir() + badManifestData := bytes.Replace(fixture.manifestData, []byte(fixture.bundle.EngineInterfaceDigest), []byte(strings.Repeat("0", 64)), 1) + badPin := fixture.driverPin + badPin.Size, badPin.SHA256 = int64(len(badManifestData)), digestBytes(badManifestData) + deps := fixture.dependencies(cacheRoot, fixture.fetcher(map[string][]byte{fixture.driverPin.Name: badManifestData}, new(int))) + if _, err := acquirePublishedDriverWith(context.Background(), publishedDriverTestConfig(cacheRoot), IO{}, fixture.lock, badPin, deps); err == nil || !strings.Contains(err.Error(), "interface digest") { + t.Fatalf("bundle interface mismatch error = %v", err) + } + + badZip := append([]byte(nil), fixture.bundleData...) + badZip[len(badZip)/2] ^= 1 + calls := 0 + deps = fixture.dependencies(t.TempDir(), fixture.fetcher(map[string][]byte{fixture.bundle.Name: badZip}, &calls)) + if _, err := acquirePublishedDriverWith(context.Background(), publishedDriverTestConfig(deps.cacheRoot()), IO{}, fixture.lock, fixture.driverPin, deps); err == nil || !strings.Contains(err.Error(), "SHA-256") { + t.Fatalf("outer bundle digest mismatch error = %v", err) + } + if calls != 2 { + t.Fatalf("bad bundle fetch count = %d, want manifest and bundle", calls) + } +} + +func TestAcquirePublishedDriverRejectsInvalidZIPContents(t *testing.T) { + fixture := newPublishedDriverFixture(t) + // The ZIP has a valid outer digest but does not satisfy the pinned entries. + badPath := filepath.Join(t.TempDir(), "bad.zip") + badData := writeRuntimeZip(t, badPath, + runtimeZipEntry{Name: fixture.spec.RuntimeName, Mode: 0o755, Data: fixture.engine}, + runtimeZipEntry{Name: fixture.spec.PackName, Mode: 0o644, Data: fixture.pack}, + ) + fixture.bundle.Size = int64(len(badData)) + fixture.bundle.SHA256 = digestBytes(badData) + calls := 0 + deps := fixture.dependencies(t.TempDir(), fixture.fetcher(map[string][]byte{fixture.bundle.Name: badData}, &calls)) + if _, err := acquirePublishedDriverWith(context.Background(), publishedDriverTestConfig(deps.cacheRoot()), IO{}, fixture.lock, fixture.driverPin, deps); err == nil { + t.Fatal("published bundle with missing bridge was accepted") + } +} + +func TestAcquirePublishedDriverFetchErrorsStayFailClosed(t *testing.T) { + fixture := newPublishedDriverFixture(t) + calls := 0 + deps := fixture.dependencies(t.TempDir(), func(ctx context.Context, _ string, _ io.Writer) error { + calls++ + if err := ctx.Err(); err != nil { + return err + } + return fmt.Errorf("network unavailable") + }) + if _, err := acquirePublishedDriverWith(context.Background(), publishedDriverTestConfig(deps.cacheRoot()), IO{}, fixture.lock, fixture.driverPin, deps); err == nil { + t.Fatal("published fetch failure was accepted") + } + if calls != 1 { + t.Fatalf("fetch count = %d, want manifest only", calls) + } +} diff --git a/internal/launchpack/driver_published_boundaries_test.go b/internal/launchpack/driver_published_boundaries_test.go new file mode 100644 index 000000000..4a30b436e --- /dev/null +++ b/internal/launchpack/driver_published_boundaries_test.go @@ -0,0 +1,238 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package launchpack + +import ( + "context" + "errors" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + + "github.com/goplus/spx/v3/internal/driverbundle" + "github.com/goplus/spx/v3/internal/release" +) + +func TestAcquirePublishedDriverCanceledContextDoesNotFetch(t *testing.T) { + fixture := newPublishedDriverFixture(t) + canceled, cancel := context.WithCancel(context.Background()) + cancel() + calls := 0 + deps := fixture.dependencies(t.TempDir(), func(context.Context, string, io.Writer) error { + calls++ + return errors.New("canceled acquisition fetched") + }) + _, err := acquirePublishedDriverWith(canceled, publishedDriverTestConfig(deps.cacheRoot()), IO{}, fixture.lock, fixture.driverPin, deps) + if !errors.Is(err, context.Canceled) { + t.Fatalf("canceled acquisition error = %v", err) + } + if calls != 0 { + t.Fatalf("fetch count = %d, want 0", calls) + } +} + +func TestAcquirePublishedDriverOfflineColdCacheFailsClosed(t *testing.T) { + fixture := newPublishedDriverFixture(t) + calls := 0 + deps := fixture.dependencies(t.TempDir(), fixture.fetcher(nil, &calls)) + cfg := publishedDriverTestConfig(deps.cacheRoot()) + cfg.RuntimeOffline = true + if _, err := acquirePublishedDriverWith(context.Background(), cfg, IO{}, fixture.lock, fixture.driverPin, deps); err == nil { + t.Fatal("offline cold published acquisition succeeded") + } + if calls != 0 { + t.Fatalf("offline fetch count = %d, want 0", calls) + } +} + +func TestAcquirePublishedDriverExplicitMirrorManifestIsNeverHiddenByCache(t *testing.T) { + fixture := newPublishedDriverFixture(t) + cacheRoot := t.TempDir() + calls := 0 + assets, err := acquirePublishedDriverWith(context.Background(), publishedDriverTestConfig(cacheRoot), IO{}, fixture.lock, fixture.driverPin, fixture.dependencies(cacheRoot, fixture.fetcher(nil, &calls))) + if err != nil { + t.Fatal(err) + } + assets.Cleanup() + if calls != 2 { + t.Fatalf("warm-cache fetch count = %d, want 2", calls) + } + + for name, manifestData := range map[string][]byte{ + "invalid": []byte("not a driver manifest"), + "missing": nil, + } { + t.Run(name, func(t *testing.T) { + assetDir := t.TempDir() + if manifestData != nil { + if err := os.WriteFile(filepath.Join(assetDir, fixture.driverPin.Name), manifestData, 0o600); err != nil { + t.Fatal(err) + } + } + cfg := publishedDriverTestConfig(cacheRoot) + cfg.DriverAssetDir = assetDir + localCalls := 0 + deps := fixture.dependencies(cacheRoot, fixture.fetcher(nil, &localCalls)) + if _, err := acquirePublishedDriverWith(context.Background(), cfg, IO{}, fixture.lock, fixture.driverPin, deps); err == nil { + t.Fatal("explicit mirror manifest failure was hidden by warm cache") + } + if localCalls != 0 { + t.Fatalf("explicit mirror fetch count = %d, want 0", localCalls) + } + }) + } +} + +func TestAcquirePublishedDriverExplicitMirrorBundleIsNeverHiddenByCache(t *testing.T) { + fixture := newPublishedDriverFixture(t) + cacheRoot := t.TempDir() + warmCalls := 0 + warm, err := acquirePublishedDriverWith(context.Background(), publishedDriverTestConfig(cacheRoot), IO{}, fixture.lock, fixture.driverPin, fixture.dependencies(cacheRoot, fixture.fetcher(nil, &warmCalls))) + if err != nil { + t.Fatal(err) + } + warm.Cleanup() + if warmCalls != 2 { + t.Fatalf("warm-cache fetch count = %d, want 2", warmCalls) + } + + tampered := append([]byte(nil), fixture.bundleData...) + tampered[len(tampered)/2] ^= 1 + for name, bundleData := range map[string][]byte{"missing": nil, "tampered": tampered} { + t.Run(name, func(t *testing.T) { + assetDir := t.TempDir() + if err := os.WriteFile(filepath.Join(assetDir, fixture.driverPin.Name), fixture.manifestData, 0o600); err != nil { + t.Fatal(err) + } + if bundleData != nil { + if err := os.WriteFile(filepath.Join(assetDir, fixture.bundle.Name), bundleData, 0o600); err != nil { + t.Fatal(err) + } + } + cfg := publishedDriverTestConfig(cacheRoot) + cfg.DriverAssetDir = assetDir + localCalls := 0 + deps := fixture.dependencies(cacheRoot, fixture.fetcher(nil, &localCalls)) + if _, err := acquirePublishedDriverWith(context.Background(), cfg, IO{}, fixture.lock, fixture.driverPin, deps); err == nil { + t.Fatal("explicit mirror bundle failure was hidden by warm cache") + } + if localCalls != 0 { + t.Fatalf("explicit mirror fetch count = %d, want 0", localCalls) + } + }) + } +} + +func TestPublishedRuntimeLockFollowsDriverPin(t *testing.T) { + fixture := newPublishedDriverFixture(t) + historical, err := release.RuntimeLockForVersion("2.4.3") + if err != nil { + t.Fatal(err) + } + pin := fixture.driverPin + pin.RuntimeVersion = historical.RuntimeVersion + deps := fixture.dependencies(t.TempDir(), fixture.fetcher(nil, new(int))) + deps.driverPin = func(string) (driverbundle.Pin, error) { return pin, nil } + lock, gotPin, err := publishedDriverIdentity(publishedDriverTestConfig(t.TempDir()), deps) + if err != nil { + t.Fatal(err) + } + if lock.RuntimeVersion != historical.RuntimeVersion || gotPin.RuntimeVersion != historical.RuntimeVersion { + t.Fatalf("published identity = %s/%s, want historical %s", lock.RuntimeVersion, gotPin.RuntimeVersion, historical.RuntimeVersion) + } +} + +func TestPublishedConfigRejectsRuntimeAndSourceInputs(t *testing.T) { + base := validPublishedConfigForValidation(t) + tests := []struct { + name string + mutate func(*Config) + }{ + {"runtime manifest path", func(c *Config) { c.RuntimeManifestPath = filepath.Join(t.TempDir(), "runtime.json") }}, + {"runtime asset directory", func(c *Config) { c.RuntimeAssetDir = t.TempDir() }}, + {"runtime source root", func(c *Config) { c.RuntimeSourceRoot = t.TempDir() }}, + {"source bridge package", func(c *Config) { c.BridgePackage = "./bridge" }}, + {"source mode", func(c *Config) { c.Source.SourceMode = true }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cfg := base + test.mutate(&cfg) + if err := cfg.validate(); err == nil { + t.Fatal("invalid published configuration was accepted") + } + }) + } +} + +func TestSourceOnlyEntryPointsRejectPublishedMode(t *testing.T) { + cfg := Config{Source: SourceIdentity{SourceMode: false}} + if _, err := AcquireRuntimeAssets(context.Background(), cfg); err == nil { + t.Fatal("published mode entered runtime-only acquisition") + } + if _, _, err := BuildSourceBridge(context.Background(), cfg); err == nil { + t.Fatal("published mode entered source bridge build") + } +} + +func validPublishedConfigForValidation(t *testing.T) Config { + t.Helper() + projectDir := t.TempDir() + if err := os.WriteFile(filepath.Join(projectDir, "main.spx"), []byte("onStart => {}\n"), 0o600); err != nil { + t.Fatal(err) + } + goCommand, err := exec.LookPath("go") + if err != nil { + t.Skip(err) + } + return Config{ + ProjectDir: projectDir, ProjectFile: filepath.Join(projectDir, "main.spx"), ProjectExt: ".spx", + PackDir: "assets", PackIndex: "index.json", Output: filepath.Join(t.TempDir(), "launcher"), + GoCommand: goCommand, WorkDir: projectDir, GoWork: "off", + Source: SourceIdentity{SelectedPath: driverbundle.SPXModulePath, SelectedVersion: "v3.2.4", EffectivePath: driverbundle.SPXModulePath, EffectiveVersion: "v3.2.4"}, + } +} + +func TestPrepareAssetsSourceUsesRuntimeAndBridgePath(t *testing.T) { + lock := release.DefaultRuntimeLock() + spec, err := release.HostRuntimeSpecFor(lock, runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Fatal(err) + } + root := t.TempDir() + manifestPath := filepath.Join(root, "runtime-manifest.json") + publishLocalRuntimeTest(t, root, manifestPath, spec, "source-engine", "source-pack") + goCommand, err := exec.LookPath("go") + if err != nil { + t.Skip(err) + } + graphChecks := 0 + cfg := Config{ + RuntimeManifestPath: manifestPath, RuntimeCacheRoot: t.TempDir(), + Source: SourceIdentity{SourceMode: true}, GoCommand: goCommand, WorkDir: root, GoWork: "off", + BridgePackage: "./missing-bridge", VerifyGraph: func(context.Context) error { graphChecks++; return nil }, + } + if _, err := PrepareAssets(context.Background(), cfg); err == nil { + t.Fatal("source bridge build unexpectedly succeeded") + } + if graphChecks != 1 { + t.Fatalf("source graph checks = %d, want one source-bridge check", graphChecks) + } +} diff --git a/internal/launchpack/driver_published_bundle.go b/internal/launchpack/driver_published_bundle.go new file mode 100644 index 000000000..048e9800c --- /dev/null +++ b/internal/launchpack/driver_published_bundle.go @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package launchpack + +import ( + "errors" + "fmt" + "path/filepath" + "runtime" + + "github.com/goplus/spx/v3/internal/driverbundle" + "github.com/goplus/spx/v3/internal/release" + "github.com/goplus/spx/v3/internal/runtimebundle" +) + +func expectedDriverBundle(bundle driverbundle.Bundle) (runtimebundle.Bundle, error) { + entries := make([]runtimebundle.Entry, len(bundle.Files)) + for i, file := range bundle.Files { + entries[i] = runtimebundle.Entry{Name: file.Name, Mode: file.Mode, Size: file.Size, SHA256: file.SHA256} + } + return (runtimebundle.Bundle{ + Schema: runtimebundle.SchemaV1, + Namespace: runtimebundle.NamespaceDriver, + ArchiveSHA256: bundle.SHA256, + Entries: entries, + }).WithDigest() +} + +func publishedDriverAssets(materialized *runtimebundle.Materialized, bundle driverbundle.Bundle, manifest driverbundle.Manifest, pin driverbundle.Pin, lock release.RuntimeLock) (Assets, error) { + if materialized == nil { + return Assets{}, errors.New("launchpack: nil materialized published driver bundle") + } + if len(bundle.Files) != 3 { + return Assets{}, errors.New("launchpack: published driver bundle does not contain exactly three files") + } + paths := make(map[string]string, len(bundle.Files)) + digests := make(map[string]string, len(bundle.Files)) + for _, file := range bundle.Files { + path := filepath.Join(materialized.Path, filepath.FromSlash(file.Name)) + if err := validateRuntimeFile(path, "published driver component"); err != nil { + return Assets{}, err + } + size, digest, err := hashRuntimeFile(path) + if err != nil { + return Assets{}, fmt.Errorf("launchpack: hash published driver component %s: %w", file.Name, err) + } + if size != file.Size || digest != file.SHA256 { + return Assets{}, fmt.Errorf("launchpack: published driver component %s does not match manifest", file.Name) + } + paths[file.Name] = path + digests[file.Name] = digest + } + spec, err := release.HostRuntimeSpecFor(lock, runtime.GOOS, runtime.GOARCH) + if err != nil { + return Assets{}, err + } + enginePath, ok := paths[spec.RuntimeName] + if !ok { + return Assets{}, fmt.Errorf("launchpack: published driver bundle is missing %s", spec.RuntimeName) + } + packPath, ok := paths[spec.PackName] + if !ok { + return Assets{}, fmt.Errorf("launchpack: published driver bundle is missing %s", spec.PackName) + } + bridgeName, err := bridgeFileName(runtime.GOOS, runtime.GOARCH) + if err != nil { + return Assets{}, err + } + bridgePath, ok := paths[bridgeName] + if !ok { + return Assets{}, fmt.Errorf("launchpack: published driver bundle is missing %s", bridgeName) + } + engineDigest := digests[spec.RuntimeName] + packDigest := digests[spec.PackName] + bridgeDigest := digests[bridgeName] + interfaceDigest, err := driverbundle.ComputeEngineInterfaceDigestFromSHA256(engineDigest, packDigest) + if err != nil { + return Assets{}, fmt.Errorf("launchpack: hash published Engine interface: %w", err) + } + if interfaceDigest != bundle.EngineInterfaceDigest { + return Assets{}, fmt.Errorf("launchpack: published Engine interface digest = %s, want %s", interfaceDigest, bundle.EngineInterfaceDigest) + } + return Assets{ + EnginePath: enginePath, PackPath: packPath, BridgePath: bridgePath, Lock: lock, + Published: &PublishedDriverIdentity{ + ManifestSHA256: pin.SHA256, BundleSHA256: bundle.SHA256, BundleName: bundle.Name, + SPXVersion: manifest.SPXVersion, ProducerCommit: manifest.ProducerCommit, + EngineSHA256: engineDigest, PackSHA256: packDigest, BridgeSHA256: bridgeDigest, + EngineInterfaceDigest: interfaceDigest, + }, + Cleanup: func() { _ = materialized.Close() }, + }, nil +} diff --git a/internal/launchpack/driver_published_fixture_test.go b/internal/launchpack/driver_published_fixture_test.go new file mode 100644 index 000000000..4e00bfc8c --- /dev/null +++ b/internal/launchpack/driver_published_fixture_test.go @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package launchpack + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "path" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/goplus/spx/v3/internal/driverbundle" + "github.com/goplus/spx/v3/internal/release" + "github.com/goplus/spx/v3/internal/runtimebundle" +) + +type publishedDriverFixture struct { + lock release.RuntimeLock + spec release.HostRuntimeSpec + manifest driverbundle.Manifest + manifestData []byte + driverPin driverbundle.Pin + runtimePin release.RuntimeManifestPin + bundle driverbundle.Bundle + bundleData []byte + engine []byte + pack []byte + bridge []byte +} + +func newPublishedDriverFixture(t *testing.T) publishedDriverFixture { + t.Helper() + lock := release.DefaultRuntimeLock() + spec, err := release.HostRuntimeSpecFor(lock, runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Fatal(err) + } + engine := []byte("published-engine-" + runtime.GOOS + "-" + runtime.GOARCH) + pack := []byte("published-pack") + bridge := []byte("published-bridge") + bridgeName, err := bridgeFileName(runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Fatal(err) + } + root := t.TempDir() + bundlePath := filepath.Join(root, "bundle.zip") + bundleData := writeRuntimeZip(t, bundlePath, + runtimeZipEntry{Name: spec.RuntimeName, Mode: 0o755, Data: engine}, + runtimeZipEntry{Name: spec.PackName, Mode: 0o644, Data: pack}, + runtimeZipEntry{Name: bridgeName, Mode: 0o755, Data: bridge}, + ) + interfaceDigest, err := driverbundle.ComputeEngineInterfaceDigestFromSHA256(digestBytes(engine), digestBytes(pack)) + if err != nil { + t.Fatal(err) + } + hostBundle := driverbundle.Bundle{ + GOOS: runtime.GOOS, GOARCH: runtime.GOARCH, Name: "spx-driver-" + runtime.GOOS + "-" + runtime.GOARCH + ".zip", + Size: int64(len(bundleData)), SHA256: digestBytes(bundleData), EngineInterfaceDigest: interfaceDigest, + Files: []driverbundle.File{ + {Name: spec.RuntimeName, Mode: 0o755, Size: int64(len(engine)), SHA256: digestBytes(engine)}, + {Name: spec.PackName, Mode: 0o644, Size: int64(len(pack)), SHA256: digestBytes(pack)}, + {Name: bridgeName, Mode: 0o755, Size: int64(len(bridge)), SHA256: digestBytes(bridge)}, + }, + } + runtimeManifestData := []byte("pinned-runtime-manifest") + runtimePin := release.RuntimeManifestPin{ + Schema: 1, RuntimeVersion: lock.RuntimeVersion, Name: lock.Manifest, + Size: int64(len(runtimeManifestData)), SHA256: digestBytes(runtimeManifestData), + } + lockSHA, err := lock.SHA256() + if err != nil { + t.Fatal(err) + } + bundles := []driverbundle.Bundle{hostBundle} + for _, target := range []struct{ goos, goarch string }{{"darwin", "amd64"}, {"darwin", "arm64"}, {"linux", "amd64"}, {"windows", "amd64"}} { + if target.goos == runtime.GOOS && target.goarch == runtime.GOARCH { + continue + } + names := driverFixtureFileNames(lock.RuntimeVersion, target.goos, target.goarch) + otherEngine, otherPack, otherBridge := []byte("other-engine"), []byte("other-pack"), []byte("other-bridge") + otherInterface, err := driverbundle.ComputeEngineInterfaceDigestFromSHA256(digestBytes(otherEngine), digestBytes(otherPack)) + if err != nil { + t.Fatal(err) + } + bundles = append(bundles, driverbundle.Bundle{ + GOOS: target.goos, GOARCH: target.goarch, Name: "spx-driver-" + target.goos + "-" + target.goarch + ".zip", + Size: 100, SHA256: strings.Repeat("1", 64), EngineInterfaceDigest: otherInterface, + Files: []driverbundle.File{ + {Name: names[0], Mode: 0o755, Size: int64(len(otherEngine)), SHA256: digestBytes(otherEngine)}, + {Name: names[1], Mode: 0o644, Size: int64(len(otherPack)), SHA256: digestBytes(otherPack)}, + {Name: names[2], Mode: 0o755, Size: int64(len(otherBridge)), SHA256: digestBytes(otherBridge)}, + }, + }) + } + for i := 1; i < len(bundles); i++ { + for j := i; j > 0 && bundles[j].GOOS+"/"+bundles[j].GOARCH < bundles[j-1].GOOS+"/"+bundles[j-1].GOARCH; j-- { + bundles[j], bundles[j-1] = bundles[j-1], bundles[j] + } + } + manifest := driverbundle.Manifest{ + Schema: driverbundle.ManifestSchema, SPXModule: driverbundle.SPXModulePath, SPXVersion: "v3.2.4", + RuntimeVersion: lock.RuntimeVersion, RuntimeABI: lock.RuntimeABI, ReleaseRepository: lock.ReleaseRepository, + RuntimeLockSHA256: lockSHA, RuntimeManifestSHA256: runtimePin.SHA256, GoVersion: lock.Toolchain.Go, + ProducerCommit: strings.Repeat("a", 40), Bundles: bundles, + } + manifestData, err := manifest.JSON() + if err != nil { + t.Fatal(err) + } + driverPin := driverbundle.Pin{ + Schema: driverbundle.PinSchema, SPXModule: driverbundle.SPXModulePath, SPXVersion: manifest.SPXVersion, + RuntimeVersion: lock.RuntimeVersion, Name: driverbundle.ManifestName, + Size: int64(len(manifestData)), SHA256: digestBytes(manifestData), + } + return publishedDriverFixture{lock: lock, spec: spec, manifest: manifest, manifestData: manifestData, driverPin: driverPin, runtimePin: runtimePin, bundle: hostBundle, bundleData: bundleData, engine: engine, pack: pack, bridge: bridge} +} + +func driverFixtureFileNames(runtimeVersion, goos, goarch string) [3]string { + engine := "gdspxrt" + runtimeVersion + if goos == "windows" { + engine += ".exe" + } + extension := map[string]string{"darwin": ".dylib", "linux": ".so", "windows": ".dll"}[goos] + return [3]string{engine, "gdspxrt" + runtimeVersion + ".pck", "gdspx-" + goos + "-" + goarch + extension} +} + +func (f publishedDriverFixture) fetcher(replacements map[string][]byte, calls *int) runtimebundle.FetchFunc { + return func(ctx context.Context, url string, dst io.Writer) error { + if err := ctx.Err(); err != nil { + return err + } + (*calls)++ + name := path.Base(url) + data := replacements[name] + if data == nil { + switch name { + case f.driverPin.Name: + data = f.manifestData + case f.bundle.Name: + data = f.bundleData + } + } + if len(data) == 0 { + return fmt.Errorf("fixture has no driver asset %q", name) + } + _, err := io.Copy(dst, bytes.NewReader(data)) + return err + } +} + +func (f publishedDriverFixture) dependencies(cacheRoot string, fetch runtimebundle.FetchFunc) driverAssetDependencies { + return driverAssetDependencies{ + fetch: fetch, cacheRoot: func() string { return cacheRoot }, + driverPin: func(version string) (driverbundle.Pin, error) { + if version != f.driverPin.SPXVersion { + return driverbundle.Pin{}, driverbundle.ErrPinNotFound + } + return f.driverPin, nil + }, + runtimePin: func(lock release.RuntimeLock) (release.RuntimeManifestPin, error) { + if lock.RuntimeVersion != f.lock.RuntimeVersion { + return release.RuntimeManifestPin{}, errors.New("fixture lock mismatch") + } + return f.runtimePin, nil + }, + } +} + +func publishedDriverTestConfig(cacheRoot string) Config { + return Config{ + RuntimeCacheRoot: cacheRoot, + Source: SourceIdentity{ + SelectedPath: driverbundle.SPXModulePath, EffectivePath: driverbundle.SPXModulePath, + SelectedVersion: "v3.2.4", EffectiveVersion: "v3.2.4", + }, + } +} diff --git a/internal/launchpack/driver_published_payload_test.go b/internal/launchpack/driver_published_payload_test.go new file mode 100644 index 000000000..dfe4aa4f2 --- /dev/null +++ b/internal/launchpack/driver_published_payload_test.go @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package launchpack + +import ( + "archive/zip" + "bytes" + "encoding/json" + "io" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/goplus/spx/v3/internal/projectbundle" + "github.com/goplus/spx/v3/internal/runtimepayload" +) + +func TestLauncherPayloadRecordsPublishedDriverProvenance(t *testing.T) { + fixture := newPublishedDriverFixture(t) + assets := publishedPayloadAssets(t, fixture) + cfg, project := publishedPayloadConfig(t) + payload, payloadDigest, manifestDigest := buildLaunchpackPayloadTest(t, cfg, assets, project) + verified, err := runtimepayload.Verify(payload, payloadDigest, manifestDigest, runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Fatal(err) + } + if verified.Manifest.SPX.SourceMode || verified.Manifest.SPX.SelectedVersion != fixture.manifest.SPXVersion { + t.Fatalf("published payload source identity = %#v", verified.Manifest.SPX) + } + engineManifest := readPayloadJSONEntry(t, payload, "engine/runtime-manifest.json") + bridgeManifest := readPayloadJSONEntry(t, payload, "bridge/bridge-manifest.json") + for name, manifest := range map[string]map[string]any{"Engine": engineManifest, "bridge": bridgeManifest} { + if manifest["mode"] != "published" { + t.Fatalf("%s mode = %#v", name, manifest["mode"]) + } + } + assertPayloadProvenance(t, engineManifest, assets) + assertPayloadProvenance(t, bridgeManifest, assets) +} + +func TestLauncherPayloadSourceRemainsLocal(t *testing.T) { + fixture := newPublishedDriverFixture(t) + assets := publishedPayloadAssets(t, fixture) + assets.Published = nil + cfg, project := publishedPayloadConfig(t) + cfg.Source.SourceMode = true + payload, payloadDigest, manifestDigest := buildLaunchpackPayloadTest(t, cfg, assets, project) + verified, err := runtimepayload.Verify(payload, payloadDigest, manifestDigest, runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Fatal(err) + } + if !verified.Manifest.SPX.SourceMode { + t.Fatalf("source payload source identity = %#v", verified.Manifest.SPX) + } + for name, manifest := range map[string]map[string]any{ + "Engine": readPayloadJSONEntry(t, payload, "engine/runtime-manifest.json"), + "bridge": readPayloadJSONEntry(t, payload, "bridge/bridge-manifest.json"), + } { + if manifest["mode"] != "source" { + t.Fatalf("%s mode = %#v", name, manifest["mode"]) + } + if manifest["schema"] != map[string]string{"Engine": "spx-local-engine/v1", "bridge": "spx-local-bridge/v1"}[name] { + t.Fatalf("%s schema = %#v", name, manifest["schema"]) + } + for _, key := range []string{"driver_manifest_sha256", "driver_bundle_sha256", "driver_bundle_name", "driver_spx_version", "driver_producer_commit"} { + if _, found := manifest[key]; found { + t.Fatalf("source %s manifest unexpectedly contains %q", name, key) + } + } + } +} + +func publishedPayloadAssets(t *testing.T, fixture publishedDriverFixture) Assets { + t.Helper() + root := t.TempDir() + enginePath := filepath.Join(root, fixture.spec.RuntimeName) + packPath := filepath.Join(root, fixture.spec.PackName) + bridgeName, err := bridgeFileName(runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Fatal(err) + } + bridgePath := filepath.Join(root, bridgeName) + for _, file := range []struct { + path string + data []byte + mode os.FileMode + }{{enginePath, fixture.engine, 0o755}, {packPath, fixture.pack, 0o644}, {bridgePath, fixture.bridge, 0o755}} { + if err := os.WriteFile(file.path, file.data, file.mode); err != nil { + t.Fatal(err) + } + } + return Assets{ + EnginePath: enginePath, PackPath: packPath, BridgePath: bridgePath, Lock: fixture.lock, + Published: &PublishedDriverIdentity{ + ManifestSHA256: fixture.driverPin.SHA256, BundleSHA256: fixture.bundle.SHA256, + BundleName: fixture.bundle.Name, SPXVersion: fixture.manifest.SPXVersion, + ProducerCommit: fixture.manifest.ProducerCommit, + EngineSHA256: digestBytes(fixture.engine), PackSHA256: digestBytes(fixture.pack), + BridgeSHA256: digestBytes(fixture.bridge), EngineInterfaceDigest: fixture.bundle.EngineInterfaceDigest, + }, + } +} + +func publishedPayloadConfig(t *testing.T) (Config, projectbundle.Config) { + t.Helper() + projectDir := t.TempDir() + if err := os.WriteFile(filepath.Join(projectDir, "main.spx"), []byte("onStart => {}\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(projectDir, "assets"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(projectDir, "assets", "index.json"), []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + output := filepath.Join(t.TempDir(), "launcher") + cfg := publishedDriverTestConfig(t.TempDir()) + cfg.ProjectDir, cfg.ProjectFile, cfg.ProjectExt = projectDir, filepath.Join(projectDir, "main.spx"), ".spx" + cfg.PackDir, cfg.PackIndex, cfg.Output = "assets", "index.json", output + return cfg, projectbundle.Config{ProjectDir: projectDir, ProjectFiles: []string{"main.spx"}, PackDir: "assets", Output: output} +} + +func buildLaunchpackPayloadTest(t *testing.T, cfg Config, assets Assets, project projectbundle.Config) ([]byte, string, string) { + t.Helper() + var payload bytes.Buffer + payloadDigest, manifestDigest, err := writeLauncherPayload(t.TempDir(), &payload, cfg, assets, project, IO{}) + if err != nil { + t.Fatal(err) + } + return payload.Bytes(), payloadDigest, manifestDigest +} + +func readPayloadJSONEntry(t *testing.T, payload []byte, name string) map[string]any { + t.Helper() + reader, err := zip.NewReader(bytes.NewReader(payload), int64(len(payload))) + if err != nil { + t.Fatal(err) + } + for _, file := range reader.File { + if file.Name != name { + continue + } + input, err := file.Open() + if err != nil { + t.Fatal(err) + } + data, readErr := io.ReadAll(input) + closeErr := input.Close() + if readErr != nil || closeErr != nil { + t.Fatalf("read %s: read=%v close=%v", name, readErr, closeErr) + } + var manifest map[string]any + if err := json.Unmarshal(data, &manifest); err != nil { + t.Fatalf("decode %s: %v", name, err) + } + return manifest + } + t.Fatalf("payload is missing %s", name) + return nil +} + +func assertPayloadProvenance(t *testing.T, manifest map[string]any, assets Assets) { + t.Helper() + if assets.Published == nil { + t.Fatal("published assets have no provenance") + } + want := map[string]string{ + "driver_manifest_sha256": assets.Published.ManifestSHA256, + "driver_bundle_sha256": assets.Published.BundleSHA256, + "driver_bundle_name": assets.Published.BundleName, + "driver_spx_version": assets.Published.SPXVersion, + "driver_producer_commit": assets.Published.ProducerCommit, + } + for key, value := range want { + if manifest[key] != value { + t.Fatalf("payload provenance %s = %#v, want %q", key, manifest[key], value) + } + } +} diff --git a/internal/launchpack/driver_published_support.go b/internal/launchpack/driver_published_support.go new file mode 100644 index 000000000..ad7f00974 --- /dev/null +++ b/internal/launchpack/driver_published_support.go @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package launchpack + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/goplus/spx/v3/internal/driverbundle" + "github.com/goplus/spx/v3/internal/runtimebundle" + "golang.org/x/mod/module" + "golang.org/x/mod/semver" +) + +func validatePublishedSource(source SourceIdentity) error { + if source.SourceMode { + return errors.New("launchpack: published driver cannot use source mode") + } + if source.Main { + return errors.New("launchpack: published mode requires an unreplaced SPX dependency") + } + if source.SelectedPath != driverbundle.SPXModulePath || source.EffectivePath != driverbundle.SPXModulePath { + return fmt.Errorf("launchpack: published driver module must be %q", driverbundle.SPXModulePath) + } + version := source.SelectedVersion + if !semver.IsValid(version) || semver.Canonical(version) != version || module.IsPseudoVersion(version) { + return fmt.Errorf("launchpack: published driver requires an exact canonical release version, got %q", version) + } + if source.EffectiveVersion != version { + return fmt.Errorf("launchpack: published driver effective version %q does not match selected version %q", source.EffectiveVersion, version) + } + return nil +} + +func publishedDriverEnvironment(cfg Config, base []string) []string { + if base == nil { + base = os.Environ() + } + env := append([]string(nil), base...) + for _, item := range []struct{ key, value string }{ + {driverAssetDirEnv, cfg.DriverAssetDir}, + {runtimeCacheEnv, cfg.RuntimeCacheRoot}, + } { + if item.value == "" { + continue + } + filtered := env[:0] + for _, entry := range env { + key, _, ok := strings.Cut(entry, "=") + if !ok || key != item.key { + filtered = append(filtered, entry) + } + } + env = append(filtered, item.key+"="+item.value) + } + return env +} + +func acquireDriverFile(ctx context.Context, root, name string, size int64, digest, url, localDir string, offline bool, fetch runtimebundle.FetchFunc) (*runtimebundle.AcquiredFile, error) { + if localDir != "" { + source := filepath.Join(localDir, name) + if err := verifyLocalDriverAsset(source, size, digest); err != nil { + return nil, err + } + return runtimebundle.AcquireFile(ctx, root, runtimebundle.FetchSpec{ + Name: name, URL: source, Size: size, SHA256: digest, + Fetch: func(ctx context.Context, _ string, dst io.Writer) error { + return copyLocalRuntimeAsset(ctx, source, dst) + }, + }) + } + return runtimebundle.AcquireFile(ctx, root, runtimebundle.FetchSpec{ + Name: name, URL: url, Size: size, SHA256: digest, Offline: offline, Fetch: fetch, + }) +} + +func verifyLocalDriverAsset(path string, size int64, digest string) error { + gotSize, gotDigest, err := hashRuntimeFile(path) + if err != nil { + return fmt.Errorf("verify local published driver asset %q: %w", path, err) + } + if gotSize != size { + return fmt.Errorf("local published driver asset %q size = %d, want %d", path, gotSize, size) + } + if gotDigest != digest { + return fmt.Errorf("local published driver asset %q SHA-256 = %s, want %s", path, gotDigest, digest) + } + return nil +} + +func readPinnedDriverManifest(file *runtimebundle.AcquiredFile, pin driverbundle.Pin) ([]byte, error) { + if file == nil { + return nil, errors.New("nil published driver manifest") + } + data, err := readRuntimeMetadata(file, pin.Name) + if err != nil { + return nil, err + } + if err := verifyDriverManifestPin(data, pin); err != nil { + return nil, err + } + return data, nil +} + +func verifyDriverManifestPin(data []byte, pin driverbundle.Pin) error { + if int64(len(data)) != pin.Size { + return fmt.Errorf("published driver manifest size = %d, want pinned %d", len(data), pin.Size) + } + if digest := digestBytes(data); digest != pin.SHA256 { + return fmt.Errorf("published driver manifest SHA-256 = %s, want pinned %s", digest, pin.SHA256) + } + return nil +} diff --git a/internal/launchpack/payload.go b/internal/launchpack/payload.go index 879a1eed9..da8be1fb4 100644 --- a/internal/launchpack/payload.go +++ b/internal/launchpack/payload.go @@ -18,12 +18,10 @@ package launchpack import ( "context" - "encoding/json" "errors" "fmt" "io" "os" - "os/exec" "path/filepath" "runtime" @@ -33,80 +31,6 @@ import ( "github.com/goplus/spx/v3/internal/runtimepayload" ) -func buildSourceBridge(ctx context.Context, cfg Config, bridgeName string, streams IO) (string, func(), error) { - if err := cfg.validateGraphInputs(); err != nil { - return "", nil, err - } - if err := cfg.verifyGraph(ctx, "before source bridge build"); err != nil { - return "", nil, err - } - if cfg.BridgePackage == "" { - return "", nil, fmt.Errorf("launchpack: bridge package is required") - } - workDir, err := os.MkdirTemp("", "spx-launchpack-bridge-") - if err != nil { - return "", nil, fmt.Errorf("launchpack: create source bridge work directory: %w", err) - } - keepWork := hasBuildFlag(cfg.BuildFlags, "work") - cleanup := func() { _ = os.RemoveAll(workDir) } - if keepWork { - cleanup = func() {} - if streams.Stderr != nil { - _, _ = fmt.Fprintf(streams.Stderr, "SPXBRIDGEWORK=%s\n", workDir) - } - } - bridgePath := filepath.Join(workDir, bridgeName) - args := sourceBridgeBuildArgs(cfg, bridgePath) - command := exec.CommandContext(ctx, cfg.GoCommand, args...) - command.Dir = cfg.WorkDir - command.Env = sourceBridgeEnv(cfg, streams.Env) - command.Stdin = streams.Stdin - command.Stdout = streams.Stdout - command.Stderr = streams.Stderr - if err := command.Run(); err != nil { - cleanup() - return "", nil, fmt.Errorf("launchpack: build source interpreter bridge: %w", err) - } - if err := cfg.verifyGraph(ctx, "after source bridge build"); err != nil { - cleanup() - return "", nil, err - } - if err := validatePinnedFile("source interpreter bridge", bridgePath); err != nil { - cleanup() - return "", nil, err - } - return bridgePath, cleanup, nil -} - -func sourceBridgeBuildArgs(cfg Config, bridgePath string) []string { - return sourceBridgeBuildArgsForGOOS(cfg, bridgePath, runtime.GOOS) -} - -func sourceBridgeBuildArgsForGOOS(cfg Config, bridgePath, goos string) []string { - args := append([]string{"build"}, cfg.GraphFlags...) - args = append(args, normalizedGoBuildFlags(cfg.BuildFlags)...) - args = append(args, "-buildmode=c-shared") - if goos == "windows" { - args = append(args, "-ldflags=-extldflags=-Wl,--allow-multiple-definition") - } - return append(args, "-o", bridgePath, cfg.BridgePackage) -} - -func bridgeFileName(goos, goarch string) (string, error) { - extension := "" - switch goos { - case "darwin": - extension = ".dylib" - case "linux": - extension = ".so" - case "windows": - extension = ".dll" - default: - return "", fmt.Errorf("launchpack: host platform %s/%s is not supported", goos, goarch) - } - return "gdspx-" + goos + "-" + goarch + extension, nil -} - func buildLauncher(ctx context.Context, cfg Config, assets Assets, configSnapshot projectpolicy.PortableConfigSnapshot, streams IO) (string, string, error) { projectConfig, err := prepareProjectBundleConfig(cfg, configSnapshot) if err != nil { @@ -176,7 +100,7 @@ func writeLauncherPayload(workDir string, dst io.Writer, cfg Config, assets Asse } }() - interfaceDigest, engineDigest, packDigest, err := localEngineSourceDigests(engine.source(""), pack.source("")) + interfaceDigest, engineDigest, packDigest, err := engineSourceDigests(engine.source(""), pack.source("")) if err != nil { return "", "", err } @@ -184,31 +108,8 @@ func writeLauncherPayload(workDir string, dst io.Writer, cfg Config, assets Asse if err != nil { return "", "", fmt.Errorf("launchpack: hash interpreter bridge: %w", err) } - engineManifest, err := json.Marshal(struct { - Schema string `json:"schema"` - Mode string `json:"mode"` - RuntimeVersion string `json:"runtime_version"` - RuntimeABI int `json:"runtime_abi"` - EngineInterfaceDigest string `json:"engine_interface_digest"` - ExecutableSHA256 string `json:"executable_sha256"` - PackSHA256 string `json:"pack_sha256"` - }{ - Schema: "spx-local-engine/v1", Mode: "source", RuntimeVersion: assets.Lock.RuntimeVersion, - RuntimeABI: assets.Lock.RuntimeABI, EngineInterfaceDigest: interfaceDigest, - ExecutableSHA256: engineDigest, PackSHA256: packDigest, - }) - if err != nil { - return "", "", err - } - bridgeManifest, err := json.Marshal(struct { - Schema string `json:"schema"` - Mode string `json:"mode"` - SPXSource string `json:"spx_source"` - EngineInterfaceDigest string `json:"engine_interface_digest"` - BridgeSHA256 string `json:"bridge_sha256"` - }{ - Schema: "spx-local-bridge/v1", Mode: "source", SPXSource: cfg.Source.EffectivePath, - EngineInterfaceDigest: interfaceDigest, BridgeSHA256: bridgeDigest, + engineManifest, bridgeManifest, err := componentManifests(cfg, assets, componentDigests{ + interfaceDigest: interfaceDigest, engine: engineDigest, pack: packDigest, bridge: bridgeDigest, }) if err != nil { return "", "", err diff --git a/internal/launchpack/payload_files.go b/internal/launchpack/payload_files.go index fa97d29e4..04ba2cc65 100644 --- a/internal/launchpack/payload_files.go +++ b/internal/launchpack/payload_files.go @@ -26,6 +26,7 @@ import ( "runtime" "strings" + "github.com/goplus/spx/v3/internal/driverbundle" "github.com/goplus/spx/v3/internal/runtimebundle" "github.com/goplus/spx/v3/internal/runtimepayload" ) @@ -101,19 +102,20 @@ func digestFileSource(source runtimepayload.FileSource) (string, error) { return hex.EncodeToString(hasher.Sum(nil)), nil } -func localEngineSourceDigests(engine, pack runtimepayload.FileSource) (interfaceDigest, engineDigest, packDigest string, err error) { - interfaceHasher := sha256.New() - engineHasher := sha256.New() - packHasher := sha256.New() - _, _ = interfaceHasher.Write([]byte("spx-local-engine-interface/v1\x00")) - if err := copyFileSource(io.MultiWriter(interfaceHasher, engineHasher), engine); err != nil { +func engineSourceDigests(engine, pack runtimepayload.FileSource) (interfaceDigest, engineDigest, packDigest string, err error) { + engineDigest, err = digestFileSource(engine) + if err != nil { return "", "", "", fmt.Errorf("launchpack: hash Engine: %w", err) } - _, _ = interfaceHasher.Write([]byte{0}) - if err := copyFileSource(io.MultiWriter(interfaceHasher, packHasher), pack); err != nil { + packDigest, err = digestFileSource(pack) + if err != nil { return "", "", "", fmt.Errorf("launchpack: hash runtime PCK: %w", err) } - return hex.EncodeToString(interfaceHasher.Sum(nil)), hex.EncodeToString(engineHasher.Sum(nil)), hex.EncodeToString(packHasher.Sum(nil)), nil + interfaceDigest, err = driverbundle.ComputeEngineInterfaceDigestFromSHA256(engineDigest, packDigest) + if err != nil { + return "", "", "", fmt.Errorf("launchpack: identify Engine interface: %w", err) + } + return interfaceDigest, engineDigest, packDigest, nil } func copyFileSource(dst io.Writer, source runtimepayload.FileSource) error { diff --git a/internal/launchpack/payload_manifest.go b/internal/launchpack/payload_manifest.go new file mode 100644 index 000000000..010d64c67 --- /dev/null +++ b/internal/launchpack/payload_manifest.go @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package launchpack + +import ( + "encoding/json" + "errors" +) + +type componentDigests struct { + interfaceDigest string + engine string + pack string + bridge string +} + +type driverManifestFields struct { + DriverManifestSHA256 string `json:"driver_manifest_sha256,omitempty"` + DriverBundleSHA256 string `json:"driver_bundle_sha256,omitempty"` + DriverBundleName string `json:"driver_bundle_name,omitempty"` + DriverSPXVersion string `json:"driver_spx_version,omitempty"` + DriverProducerCommit string `json:"driver_producer_commit,omitempty"` +} + +type engineComponentManifest struct { + driverManifestFields + Schema string `json:"schema"` + Mode string `json:"mode"` + RuntimeVersion string `json:"runtime_version"` + RuntimeABI int `json:"runtime_abi"` + EngineInterfaceDigest string `json:"engine_interface_digest"` + ExecutableSHA256 string `json:"executable_sha256"` + PackSHA256 string `json:"pack_sha256"` +} + +type bridgeComponentManifest struct { + driverManifestFields + Schema string `json:"schema"` + Mode string `json:"mode"` + SPXSource string `json:"spx_source"` + EngineInterfaceDigest string `json:"engine_interface_digest"` + BridgeSHA256 string `json:"bridge_sha256"` +} + +func componentManifests(cfg Config, assets Assets, digests componentDigests) ([]byte, []byte, error) { + mode, engineSchema, bridgeSchema := "source", "spx-local-engine/v1", "spx-local-bridge/v1" + var driver driverManifestFields + if cfg.Source.SourceMode { + if assets.Published != nil { + return nil, nil, errors.New("launchpack: source assets carry published driver provenance") + } + } else { + if assets.Published == nil { + return nil, nil, errors.New("launchpack: published driver provenance is incomplete") + } + if err := assets.Published.validate(); err != nil { + return nil, nil, err + } + if err := assets.Published.verifyDigests(digests.engine, digests.pack, digests.bridge, digests.interfaceDigest); err != nil { + return nil, nil, err + } + mode, engineSchema, bridgeSchema = "published", "spx-published-engine/v1", "spx-published-bridge/v1" + driver = driverManifestFields{ + DriverManifestSHA256: assets.Published.ManifestSHA256, + DriverBundleSHA256: assets.Published.BundleSHA256, + DriverBundleName: assets.Published.BundleName, + DriverSPXVersion: assets.Published.SPXVersion, + DriverProducerCommit: assets.Published.ProducerCommit, + } + } + engine, err := json.Marshal(engineComponentManifest{ + driverManifestFields: driver, + Schema: engineSchema, Mode: mode, RuntimeVersion: assets.Lock.RuntimeVersion, + RuntimeABI: assets.Lock.RuntimeABI, EngineInterfaceDigest: digests.interfaceDigest, + ExecutableSHA256: digests.engine, PackSHA256: digests.pack, + }) + if err != nil { + return nil, nil, err + } + bridge, err := json.Marshal(bridgeComponentManifest{ + driverManifestFields: driver, + Schema: bridgeSchema, Mode: mode, SPXSource: cfg.Source.EffectivePath, + EngineInterfaceDigest: digests.interfaceDigest, BridgeSHA256: digests.bridge, + }) + return engine, bridge, err +} diff --git a/internal/launchpack/runtime_assets.go b/internal/launchpack/runtime_assets.go index 139e1080b..773ba7add 100644 --- a/internal/launchpack/runtime_assets.go +++ b/internal/launchpack/runtime_assets.go @@ -20,10 +20,7 @@ import ( "context" "errors" "fmt" - "os" - "path/filepath" "runtime" - "strings" "github.com/goplus/spx/v3/internal/release" "github.com/goplus/spx/v3/internal/runtimebundle" @@ -46,7 +43,7 @@ type runtimeAssetDependencies struct { func defaultRuntimeAssetDependencies() runtimeAssetDependencies { return runtimeAssetDependencies{ - fetch: fetchRuntimeURL, + fetch: fetchReleaseURL, cacheRoot: runtimebundle.DefaultCacheRoot, manifestPin: release.RuntimeManifestPinForLock, goBin: resolveGoBin, @@ -134,140 +131,15 @@ func acquireRuntimeAssetsWith(ctx context.Context, cfg Config, streams IO, lock if assetDirSet || !cfg.Source.SourceMode { return Assets{}, err } - return acquireSourceRuntime(ctx, cfg, env, cacheRoot, lock, spec, dependencies, err) -} - -func resolveRuntimeCacheRoot(env []string, defaultRoot func() string) (string, error) { - value, found, duplicate := environmentValue(env, runtimeCacheEnv) - if duplicate { - return "", fmt.Errorf("launchpack: duplicate %s", runtimeCacheEnv) - } - if !found || value == "" { - value = filepath.Clean(defaultRoot()) - } - if !filepath.IsAbs(value) || filepath.Clean(value) != value { - return "", fmt.Errorf("launchpack: %s must be an absolute clean path", runtimeCacheEnv) - } - return value, nil -} - -func runtimeOffline(env []string) (bool, error) { - value, found, duplicate := environmentValue(env, runtimeOfflineEnv) - if duplicate { - return false, fmt.Errorf("launchpack: duplicate %s", runtimeOfflineEnv) - } - if !found || strings.TrimSpace(value) == "" { - return false, nil - } - switch strings.ToLower(strings.TrimSpace(value)) { - case "1", "true", "yes", "on": - return true, nil - case "0", "false", "no", "off": - return false, nil - default: - return false, fmt.Errorf("launchpack: invalid %s value %q", runtimeOfflineEnv, value) - } -} - -func environmentValue(env []string, key string) (value string, found, duplicate bool) { - for _, entry := range env { - name, current, ok := strings.Cut(entry, "=") - if !ok || name != key { - continue - } - if found { - return "", true, true - } - value, found = current, true - } - return value, found, false -} - -func runtimeEnvironment(cfg Config, base []string) []string { - if base == nil { - base = os.Environ() - } - env := append([]string(nil), base...) - for _, item := range []struct{ key, value string }{ - {runtimeLocalManifestEnv, cfg.RuntimeManifestPath}, - {runtimeAssetDirEnv, cfg.RuntimeAssetDir}, - {runtimeCacheEnv, cfg.RuntimeCacheRoot}, - } { - if item.value == "" { - continue - } - filtered := env[:0] - for _, entry := range env { - key, _, ok := strings.Cut(entry, "=") - if !ok || key != item.key { - filtered = append(filtered, entry) - } - } - env = append(filtered, item.key+"="+item.value) + if ctxErr := ctx.Err(); ctxErr != nil { + return Assets{}, ctxErr } - return env -} - -func findExplicitLocalRuntimeManifest(env []string, lock release.RuntimeLock, spec release.HostRuntimeSpec) (localRuntimeSource, bool, error) { - path, found, duplicate := environmentValue(env, runtimeLocalManifestEnv) - if duplicate { - return localRuntimeSource{}, false, fmt.Errorf("launchpack: duplicate %s", runtimeLocalManifestEnv) - } - if !found { - return localRuntimeSource{}, false, nil - } - return readLocalRuntimeManifest(path, lock, spec, true) -} - -func findSourceLocalRuntimeManifest(root string, lock release.RuntimeLock, spec release.HostRuntimeSpec) (localRuntimeSource, bool, error) { - path, err := release.LocalRuntimeManifestPath(root, lock, spec.GOOS, spec.GOARCH) - if err != nil { - return localRuntimeSource{}, false, err - } - if info, err := os.Lstat(path); err != nil { - if os.IsNotExist(err) { - return localRuntimeSource{}, false, nil - } - return localRuntimeSource{}, false, fmt.Errorf("launchpack: inspect local runtime manifest: %w", err) - } else if !isRegularNonSymlink(info) { - return localRuntimeSource{}, false, fmt.Errorf("launchpack: discovered local runtime manifest is not a regular non-symlink file: %s", path) + if !runtimeReleaseUnavailable(err) { + return Assets{}, err } - return readLocalRuntimeManifest(path, lock, spec, false) + return acquireSourceRuntime(ctx, cfg, env, cacheRoot, lock, spec, dependencies, err) } -func readLocalRuntimeManifest(path string, lock release.RuntimeLock, spec release.HostRuntimeSpec, strict bool) (localRuntimeSource, bool, error) { - if !filepath.IsAbs(path) || filepath.Clean(path) != path { - return localRuntimeSource{}, false, fmt.Errorf("launchpack: %s must be an absolute clean path", runtimeLocalManifestEnv) - } - info, err := os.Lstat(path) - if err != nil { - return localRuntimeSource{}, false, fmt.Errorf("launchpack: inspect local runtime manifest %q: %w", path, err) - } - if !isRegularNonSymlink(info) { - return localRuntimeSource{}, false, fmt.Errorf("launchpack: local runtime manifest %q is not a regular non-symlink file", path) - } - data, err := readRegularFile(path) - if err != nil { - return localRuntimeSource{}, false, err - } - manifest, err := release.ParseLocalRuntimeManifest(data) - if err != nil { - return localRuntimeSource{}, false, err - } - validate := manifest.ValidateForVersion - if strict { - validate = manifest.ValidateForLock - } - if err := validate(lock, spec.GOOS, spec.GOARCH); err != nil { - return localRuntimeSource{}, false, err - } - directory := filepath.Dir(path) - if err := manifest.VerifyFiles(directory); err != nil { - return localRuntimeSource{}, false, err - } - return localRuntimeSource{ - manifest: manifest, bytes: data, - enginePath: filepath.Join(directory, manifest.Engine.Name), - packPath: filepath.Join(directory, manifest.Pack.Name), - }, true, nil +func runtimeReleaseUnavailable(err error) bool { + return errors.Is(err, errReleaseUnavailable) || errors.Is(err, runtimebundle.ErrOfflineCacheMiss) } diff --git a/internal/launchpack/runtime_environment.go b/internal/launchpack/runtime_environment.go new file mode 100644 index 000000000..434b771f6 --- /dev/null +++ b/internal/launchpack/runtime_environment.go @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package launchpack + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +func resolveRuntimeCacheRoot(env []string, defaultRoot func() string) (string, error) { + value, found, duplicate := environmentValue(env, runtimeCacheEnv) + if duplicate { + return "", fmt.Errorf("launchpack: duplicate %s", runtimeCacheEnv) + } + if !found || value == "" { + value = filepath.Clean(defaultRoot()) + } + if !filepath.IsAbs(value) || filepath.Clean(value) != value { + return "", fmt.Errorf("launchpack: %s must be an absolute clean path", runtimeCacheEnv) + } + return value, nil +} + +func runtimeOffline(env []string) (bool, error) { + value, found, duplicate := environmentValue(env, runtimeOfflineEnv) + if duplicate { + return false, fmt.Errorf("launchpack: duplicate %s", runtimeOfflineEnv) + } + value = strings.ToLower(strings.TrimSpace(value)) + if !found || value == "" { + return false, nil + } + switch value { + case "1", "true", "yes", "on": + return true, nil + case "0", "false", "no", "off": + return false, nil + default: + return false, fmt.Errorf("launchpack: invalid %s value %q", runtimeOfflineEnv, value) + } +} + +func environmentValue(env []string, key string) (value string, found, duplicate bool) { + for _, entry := range env { + name, current, ok := strings.Cut(entry, "=") + if !ok || name != key { + continue + } + if found { + return "", true, true + } + value, found = current, true + } + return value, found, false +} + +func runtimeEnvironment(cfg Config, base []string) []string { + if base == nil { + base = os.Environ() + } + env := append([]string(nil), base...) + for _, item := range []struct{ key, value string }{ + {runtimeLocalManifestEnv, cfg.RuntimeManifestPath}, + {runtimeAssetDirEnv, cfg.RuntimeAssetDir}, + {runtimeCacheEnv, cfg.RuntimeCacheRoot}, + } { + if item.value == "" { + continue + } + filtered := env[:0] + for _, entry := range env { + key, _, ok := strings.Cut(entry, "=") + if !ok || key != item.key { + filtered = append(filtered, entry) + } + } + env = append(filtered, item.key+"="+item.value) + } + return env +} diff --git a/internal/launchpack/runtime_fetch.go b/internal/launchpack/runtime_fetch.go index 558261b5f..a3748c95f 100644 --- a/internal/launchpack/runtime_fetch.go +++ b/internal/launchpack/runtime_fetch.go @@ -20,6 +20,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "errors" "fmt" "io" "net/http" @@ -32,6 +33,8 @@ import ( var runtimeHTTPClient = &http.Client{Timeout: 30 * time.Minute} +var errReleaseUnavailable = errors.New("launchpack: release unavailable") + func resolvePublishedRuntime(ctx context.Context, cacheRoot string, lock release.RuntimeLock, spec release.HostRuntimeSpec, pin release.RuntimeManifestPin, env []string, offline bool, dependencies runtimeAssetDependencies) (runtimeAssetSource, error) { assetDir, assetDirSet, duplicate := environmentValue(env, runtimeAssetDirEnv) if duplicate { @@ -108,11 +111,8 @@ func acquireReleaseAsset(ctx context.Context, root string, asset release.Runtime } func verifyRuntimeManifestPin(pin release.RuntimeManifestPin, data []byte) error { - if int64(len(data)) != pin.Size { - return fmt.Errorf("launchpack: runtime manifest size = %d, want pinned %d", len(data), pin.Size) - } - if digest := digestBytes(data); digest != pin.SHA256 { - return fmt.Errorf("launchpack: runtime manifest SHA-256 = %s, want pinned %s", digest, pin.SHA256) + if err := pin.Verify(data); err != nil { + return fmt.Errorf("launchpack: verify runtime manifest pin: %w", err) } return nil } @@ -158,19 +158,46 @@ func digestBytes(data []byte) string { return hex.EncodeToString(sum[:]) } -func fetchRuntimeURL(ctx context.Context, url string, dst io.Writer) error { +func fetchReleaseURL(ctx context.Context, url string, dst io.Writer) error { request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return err } response, err := runtimeHTTPClient.Do(request) if err != nil { - return err + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + return fmt.Errorf("%w: GET %s: %w", errReleaseUnavailable, url, err) } defer response.Body.Close() if response.StatusCode != http.StatusOK { - return fmt.Errorf("GET %s returned %s", url, response.Status) + return fmt.Errorf("%w: GET %s returned %s", errReleaseUnavailable, url, response.Status) + } + tracked := &writeErrorTracker{writer: dst} + if _, err := io.Copy(tracked, response.Body); err != nil { + if tracked.err != nil { + return tracked.err + } + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + return fmt.Errorf("%w: read %s: %w", errReleaseUnavailable, url, err) + } + return nil +} + +type writeErrorTracker struct { + writer io.Writer + err error +} + +func (w *writeErrorTracker) Write(data []byte) (int, error) { + n, err := w.writer.Write(data) + if err != nil { + w.err = err + } else if n != len(data) { + w.err = io.ErrShortWrite } - _, err = io.Copy(dst, response.Body) - return err + return n, err } diff --git a/internal/launchpack/runtime_fetch_test.go b/internal/launchpack/runtime_fetch_test.go new file mode 100644 index 000000000..5a55da39e --- /dev/null +++ b/internal/launchpack/runtime_fetch_test.go @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package launchpack + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/goplus/spx/v3/internal/runtimebundle" +) + +func TestFetchReleaseURLClassifiesUnavailableRelease(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + response.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + if err := fetchReleaseURL(context.Background(), server.URL, io.Discard); !errors.Is(err, errReleaseUnavailable) { + t.Fatalf("fetch error = %v, want release unavailable", err) + } +} + +func TestFetchReleaseURLPreservesCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err := fetchReleaseURL(ctx, "https://example.invalid/runtime", io.Discard) + if !errors.Is(err, context.Canceled) || errors.Is(err, errReleaseUnavailable) { + t.Fatalf("canceled fetch error = %v", err) + } +} + +func TestFetchReleaseURLPreservesDestinationFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(response, "payload") + })) + defer server.Close() + want := errors.New("destination rejected payload") + err := fetchReleaseURL(context.Background(), server.URL, errorWriter{err: want}) + if !errors.Is(err, want) || errors.Is(err, errReleaseUnavailable) { + t.Fatalf("destination failure = %v", err) + } +} + +func TestRuntimeReleaseUnavailableClassification(t *testing.T) { + for _, err := range []error{ + io.ErrShortWrite, + runtimebundle.ErrDigestMismatch, + runtimebundle.ErrInvalidManifest, + runtimebundle.ErrArchiveLimit, + runtimebundle.ErrUnsafeArchive, + runtimebundle.ErrUnsupportedArchiveEntry, + } { + if runtimeReleaseUnavailable(err) { + t.Fatalf("integrity error %v classified as unavailable", err) + } + } + if !runtimeReleaseUnavailable(errReleaseUnavailable) || !runtimeReleaseUnavailable(runtimebundle.ErrOfflineCacheMiss) { + t.Fatal("availability errors were not classified for source fallback") + } +} + +type errorWriter struct{ err error } + +func (w errorWriter) Write([]byte) (int, error) { return 0, w.err } diff --git a/internal/launchpack/runtime_local_manifest.go b/internal/launchpack/runtime_local_manifest.go new file mode 100644 index 000000000..1a9387cf5 --- /dev/null +++ b/internal/launchpack/runtime_local_manifest.go @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package launchpack + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/goplus/spx/v3/internal/release" +) + +func findExplicitLocalRuntimeManifest(env []string, lock release.RuntimeLock, spec release.HostRuntimeSpec) (localRuntimeSource, bool, error) { + path, found, duplicate := environmentValue(env, runtimeLocalManifestEnv) + if duplicate { + return localRuntimeSource{}, false, fmt.Errorf("launchpack: duplicate %s", runtimeLocalManifestEnv) + } + if !found { + return localRuntimeSource{}, false, nil + } + return readLocalRuntimeManifest(path, lock, spec, true) +} + +func findSourceLocalRuntimeManifest(root string, lock release.RuntimeLock, spec release.HostRuntimeSpec) (localRuntimeSource, bool, error) { + path, err := release.LocalRuntimeManifestPath(root, lock, spec.GOOS, spec.GOARCH) + if err != nil { + return localRuntimeSource{}, false, err + } + if info, err := os.Lstat(path); err != nil { + if os.IsNotExist(err) { + return localRuntimeSource{}, false, nil + } + return localRuntimeSource{}, false, fmt.Errorf("launchpack: inspect local runtime manifest: %w", err) + } else if !isRegularNonSymlink(info) { + return localRuntimeSource{}, false, fmt.Errorf("launchpack: discovered local runtime manifest is not a regular non-symlink file: %s", path) + } + return readLocalRuntimeManifest(path, lock, spec, false) +} + +func readLocalRuntimeManifest(path string, lock release.RuntimeLock, spec release.HostRuntimeSpec, strict bool) (localRuntimeSource, bool, error) { + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return localRuntimeSource{}, false, fmt.Errorf("launchpack: %s must be an absolute clean path", runtimeLocalManifestEnv) + } + info, err := os.Lstat(path) + if err != nil { + return localRuntimeSource{}, false, fmt.Errorf("launchpack: inspect local runtime manifest %q: %w", path, err) + } + if !isRegularNonSymlink(info) { + return localRuntimeSource{}, false, fmt.Errorf("launchpack: local runtime manifest %q is not a regular non-symlink file", path) + } + data, err := readRegularFile(path) + if err != nil { + return localRuntimeSource{}, false, err + } + manifest, err := release.ParseLocalRuntimeManifest(data) + if err != nil { + return localRuntimeSource{}, false, err + } + validate := manifest.ValidateForVersion + if strict { + validate = manifest.ValidateForLock + } + if err := validate(lock, spec.GOOS, spec.GOARCH); err != nil { + return localRuntimeSource{}, false, err + } + directory := filepath.Dir(path) + if err := manifest.VerifyFiles(directory); err != nil { + return localRuntimeSource{}, false, err + } + return localRuntimeSource{ + manifest: manifest, bytes: data, + enginePath: filepath.Join(directory, manifest.Engine.Name), + packPath: filepath.Join(directory, manifest.Pack.Name), + }, true, nil +} diff --git a/internal/launchpack/runtime_source_test.go b/internal/launchpack/runtime_source_test.go index a958bbd31..80f936051 100644 --- a/internal/launchpack/runtime_source_test.go +++ b/internal/launchpack/runtime_source_test.go @@ -19,12 +19,14 @@ package launchpack import ( "context" "errors" + "fmt" "io" "os" "runtime" "testing" "github.com/goplus/spx/v3/internal/release" + "github.com/goplus/spx/v3/internal/runtimebundle" ) func TestSourceRuntimePrefersPublishedAssets(t *testing.T) { @@ -55,7 +57,7 @@ func TestSourceRuntimeFallsBackAfterPublishedFetchFailure(t *testing.T) { fetchCalls, binCalls := 0, 0 dependencies := fixture.dependencies(cacheRoot, func(context.Context, string, io.Writer) error { fetchCalls++ - return errors.New("network unavailable") + return fmt.Errorf("%w: network unavailable", errReleaseUnavailable) }) dependencies.goBin = func(context.Context, Config, []string) (string, error) { binCalls++ @@ -79,6 +81,54 @@ func TestSourceRuntimeFallsBackAfterPublishedFetchFailure(t *testing.T) { } } +func TestSourceRuntimeRejectsPublishedIntegrityFailure(t *testing.T) { + fixture := newPublishedRuntimeFixture(t) + badArchive := append([]byte(nil), fixture.assets[fixture.spec.ArchiveName]...) + badArchive[len(badArchive)/2] ^= 1 + for name, replacements := range map[string]map[string][]byte{ + "manifest": {fixture.lock.Manifest: []byte("tampered manifest")}, + "archive": {fixture.spec.ArchiveName: badArchive}, + } { + t.Run(name, func(t *testing.T) { + cacheRoot := t.TempDir() + bin := writeInstalledRuntimeTest(t, fixture.spec, "local-engine", "local-pack") + calls, binCalls := 0, 0 + dependencies := fixture.dependencies(cacheRoot, fixture.fetcher(replacements, &calls)) + dependencies.goBin = func(context.Context, Config, []string) (string, error) { + binCalls++ + return bin, nil + } + _, err := acquireRuntimeAssetsWith(context.Background(), sourceRuntimeConfig(t.TempDir(), cacheRoot), IO{Env: []string{}}, fixture.lock, dependencies) + if !errors.Is(err, runtimebundle.ErrDigestMismatch) { + t.Fatalf("integrity failure = %v, want ErrDigestMismatch", err) + } + if binCalls != 0 { + t.Fatalf("Go-bin calls = %d, want 0", binCalls) + } + }) + } +} + +func TestSourceRuntimeRejectsOversizedPublishedManifest(t *testing.T) { + fixture := newPublishedRuntimeFixture(t) + oversized := append(append([]byte(nil), fixture.manifestData...), 'x') + cacheRoot := t.TempDir() + bin := writeInstalledRuntimeTest(t, fixture.spec, "local-engine", "local-pack") + calls, binCalls := 0, 0 + dependencies := fixture.dependencies(cacheRoot, fixture.fetcher(map[string][]byte{fixture.lock.Manifest: oversized}, &calls)) + dependencies.goBin = func(context.Context, Config, []string) (string, error) { + binCalls++ + return bin, nil + } + _, err := acquireRuntimeAssetsWith(context.Background(), sourceRuntimeConfig(t.TempDir(), cacheRoot), IO{Env: []string{}}, fixture.lock, dependencies) + if err == nil || errors.Is(err, errReleaseUnavailable) { + t.Fatalf("oversized manifest error = %v", err) + } + if binCalls != 0 { + t.Fatalf("Go-bin calls = %d, want 0", binCalls) + } +} + func TestPublishedFetchFailureDoesNotFallbackOutsideSourceMode(t *testing.T) { fixture := newPublishedRuntimeFixture(t) cacheRoot := t.TempDir() diff --git a/internal/launchpack/service.go b/internal/launchpack/service.go index 70c7077a4..db3e906a7 100644 --- a/internal/launchpack/service.go +++ b/internal/launchpack/service.go @@ -29,6 +29,9 @@ import ( // Explicit local settings take priority. Source checkouts may use an exact // GOPATH/bin runtime when the pinned release is unavailable. func AcquireRuntimeAssets(ctx context.Context, cfg Config) (Assets, error) { + if !cfg.Source.SourceMode { + return Assets{}, fmt.Errorf("launchpack: runtime-only acquisition requires source mode") + } lock, err := runtimeLock(cfg) if err != nil { return Assets{}, err @@ -42,6 +45,9 @@ func BuildSourceBridge(ctx context.Context, cfg Config) (string, func(), error) if ctx == nil { return "", nil, fmt.Errorf("launchpack: nil context") } + if !cfg.Source.SourceMode { + return "", nil, fmt.Errorf("launchpack: bridge build requires source mode") + } if err := cfg.validateGraphInputs(); err != nil { return "", nil, err } @@ -52,46 +58,54 @@ func BuildSourceBridge(ctx context.Context, cfg Config) (string, func(), error) return buildSourceBridge(ctx, cfg, name, cfg.IO) } -// BuildLauncher packages the project, runtime, and bridge into one executable. -func BuildLauncher(ctx context.Context, cfg Config) (Result, error) { - if ctx == nil { - return Result{}, fmt.Errorf("launchpack: nil context") - } - if err := cfg.validate(); err != nil { - return Result{}, err - } - if _, err := cfg.PortableConfig.Identity(); err != nil { - return Result{}, fmt.Errorf("launchpack: portable config: %w", err) +// PrepareAssets resolves source-built or published host assets. +func PrepareAssets(ctx context.Context, cfg Config) (Assets, error) { + if !cfg.Source.SourceMode { + return AcquirePublishedDriver(ctx, cfg) } assets, err := AcquireRuntimeAssets(ctx, cfg) if err != nil { - return Result{}, err + return Assets{}, err } bridge, bridgeCleanup, err := BuildSourceBridge(ctx, cfg) if err != nil { - if assets.Cleanup != nil { - assets.Cleanup() - } - return Result{}, err + cleanupAssets(assets) + return Assets{}, err } assets.BridgePath = bridge if cfg.VerifyBridge != nil { if err := cfg.VerifyBridge(bridge); err != nil { bridgeCleanup() - if assets.Cleanup != nil { - assets.Cleanup() - } - return Result{}, fmt.Errorf("launchpack: verify source bridge: %w", err) + cleanupAssets(assets) + return Assets{}, fmt.Errorf("launchpack: verify source bridge: %w", err) } } - oldCleanup := assets.Cleanup - cleanup := func() { + runtimeCleanup := assets.Cleanup + assets.Cleanup = func() { bridgeCleanup() - if oldCleanup != nil { - oldCleanup() + if runtimeCleanup != nil { + runtimeCleanup() } } - defer cleanup() + return assets, nil +} + +// BuildLauncher packages the project, runtime, and bridge into one executable. +func BuildLauncher(ctx context.Context, cfg Config) (Result, error) { + if ctx == nil { + return Result{}, fmt.Errorf("launchpack: nil context") + } + if err := cfg.validate(); err != nil { + return Result{}, err + } + if _, err := cfg.PortableConfig.Identity(); err != nil { + return Result{}, fmt.Errorf("launchpack: portable config: %w", err) + } + assets, err := PrepareAssets(ctx, cfg) + if err != nil { + return Result{}, err + } + defer cleanupAssets(assets) payload, manifest, err := buildLauncher(ctx, cfg, assets, cfg.PortableConfig, cfg.IO) if err != nil { @@ -102,6 +116,12 @@ func BuildLauncher(ctx context.Context, cfg Config) (Result, error) { }, nil } +func cleanupAssets(assets Assets) { + if assets.Cleanup != nil { + assets.Cleanup() + } +} + func (c Config) verifyGraph(ctx context.Context, phase string) error { if c.VerifyGraph == nil { return nil diff --git a/internal/launchpack/types.go b/internal/launchpack/types.go index bb5fa6e1e..1ee62b303 100644 --- a/internal/launchpack/types.go +++ b/internal/launchpack/types.go @@ -73,6 +73,10 @@ type Config struct { RuntimeLock release.RuntimeLock Source SourceIdentity + // DriverAssetDir is an optional local mirror for published driver + // manifests and bundles. It is intentionally separate from runtime assets. + DriverAssetDir string + GoCommand string WorkDir string GoWork string @@ -86,13 +90,29 @@ type Config struct { IO IO } +// PublishedDriverIdentity is the trust identity of one published driver +// bundle and its materialized components. +type PublishedDriverIdentity struct { + ManifestSHA256 string + BundleSHA256 string + BundleName string + SPXVersion string + ProducerCommit string + EngineSHA256 string + PackSHA256 string + BridgeSHA256 string + EngineInterfaceDigest string +} + // Assets are the verified runtime and source bridge files used by a launcher -// or a direct project run. The caller must invoke Cleanup when done. +// or a direct project run. Published is nil for source assets. The caller +// must invoke Cleanup when done. type Assets struct { EnginePath string PackPath string BridgePath string Lock release.RuntimeLock + Published *PublishedDriverIdentity Cleanup func() } diff --git a/internal/launchpack/validation.go b/internal/launchpack/validation.go index a6b10c9ac..b98f9f1eb 100644 --- a/internal/launchpack/validation.go +++ b/internal/launchpack/validation.go @@ -26,12 +26,11 @@ import ( ) func (c Config) validate() error { - for name, value := range map[string]string{ - "project-dir": c.ProjectDir, "project-file": c.ProjectFile, - "output": c.Output, + for _, item := range []struct{ name, value string }{ + {"project-dir", c.ProjectDir}, {"project-file", c.ProjectFile}, {"output", c.Output}, } { - if value == "" { - return fmt.Errorf("launchpack: %s is required", name) + if item.value == "" { + return fmt.Errorf("launchpack: %s is required", item.name) } } if err := regularPath("project-dir", c.ProjectDir, true); err != nil { @@ -68,14 +67,35 @@ func (c Config) validate() error { if c.Output == c.ProjectDir || pathWithin(packRoot, c.Output) { return fmt.Errorf("launchpack: output must not be inside the pack directory") } - if c.BridgePackage == "" { - return fmt.Errorf("launchpack: bridge package is required") + if c.Source.SourceMode { + if c.BridgePackage == "" { + return fmt.Errorf("launchpack: bridge package is required") + } + } else { + if err := validatePublishedSource(c.Source); err != nil { + return err + } + if c.BridgePackage != "" { + return fmt.Errorf("launchpack: published mode must not configure a bridge package") + } + if c.RuntimeSourceRoot != "" { + return fmt.Errorf("launchpack: published mode must not configure a runtime source root") + } } if c.RuntimeSourceRoot != "" { if err := regularPath("runtime-source-root", c.RuntimeSourceRoot, true); err != nil { return err } } + if c.DriverAssetDir != "" && (!filepath.IsAbs(c.DriverAssetDir) || filepath.Clean(c.DriverAssetDir) != c.DriverAssetDir) { + return fmt.Errorf("launchpack: driver asset directory must be an absolute clean path") + } + if !c.Source.SourceMode && c.RuntimeAssetDir != "" { + return fmt.Errorf("launchpack: published mode must not configure a runtime asset directory") + } + if !c.Source.SourceMode && c.RuntimeManifestPath != "" { + return fmt.Errorf("launchpack: published mode must not configure a runtime manifest") + } return nil } diff --git a/internal/release/runtime_manifest_pin.go b/internal/release/runtime_manifest_pin.go index 9d5b9ed25..0618fea46 100644 --- a/internal/release/runtime_manifest_pin.go +++ b/internal/release/runtime_manifest_pin.go @@ -17,8 +17,10 @@ package release import ( + "bytes" "crypto/sha256" "embed" + "encoding/json" "errors" "fmt" "io/fs" @@ -42,6 +44,44 @@ type RuntimeManifestPin struct { SHA256 string `json:"sha256"` } +// NewRuntimeManifestPin pins a manifest after validating it against lock. +func NewRuntimeManifestPin(lock RuntimeLock, data []byte) (RuntimeManifestPin, error) { + manifest, err := ParseRuntimeManifest(data) + if err != nil { + return RuntimeManifestPin{}, err + } + if err := manifest.ValidateForLock(lock); err != nil { + return RuntimeManifestPin{}, err + } + canonical, err := manifest.JSON() + if err != nil { + return RuntimeManifestPin{}, err + } + if !bytes.Equal(data, canonical) { + return RuntimeManifestPin{}, errors.New("release: runtime manifest is not canonical") + } + pin := RuntimeManifestPin{ + Schema: runtimeManifestPinSchema, RuntimeVersion: lock.RuntimeVersion, + Name: lock.Manifest, Size: int64(len(data)), SHA256: fmt.Sprintf("%x", sha256.Sum256(data)), + } + if err := pin.ValidateForLock(lock); err != nil { + return RuntimeManifestPin{}, err + } + return pin, nil +} + +// JSON returns canonical, human-readable pin bytes. +func (p RuntimeManifestPin) JSON() ([]byte, error) { + if err := p.validate(); err != nil { + return nil, err + } + data, err := json.MarshalIndent(p, "", " ") + if err != nil { + return nil, fmt.Errorf("release: encode runtime manifest pin: %w", err) + } + return append(data, '\n'), nil +} + var ( //go:embed runtime_manifest_pins/*.json embeddedRuntimeManifestPins embed.FS @@ -92,6 +132,21 @@ func (p RuntimeManifestPin) ValidateForLock(lock RuntimeLock) error { return nil } +// Verify checks manifest bytes against this pin. +func (p RuntimeManifestPin) Verify(data []byte) error { + if err := p.validate(); err != nil { + return err + } + if int64(len(data)) != p.Size { + return fmt.Errorf("release: runtime manifest size = %d, want %d", len(data), p.Size) + } + digest := fmt.Sprintf("%x", sha256.Sum256(data)) + if digest != p.SHA256 { + return fmt.Errorf("release: runtime manifest SHA-256 = %s, want %s", digest, p.SHA256) + } + return nil +} + // RuntimeManifestPinForLock returns the pinned manifest identity for lock. // Missing pins fail closed. func RuntimeManifestPinForLock(lock RuntimeLock) (RuntimeManifestPin, error) { diff --git a/internal/release/runtime_manifest_pin_test.go b/internal/release/runtime_manifest_pin_test.go index 27db87c1a..bcbdb7017 100644 --- a/internal/release/runtime_manifest_pin_test.go +++ b/internal/release/runtime_manifest_pin_test.go @@ -17,7 +17,9 @@ package release import ( + "crypto/sha256" "errors" + "fmt" "strings" "testing" ) @@ -75,15 +77,49 @@ func TestRuntimeManifestPinForLockRejectsUnpinnedRuntime(t *testing.T) { } } -func TestRuntimeManifestPinForLockDoesNotInventCurrentRuntime(t *testing.T) { - lock := DefaultRuntimeLock() - if lock.RuntimeVersion != "2.4.4" { - t.Fatalf("default runtime version = %q, want current unpublished 2.4.4", lock.RuntimeVersion) +func TestRuntimeManifestPinVerify(t *testing.T) { + data := []byte("manifest") + pin := RuntimeManifestPin{ + Schema: 1, RuntimeVersion: "9.9.9", Name: "runtime-manifest.json", + Size: int64(len(data)), SHA256: fmt.Sprintf("%x", sha256.Sum256(data)), + } + if err := pin.Verify(data); err != nil { + t.Fatal(err) + } + if err := pin.Verify([]byte("manifesT")); err == nil { + t.Fatal("runtime manifest pin accepted tampered bytes") + } +} + +func TestNewRuntimeManifestPin(t *testing.T) { + lock, provenance, inputs, _ := runtimeManifestFixture(t) + manifest, err := GenerateRuntimeManifest(lock, provenance, inputs) + if err != nil { + t.Fatal(err) + } + data, err := manifest.JSON() + if err != nil { + t.Fatal(err) + } + pin, err := NewRuntimeManifestPin(lock, data) + if err != nil { + t.Fatal(err) + } + if err := pin.Verify(data); err != nil { + t.Fatal(err) + } + encoded, err := pin.JSON() + if err != nil { + t.Fatal(err) + } + parsed, err := parseRuntimeManifestPin(encoded) + if err != nil || parsed != pin { + t.Fatalf("round trip pin = %#v, %v", parsed, err) } - if _, ok := runtimeManifestPins[lock.RuntimeVersion]; ok { - t.Fatalf("runtime manifest pin unexpectedly exists for unpublished %s", lock.RuntimeVersion) + if _, err := NewRuntimeManifestPin(lock, []byte(`{}`)); err == nil { + t.Fatal("invalid runtime manifest was pinned") } - if _, err := RuntimeManifestPinForLock(lock); err == nil || !strings.Contains(err.Error(), "no runtime manifest pin") { - t.Fatalf("RuntimeManifestPinForLock error = %v, want missing-pin failure", err) + if _, err := NewRuntimeManifestPin(lock, append(data, '\n')); err == nil { + t.Fatal("non-canonical runtime manifest was pinned") } } diff --git a/internal/runtimebundle/acquire.go b/internal/runtimebundle/acquire.go index ecb181a44..1fe335c4a 100644 --- a/internal/runtimebundle/acquire.go +++ b/internal/runtimebundle/acquire.go @@ -32,6 +32,9 @@ import ( var errAcquireSizeLimit = errors.New("runtimebundle: acquired asset exceeds size limit") +// ErrOfflineCacheMiss reports that offline acquisition has no cached file. +var ErrOfflineCacheMiss = errors.New("runtimebundle: offline cache miss") + // FetchFunc writes one URL response to dst and must honor ctx. type FetchFunc func(ctx context.Context, url string, dst io.Writer) error @@ -169,7 +172,7 @@ func acquirePath(ctx context.Context, root string, spec FetchSpec) (string, erro return "", fmt.Errorf("runtimebundle: inspect cached asset %q: %w", path, err) } if spec.Offline { - return "", fmt.Errorf("runtimebundle: offline cache miss for %q (URL: %s)", spec.Name, spec.URL) + return "", fmt.Errorf("%w for %q (URL: %s)", ErrOfflineCacheMiss, spec.Name, spec.URL) } if spec.Fetch == nil { return "", fmt.Errorf("runtimebundle: no fetcher for %q", spec.Name) diff --git a/internal/runtimebundle/acquire_test.go b/internal/runtimebundle/acquire_test.go index ac991786b..b1fb7de60 100644 --- a/internal/runtimebundle/acquire_test.go +++ b/internal/runtimebundle/acquire_test.go @@ -157,6 +157,9 @@ func TestAcquireOfflineMissDoesNotFetch(t *testing.T) { if err == nil { t.Fatal("offline cache miss unexpectedly succeeded") } + if !errors.Is(err, ErrOfflineCacheMiss) { + t.Fatalf("offline cache miss error = %v, want ErrOfflineCacheMiss", err) + } if fetches.Load() != 0 { t.Fatalf("fetch count = %d, want 0", fetches.Load()) } @@ -224,6 +227,9 @@ func TestAcquireOfflineInvalidCacheDoesNotFetch(t *testing.T) { if err == nil { t.Fatal("offline invalid cache unexpectedly succeeded") } + if errors.Is(err, ErrOfflineCacheMiss) { + t.Fatalf("offline invalid cache error = %v, must not be a cache miss", err) + } if fetches.Load() != 0 { t.Fatalf("fetch count = %d, want 0", fetches.Load()) } diff --git a/internal/runtimebundle/cache_materialize.go b/internal/runtimebundle/cache_materialize.go index 1d474f71b..997db624d 100644 --- a/internal/runtimebundle/cache_materialize.go +++ b/internal/runtimebundle/cache_materialize.go @@ -268,6 +268,29 @@ func (c *Cache) Materialize(ctx context.Context, namespace Namespace, zipPath st return hit, nil } +// Lookup returns a verified materialized bundle while holding its shared use +// lease. It prepares the namespace and per-target lock even on a miss. +func (c *Cache) Lookup(ctx context.Context, namespace Namespace, expected *Bundle) (*Materialized, bool, error) { + if c == nil { + return nil, false, fmt.Errorf("runtimebundle: nil cache") + } + if ctx == nil { + ctx = context.Background() + } + limits, err := c.Limits.withDefaults() + if err != nil { + return nil, false, err + } + if expected == nil { + return nil, false, fmt.Errorf("runtimebundle: expected bundle is required") + } + expected, err = normalizeExpectedBundle(namespace, expected, limits) + if err != nil { + return nil, false, err + } + return c.tryMaterializedHit(ctx, namespace, expected.Digest, expected) +} + func normalizeExpectedBundle(namespace Namespace, expected *Bundle, limits Limits) (*Bundle, error) { if expected == nil { return nil, nil diff --git a/internal/runtimebundle/cache_test.go b/internal/runtimebundle/cache_test.go index 92dbe108e..bbde5eeee 100644 --- a/internal/runtimebundle/cache_test.go +++ b/internal/runtimebundle/cache_test.go @@ -277,6 +277,105 @@ func TestNewCacheUsesCrossProcessLockAndNilProviderFailsClosed(t *testing.T) { } } +func TestCacheLookupReturnsOnlyVerifiedHits(t *testing.T) { + zipPath := writeTestZip(t, testZipEntry{name: "bridge", data: "bridge"}) + bundle, err := VerifyZip(zipPath) + if err != nil { + t.Fatal(err) + } + bundle.Namespace = NamespaceDriver + bundle, err = bundle.WithDigest() + if err != nil { + t.Fatal(err) + } + cache := NewCache(t.TempDir()) + if hit, ok, err := cache.Lookup(context.Background(), NamespaceDriver, &bundle); err != nil || ok || hit != nil { + t.Fatalf("empty cache lookup = %#v, %t, %v", hit, ok, err) + } + materialized, err := cache.Materialize(context.Background(), NamespaceDriver, zipPath, &bundle) + if err != nil { + t.Fatal(err) + } + if err := materialized.Close(); err != nil { + t.Fatal(err) + } + hit, ok, err := cache.Lookup(context.Background(), NamespaceDriver, &bundle) + if err != nil || !ok || hit == nil || hit.Path != materialized.Path { + t.Fatalf("materialized lookup = %#v, %t, %v", hit, ok, err) + } + if err := hit.Close(); err != nil { + t.Fatal(err) + } +} + +func TestCacheMaterializeBindsArchiveDigest(t *testing.T) { + firstZip := writeTestZip(t, + testZipEntry{name: "engine", mode: 0o700, data: "engine"}, + testZipEntry{name: "bridge", mode: 0o700, data: "bridge"}, + ) + secondZip := writeTestZip(t, + testZipEntry{name: "bridge", mode: 0o700, data: "bridge"}, + testZipEntry{name: "engine", mode: 0o700, data: "engine"}, + ) + firstData, err := os.ReadFile(firstZip) + if err != nil { + t.Fatal(err) + } + secondData, err := os.ReadFile(secondZip) + if err != nil { + t.Fatal(err) + } + first, err := VerifyZip(firstZip) + if err != nil { + t.Fatal(err) + } + first.Namespace = NamespaceDriver + first.ArchiveSHA256 = testDigest(string(firstData)) + first, err = first.WithDigest() + if err != nil { + t.Fatal(err) + } + second, err := VerifyZip(secondZip) + if err != nil { + t.Fatal(err) + } + second.Namespace = NamespaceDriver + second.ArchiveSHA256 = testDigest(string(secondData)) + second, err = second.WithDigest() + if err != nil { + t.Fatal(err) + } + if first.ArchiveSHA256 == second.ArchiveSHA256 || first.Digest == second.Digest { + t.Fatalf("archive-bound identities did not diverge: %#v / %#v", first, second) + } + + cache := NewCache(t.TempDir()) + firstHit, err := cache.Materialize(context.Background(), NamespaceDriver, firstZip, &first) + if err != nil { + t.Fatalf("materialize first archive: %v", err) + } + defer firstHit.Close() + secondHit, err := cache.Materialize(context.Background(), NamespaceDriver, secondZip, &second) + if err != nil { + t.Fatalf("materialize second archive: %v", err) + } + defer secondHit.Close() + if firstHit.Path == secondHit.Path { + t.Fatalf("different archive digests reused cache target %q", firstHit.Path) + } + storedData, err := os.ReadFile(filepath.Join(firstHit.Path, cacheManifestName)) + if err != nil { + t.Fatal(err) + } + stored, err := ParseManifest(storedData) + if err != nil { + t.Fatal(err) + } + if stored.ArchiveSHA256 != first.ArchiveSHA256 { + t.Fatalf("stored archive digest = %q, want %q", stored.ArchiveSHA256, first.ArchiveSHA256) + } +} + func TestRuntimeBundleProcessHelper(t *testing.T) { if os.Getenv("RUNTIMEBUNDLE_HELPER") == "" { return diff --git a/internal/runtimebundle/manifest.go b/internal/runtimebundle/manifest.go index 320e84f8e..dee82c049 100644 --- a/internal/runtimebundle/manifest.go +++ b/internal/runtimebundle/manifest.go @@ -101,12 +101,13 @@ type Namespace string const ( NamespaceEngine Namespace = "engine" NamespaceBridge Namespace = "bridge" + NamespaceDriver Namespace = "driver" NamespaceProject Namespace = "project" ) func (n Namespace) valid() bool { switch n { - case NamespaceEngine, NamespaceBridge, NamespaceProject: + case NamespaceEngine, NamespaceBridge, NamespaceDriver, NamespaceProject: return true default: return false @@ -126,13 +127,15 @@ type Entry struct { // Bundle is the self-describing manifest used by the runtime cache. Digest is // metadata and is intentionally excluded from the identity calculation, so a -// manifest cannot hash itself. A missing Schema is accepted when reading a -// v1 fixture and is normalized to SchemaV1. +// manifest cannot hash itself. ArchiveSHA256 optionally binds a materialized +// bundle to the exact source archive whose contents were verified. A missing +// Schema is accepted when reading a v1 fixture and is normalized to SchemaV1. type Bundle struct { - Schema string `json:"schema,omitempty"` - Namespace Namespace `json:"namespace,omitempty"` - Entries []Entry `json:"entries"` - Digest string `json:"digest,omitempty"` + Schema string `json:"schema,omitempty"` + Namespace Namespace `json:"namespace,omitempty"` + ArchiveSHA256 string `json:"archive_sha256,omitempty"` + Entries []Entry `json:"entries"` + Digest string `json:"digest,omitempty"` } var ( @@ -207,6 +210,11 @@ func (b Bundle) ValidateWithLimits(limits Limits) error { if b.Namespace != "" && !b.Namespace.valid() { return fmt.Errorf("%w: unsupported namespace %q", ErrInvalidManifest, b.Namespace) } + if b.ArchiveSHA256 != "" { + if err := validateSHA256(b.ArchiveSHA256); err != nil { + return fmt.Errorf("%w: archive SHA-256: %v", ErrInvalidManifest, err) + } + } if len(b.Entries) > limits.MaxEntries { return fmt.Errorf("%w: %d entries exceeds limit %d", ErrArchiveLimit, len(b.Entries), limits.MaxEntries) } @@ -263,9 +271,10 @@ func (b Bundle) ValidateWithLimits(limits Limits) error { } type canonicalBundle struct { - Schema string `json:"schema"` - Namespace Namespace `json:"namespace,omitempty"` - Entries []Entry `json:"entries"` + Schema string `json:"schema"` + Namespace Namespace `json:"namespace,omitempty"` + ArchiveSHA256 string `json:"archive_sha256,omitempty"` + Entries []Entry `json:"entries"` } func (b Bundle) canonical() (canonicalBundle, error) { @@ -277,7 +286,12 @@ func (b Bundle) canonical() (canonicalBundle, error) { if err := withoutDigest.ValidateWithLimits(Limits{}); err != nil { return canonicalBundle{}, err } - out := canonicalBundle{Schema: b.Schema, Namespace: b.Namespace, Entries: make([]Entry, 0, len(b.Entries))} + out := canonicalBundle{ + Schema: b.Schema, + Namespace: b.Namespace, + ArchiveSHA256: b.ArchiveSHA256, + Entries: make([]Entry, 0, len(b.Entries)), + } if out.Schema == "" { out.Schema = SchemaV1 } @@ -471,7 +485,7 @@ func manifestEntriesEqual(a, b Bundle) error { if err != nil { return err } - if left.Schema != right.Schema || left.Namespace != right.Namespace || len(left.Entries) != len(right.Entries) { + if left.Schema != right.Schema || left.Namespace != right.Namespace || left.ArchiveSHA256 != right.ArchiveSHA256 || len(left.Entries) != len(right.Entries) { return fmt.Errorf("%w: manifest identity fields differ", ErrDigestMismatch) } for i := range left.Entries { diff --git a/internal/runtimebundle/verify.go b/internal/runtimebundle/verify.go index 8d379fbee..c8f3dea88 100644 --- a/internal/runtimebundle/verify.go +++ b/internal/runtimebundle/verify.go @@ -301,6 +301,9 @@ func verifyReaderAt(reader io.ReaderAt, size int64, options VerifyOptions) (veri want.Digest = "" if want.Namespace != "" { bundle.Namespace = want.Namespace + } + bundle.ArchiveSHA256 = want.ArchiveSHA256 + if want.Namespace != "" || want.ArchiveSHA256 != "" { bundle, err = bundle.WithDigest() if err != nil { return verifiedArchive{}, err diff --git a/internal/xgodriver/argv.go b/internal/xgodriver/argv.go new file mode 100644 index 000000000..b736c1553 --- /dev/null +++ b/internal/xgodriver/argv.go @@ -0,0 +1,222 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Package xgodriver contains SPX's private project-driver implementation. +package xgodriver + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/goplus/mod/driverprotocol" + "github.com/goplus/mod/xgomod" + "github.com/goplus/spx/v3/internal/projectassets" + "github.com/goplus/spx/v3/internal/projectpolicy" +) + +const ProtocolV1 = driverprotocol.PreambleV1 + +type Action = driverprotocol.Action + +const ( + ActionRun = driverprotocol.ActionRun + ActionBuild = driverprotocol.ActionBuild +) + +// ModuleRef and ModuleOrigin are XGo's resolved graph identities. +type ModuleRef = xgomod.ModuleRef +type ModuleOrigin = xgomod.ResolvedModule + +type ProjectSnapshot struct { + Extension string + FullExtension string + PackDirectory string + PackIndexFile string +} + +// Config is SPX's domain view of a driver request. +type Config struct { + Action Action + ProjectDir string + ProjectFile string + ModuleRoot string + DriverPackage string + DriverOrigin ModuleOrigin + Declaration xgomod.FileIdentity + Project ProjectSnapshot + GoCommand string + GraphWorkDir string + GoWork string + GraphFlags []string + BuildFlags []string + Output string + FinalOutput string + ApplicationArgs []string +} + +// Parse decodes a request and verifies its live SPX inputs. +func Parse(args []string) (Config, error) { + request, err := driverprotocol.Parse(args) + if err != nil { + return Config{}, err + } + cfg := Config{ + Action: request.Action, + ProjectDir: request.Project.Dir, + ProjectFile: request.Project.File, + ModuleRoot: request.Project.ModuleRoot, + DriverPackage: request.DriverPackage, + DriverOrigin: request.DriverOrigin, + Declaration: request.Declaration, + Project: ProjectSnapshot{ + Extension: request.Project.Extension, + FullExtension: request.Project.FullExtension, + }, + GoCommand: request.Graph.GoCommand, + GraphWorkDir: request.Graph.WorkDir, + GoWork: request.Graph.GoWork, + GraphFlags: append([]string(nil), request.Graph.Flags...), + BuildFlags: append([]string(nil), request.BuildFlags...), + ApplicationArgs: append([]string(nil), request.ApplicationArgs...), + } + if request.Project.Pack != nil { + cfg.Project.PackDirectory = request.Project.Pack.Directory + cfg.Project.PackIndexFile = request.Project.Pack.IndexFile + } + if request.Output != nil { + cfg.Output = request.Output.Staging + cfg.FinalOutput = request.Output.Final + } + if err := cfg.validateLive(); err != nil { + return Config{}, err + } + return cfg, nil +} + +// validateLive binds the decoded request to its filesystem inputs. +func (p Config) validateLive() error { + for _, item := range []struct { + name string + value string + directory bool + }{ + {name: "project-dir", value: p.ProjectDir, directory: true}, + {name: "project-file", value: p.ProjectFile}, + {name: "module-root", value: p.ModuleRoot, directory: true}, + {name: "declaration-file", value: p.Declaration.Path}, + } { + if err := validateCanonicalExistingPath(item.name, item.value, item.directory); err != nil { + return err + } + } + if err := p.DriverOrigin.Validate(); err != nil { + return fmt.Errorf("project driver origin: %w", err) + } + effective := p.DriverOrigin.Effective() + if !pathWithin(effective.Dir, p.Declaration.Path) { + return fmt.Errorf("project driver declaring gox.mod must be within the effective module dir") + } + if err := p.validateGraphInputs(); err != nil { + return err + } + if p.Project.PackDirectory != "" { + if err := projectpolicy.ValidatePortableConfig(p.ProjectDir); err != nil { + return fmt.Errorf("project driver: %w", err) + } + packRoot := filepath.Join(p.ProjectDir, filepath.FromSlash(p.Project.PackDirectory)) + if err := validateCanonicalExistingPath("pack-dir", packRoot, true); err != nil { + return err + } + if _, err := projectassets.Resolve(projectassets.Config{ + ProjectDir: p.ProjectDir, + PackDir: p.Project.PackDirectory, + PackIndex: p.Project.PackIndexFile, + }); err != nil { + return fmt.Errorf("project driver assets: %w", err) + } + } + return nil +} + +func (p Config) validateGraphInputs() error { + if err := validateCanonicalExistingPath("go-command", p.GoCommand, false); err != nil { + return err + } + if err := validateCanonicalExistingPath("graph-work-dir", p.GraphWorkDir, true); err != nil { + return err + } + if p.GoWork != "off" { + if err := validateCanonicalExistingPath("go-work", p.GoWork, false); err != nil { + return err + } + } + for _, flag := range p.GraphFlags { + name, value, ok := strings.Cut(strings.TrimPrefix(flag, "-"), "=") + if !ok { + return fmt.Errorf("project driver graph flag is not canonical: %q", flag) + } + switch name { + case "overlay": + return fmt.Errorf("project driver does not support -overlay because the project snapshot uses physical filesystem contents") + case "modfile": + if err := validateCanonicalExistingPath("graph-flag-"+name, value, false); err != nil { + return err + } + } + } + return nil +} + +// validateCanonicalExistingPath checks XGo's canonical-path claim. +func validateCanonicalExistingPath(name, value string, directory bool) error { + before, err := os.Lstat(value) + if err != nil { + return fmt.Errorf("project driver path --%s cannot be inspected: %w", name, err) + } + if before.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("project driver path --%s must not be a symlink: %q", name, value) + } + resolved, err := filepath.EvalSymlinks(value) + if err != nil { + return fmt.Errorf("project driver path --%s cannot be canonicalized: %w", name, err) + } + resolved = filepath.Clean(resolved) + if resolved != value { + return fmt.Errorf("project driver path --%s is not canonical: %q resolves to %q", name, value, resolved) + } + if directory { + if !before.IsDir() { + return fmt.Errorf("project driver path --%s is not a directory: %q", name, value) + } + } else if !before.Mode().IsRegular() { + return fmt.Errorf("project driver path --%s is not a regular file: %q", name, value) + } + if name == "go-command" && runtime.GOOS != "windows" && before.Mode().Perm()&0o111 == 0 { + return fmt.Errorf("project driver path --go-command is not executable: %q", value) + } + return nil +} + +func pathWithin(root, target string) bool { + rel, err := filepath.Rel(root, target) + if err != nil { + return false + } + return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) +} diff --git a/internal/xgodriver/argv_test.go b/internal/xgodriver/argv_test.go new file mode 100644 index 000000000..afbd09fdb --- /dev/null +++ b/internal/xgodriver/argv_test.go @@ -0,0 +1,427 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xgodriver + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestParseRun(t *testing.T) { + args := validArgs(t, ActionRun) + args = append(args, + "--graph-flag=-mod=readonly", + "--build-flag=-trimpath=true", + "--", + "--headless", "", "a b", "--", "--output=application-value", + ) + + cfg, err := Parse(args) + if err != nil { + t.Fatalf("Parse() error: %v", err) + } + if cfg.Action != ActionRun { + t.Fatalf("Action = %q, want run", cfg.Action) + } + if got, want := cfg.GraphFlags, []string{"-mod=readonly"}; !reflect.DeepEqual(got, want) { + t.Fatalf("GraphFlags = %#v, want %#v", got, want) + } + if got, want := cfg.GraphWorkDir, filepath.Dir(cfg.ProjectDir); got != want { + t.Fatalf("GraphWorkDir = %q, want %q", got, want) + } + if got, want := cfg.BuildFlags, []string{"-trimpath=true"}; !reflect.DeepEqual(got, want) { + t.Fatalf("BuildFlags = %#v, want %#v", got, want) + } + wantAppArgs := []string{"--headless", "", "a b", "--", "--output=application-value"} + if !reflect.DeepEqual(cfg.ApplicationArgs, wantAppArgs) { + t.Fatalf("ApplicationArgs = %#v, want %#v", cfg.ApplicationArgs, wantAppArgs) + } + if cfg.Output != "" || cfg.FinalOutput != "" { + t.Fatalf("run outputs = %q/%q, want empty", cfg.Output, cfg.FinalOutput) + } + if cfg.DriverOrigin.Replace != nil { + t.Fatalf("Replace = %#v, want nil", cfg.DriverOrigin.Replace) + } + if cfg.DriverOrigin.Selected.Dir == "" || cfg.DriverOrigin.Selected.GoMod == "" { + t.Fatalf("selected source is incomplete: %#v", cfg.DriverOrigin.Selected) + } +} + +func TestParseBuildWithReplacement(t *testing.T) { + args := validArgs(t, ActionBuild) + args = removeOptions(args, "selected-dir", "selected-gomod") + root := filepath.Dir(optionValue(args, "project-dir")) + localSPX := filepath.Join(root, "local-spx") + mustWriteDriverTestFile(t, filepath.Join(localSPX, "go.mod"), "module github.com/goplus/spx/v3\n", 0o600) + mustWriteDriverTestFile(t, filepath.Join(localSPX, "gox.mod"), "xgo 1.8\n", 0o600) + mustWriteDriverTestFile(t, filepath.Join(root, "alternate.mod"), "module example.com/alternate\n", 0o600) + args = replaceOption(args, "declaration-file", filepath.Join(root, "local-spx", "gox.mod")) + args = append(args, + "--replace-path="+localSPX, + "--replace-version=", + "--replace-dir="+filepath.Join(root, "local-spx"), + "--replace-gomod="+filepath.Join(root, "local-spx", "go.mod"), + "--graph-flag=-modfile="+filepath.Join(root, "alternate.mod"), + "--build-flag=-x=true", + "--output="+filepath.Join(root, "out", "game"), + "--final-output="+filepath.Join(root, "bin", "game"), + ) + + cfg, err := Parse(args) + if err != nil { + t.Fatalf("Parse() error: %v", err) + } + if cfg.DriverOrigin.Replace == nil { + t.Fatal("Replace = nil, want local replacement") + } + if got, want := cfg.DriverOrigin.Selected.Version, "v3.2.0"; got != want { + t.Fatalf("selected version = %q, want %q", got, want) + } + if cfg.DriverOrigin.Selected.Dir != "" || cfg.DriverOrigin.Selected.GoMod != "" { + t.Fatalf("selected source leaked into replacement origin: %#v", cfg.DriverOrigin.Selected) + } + if got, want := cfg.DriverOrigin.Effective().Path, localSPX; got != want { + t.Fatalf("effective path = %q, want %q", got, want) + } + wantGraph := []string{"-modfile=" + filepath.Join(root, "alternate.mod")} + if !reflect.DeepEqual(cfg.GraphFlags, wantGraph) { + t.Fatalf("GraphFlags = %#v, want %#v", cfg.GraphFlags, wantGraph) + } +} + +func TestParseAcceptsOfficialSplitModuleCacheGoMod(t *testing.T) { + args := validArgs(t, ActionRun) + root := filepath.Dir(optionValue(args, "project-dir")) + cacheRoot := filepath.Join(root, "gomodcache") + moduleDir := filepath.Join(cacheRoot, filepath.FromSlash("github.com/goplus/spx/v3@v3.2.0")) + cacheGoMod := filepath.Join(cacheRoot, filepath.FromSlash("cache/download/github.com/goplus/spx/v3/@v/v3.2.0.mod")) + mustWriteDriverTestFile(t, filepath.Join(moduleDir, "gox.mod"), "xgo 1.8\n", 0o600) + mustWriteDriverTestFile(t, cacheGoMod, "module github.com/goplus/spx/v3\n", 0o600) + args = replaceOption(args, "selected-dir", moduleDir) + args = replaceOption(args, "selected-gomod", cacheGoMod) + args = replaceOption(args, "declaration-file", filepath.Join(moduleDir, "gox.mod")) + if _, err := Parse(append(args, "--")); err != nil { + t.Fatalf("Parse() split module-cache source error: %v", err) + } +} + +func TestParseRejectsNonMatchingSplitModuleCacheGoMod(t *testing.T) { + args := validArgs(t, ActionRun) + root := filepath.Dir(optionValue(args, "project-dir")) + cacheRoot := filepath.Join(root, "gomodcache") + moduleDir := filepath.Join(cacheRoot, filepath.FromSlash("github.com/goplus/spx/v3@v3.2.0")) + cacheGoMod := filepath.Join(cacheRoot, filepath.FromSlash("cache/download/github.com/goplus/spx/v3/@v/v3.2.0.mod")) + mustWriteDriverTestFile(t, filepath.Join(moduleDir, "gox.mod"), "xgo 1.8\n", 0o600) + mustWriteDriverTestFile(t, cacheGoMod, "module github.com/example/not-spx\n", 0o600) + args = replaceOption(args, "selected-dir", moduleDir) + args = replaceOption(args, "selected-gomod", cacheGoMod) + args = replaceOption(args, "declaration-file", filepath.Join(moduleDir, "gox.mod")) + if _, err := Parse(append(args, "--")); err == nil || !strings.Contains(err.Error(), "module-cache") { + t.Fatalf("Parse() split module-cache mismatch error = %v", err) + } +} + +func TestParseRunRejectsMissingPackDirectory(t *testing.T) { + args := validArgs(t, ActionRun) + packDir := filepath.Join(optionValue(args, "project-dir"), filepath.FromSlash(optionValue(args, "pack-dir"))) + if err := os.RemoveAll(packDir); err != nil { + t.Fatal(err) + } + if _, err := Parse(append(args, "--")); err == nil { + t.Fatal("Parse() accepted project without a materialized pack directory") + } +} + +func TestParseRejectsMissingGraphInput(t *testing.T) { + for _, name := range []string{"modfile"} { + t.Run(name, func(t *testing.T) { + args := validArgs(t, ActionRun) + missing := filepath.Join(filepath.Dir(optionValue(args, "project-dir")), "missing-"+name) + args = append(args, "--graph-flag=-"+name+"="+missing, "--") + if _, err := Parse(args); err == nil || !strings.Contains(err.Error(), "cannot be inspected") { + t.Fatalf("Parse() missing %s error = %v", name, err) + } + }) + } +} + +func TestParseRejectsOverlayBeforeExecution(t *testing.T) { + args := validArgs(t, ActionRun) + projectDir := optionValue(args, "project-dir") + root := filepath.Dir(projectDir) + overlay := filepath.Join(root, "overlay.json") + mustWriteDriverTestFile(t, overlay, "{}\n", 0o600) + mustWriteDriverTestFile(t, filepath.Join(projectDir, ".config"), `{"extasset":"../shared"}`, 0o600) + if err := os.RemoveAll(filepath.Join(projectDir, filepath.FromSlash(optionValue(args, "pack-dir")))); err != nil { + t.Fatal(err) + } + args = append(args, "--graph-flag=-overlay="+overlay, "--") + if _, err := Parse(args); err == nil || !strings.Contains(err.Error(), "does not support -overlay") { + t.Fatalf("Parse() overlay error = %v, want explicit unsupported error", err) + } +} + +func TestParseRejectsExtAssetOnlyForPortableDriver(t *testing.T) { + args := validArgs(t, ActionRun) + projectDir := optionValue(args, "project-dir") + mustWriteDriverTestFile(t, filepath.Join(projectDir, ".config"), `{"extasset":"custom_asset"}`, 0o600) + if _, err := Parse(append(args, "--")); err == nil || !strings.Contains(err.Error(), "unsupported extasset") { + t.Fatalf("Parse() extasset error = %v, want portable-policy rejection", err) + } +} + +func TestParseRunAcceptsAbsentPackGroup(t *testing.T) { + args := removeOptions(append(validArgs(t, ActionRun), "--"), "pack-dir", "pack-index") + if _, err := Parse(args); err != nil { + t.Fatalf("Parse() absent pack group error: %v", err) + } +} + +func TestParseRunAcceptsPackedOnlyProject(t *testing.T) { + args := validArgs(t, ActionRun) + projectDir := optionValue(args, "project-dir") + packDir := filepath.Join(projectDir, filepath.FromSlash(optionValue(args, "pack-dir"))) + if err := os.Remove(filepath.Join(packDir, optionValue(args, "pack-index"))); err != nil { + t.Fatal(err) + } + mustWriteDriverTestFile(t, filepath.Join(packDir, "index_pack.json"), `{"zorder":[]}`, 0o600) + if _, err := Parse(append(args, "--")); err != nil { + t.Fatalf("Parse() packed-only error: %v", err) + } +} + +func TestParseRejectsInvalidArgv(t *testing.T) { + tests := []struct { + name string + args func(*testing.T) []string + want string + }{ + {"missing protocol", func(*testing.T) []string { return nil }, "requires preamble and action"}, + {"legacy runtime preamble", func(t *testing.T) []string { + args := validArgs(t, ActionRun) + args[0] = "xgo-runtime-v1" + return append(args, "--") + }, "unsupported preamble"}, + {"unknown action", func(t *testing.T) []string { + args := validArgs(t, ActionRun) + args[1] = "test" + return append(args, "--") + }, "unsupported action"}, + {"run delimiter", func(t *testing.T) []string { return validArgs(t, ActionRun) }, "requires --"}, + {"build delimiter", func(t *testing.T) []string { return append(validArgs(t, ActionBuild), "--") }, "does not accept --"}, + {"positional option", func(t *testing.T) []string { return append(validArgs(t, ActionRun), "positional", "--") }, "positional argument"}, + {"missing equals", func(t *testing.T) []string { return append(validArgs(t, ActionRun), "--graph-flag", "--") }, "--name=value"}, + {"unknown option", func(t *testing.T) []string { return append(validArgs(t, ActionRun), "--mystery=value", "--") }, "unknown option --mystery"}, + {"duplicate option", func(t *testing.T) []string { return append(validArgs(t, ActionRun), "--project-dir=/other", "--") }, "may not be repeated"}, + {"partial pack group (directory only)", func(t *testing.T) []string { + return append(removeOptions(validArgs(t, ActionRun), "pack-index"), "--") + }, "pack options must be supplied as a complete group"}, + {"partial pack group (index only)", func(t *testing.T) []string { + return append(removeOptions(validArgs(t, ActionRun), "pack-dir"), "--") + }, "pack options must be supplied as a complete group"}, + {"empty pack group", func(t *testing.T) []string { + args := replaceOption(validArgs(t, ActionRun), "pack-dir", "") + args = replaceOption(args, "pack-index", "") + return append(args, "--") + }, "pack directory"}, + {"partial replacement", func(t *testing.T) []string { return append(validArgs(t, ActionRun), "--replace-path=local", "--") }, "complete group"}, + {"mixed replacement source", func(t *testing.T) []string { + args := validArgs(t, ActionRun) + root := filepath.Dir(optionValue(args, "project-dir")) + return append(args, "--replace-path=local", "--replace-version=", "--replace-dir="+filepath.Join(root, "local"), "--replace-gomod="+filepath.Join(root, "local", "go.mod"), "--") + }, "forbids --selected-dir"}, + {"run output", func(t *testing.T) []string { return append(validArgs(t, ActionRun), "--output=/tmp/out", "--") }, "does not accept --output"}, + {"build missing output", func(t *testing.T) []string { return validArgs(t, ActionBuild) }, "requires --output and --final-output"}, + {"relative project", func(t *testing.T) []string { + return append(replaceOption(validArgs(t, ActionRun), "project-dir", "relative"), "--") + }, "must be absolute"}, + {"dirty project", func(t *testing.T) []string { + args := validArgs(t, ActionRun) + return append(replaceOption(args, "project-dir", optionValue(args, "project-dir")+string(filepath.Separator)+".."), "--") + }, "must be clean"}, + {"symlinked project root", func(t *testing.T) []string { + args := validArgs(t, ActionRun) + root := filepath.Dir(optionValue(args, "project-dir")) + realProject := optionValue(args, "project-dir") + alias := filepath.Join(root, "project-alias") + if err := os.Symlink(realProject, alias); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + args = replaceOption(args, "project-dir", alias) + args = replaceOption(args, "project-file", filepath.Join(alias, "main.spx")) + return append(args, "--") + }, "must not be a symlink"}, + {"escaping pack", func(t *testing.T) []string { + return append(replaceOption(validArgs(t, ActionRun), "pack-dir", "../assets"), "--") + }, "pack directory escapes"}, + {"bad index", func(t *testing.T) []string { + return append(replaceOption(validArgs(t, ActionRun), "pack-index", "dir/index.json"), "--") + }, "pack index must be"}, + {"missing all pack indexes", func(t *testing.T) []string { + args := validArgs(t, ActionRun) + indexPath := filepath.Join(optionValue(args, "project-dir"), filepath.FromSlash(optionValue(args, "pack-dir")), optionValue(args, "pack-index")) + if err := os.Remove(indexPath); err != nil { + t.Fatal(err) + } + return append(args, "--") + }, "index_pack.json"}, + {"bad digest", func(t *testing.T) []string { + return append(replaceOption(validArgs(t, ActionRun), "declaration-sha256", "nope"), "--") + }, "64 hexadecimal"}, + {"bad forwarded flag", func(t *testing.T) []string { return append(validArgs(t, ActionRun), "--graph-flag=mod=readonly", "--") }, "must use -name=value"}, + {"noncanonical graph flag", func(t *testing.T) []string { return append(validArgs(t, ActionRun), "--graph-flag=-mod", "--") }, "must use -name=value"}, + {"unsupported graph flag", func(t *testing.T) []string { + return append(validArgs(t, ActionRun), "--graph-flag=-toolexec=/tmp/tool", "--") + }, "-toolexec is not supported"}, + {"unsupported mod value", func(t *testing.T) []string { return append(validArgs(t, ActionRun), "--graph-flag=-mod=evil", "--") }, "unsupported value"}, + {"relative modfile", func(t *testing.T) []string { + return append(validArgs(t, ActionRun), "--graph-flag=-modfile=alternate.mod", "--") + }, "must be absolute"}, + {"missing graph work directory", func(t *testing.T) []string { + args := validArgs(t, ActionRun) + missing := filepath.Join(filepath.Dir(optionValue(args, "project-dir")), "missing-graph-work-dir") + return append(replaceOption(args, "graph-work-dir", missing), "--") + }, "cannot be inspected"}, + {"duplicate graph flag", func(t *testing.T) []string { + return append(validArgs(t, ActionRun), "--graph-flag=-mod=readonly", "--graph-flag=-mod=mod", "--") + }, "may not be repeated"}, + {"unsupported build flag", func(t *testing.T) []string { return append(validArgs(t, ActionRun), "--build-flag=-ldflags=-s", "--") }, "-ldflags is not supported"}, + {"unsafe buildvcs", func(t *testing.T) []string { + return append(validArgs(t, ActionRun), "--build-flag=-buildvcs=true", "--") + }, "unsupported value"}, + {"duplicate build flag", func(t *testing.T) []string { + return append(validArgs(t, ActionRun), "--build-flag=-x=true", "--build-flag=-x=true", "--") + }, "may not be repeated"}, + {"noncanonical bool", func(t *testing.T) []string { + return append(replaceOption(validArgs(t, ActionRun), "origin-main", "1"), "--") + }, "expected true or false"}, + {"invalid selected module path", func(t *testing.T) []string { + return append(replaceOption(validArgs(t, ActionRun), "selected-path", "github.com/goplus/spx/v3@latest"), "--") + }, "invalid module path"}, + {"invalid driver import path", func(t *testing.T) []string { + return append(replaceOption(validArgs(t, ActionRun), "driver-package", "github.com/goplus/spx/v3/cmd/xgodriver@latest"), "--") + }, "invalid driver package"}, + {"same build outputs", func(t *testing.T) []string { + args := validArgs(t, ActionBuild) + root := filepath.Dir(optionValue(args, "project-dir")) + output := filepath.Join(root, "out", "game") + return append(args, "--output="+output, "--final-output="+output) + }, "must be different"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := Parse(test.args(t)) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Parse() error = %v, want containing %q", err, test.want) + } + }) + } +} + +func validArgs(t *testing.T, action Action) []string { + t.Helper() + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + projectDir := filepath.Join(root, "project") + selectedDir := filepath.Join(root, "spx") + mustWriteDriverTestFile(t, filepath.Join(projectDir, "main.spx"), "onStart => {}\n", 0o600) + mustWriteDriverTestFile(t, filepath.Join(projectDir, "assets", "index.json"), "{}\n", 0o600) + mustWriteDriverTestFile(t, filepath.Join(selectedDir, "go.mod"), "module github.com/goplus/spx/v3\n", 0o600) + mustWriteDriverTestFile(t, filepath.Join(selectedDir, "gox.mod"), "xgo 1.8\n", 0o600) + goCommand := filepath.Join(root, "bin", "go") + mustWriteDriverTestFile(t, goCommand, "#!/bin/sh\n", 0o700) + args := []string{ + ProtocolV1, + string(action), + "--project-dir=" + projectDir, + "--project-file=" + filepath.Join(projectDir, "main.spx"), + "--module-root=" + root, + "--driver-package=github.com/goplus/spx/v3/cmd/xgodriver", + "--selected-path=github.com/goplus/spx/v3", + "--selected-version=v3.2.0", + "--origin-main=false", + "--selected-dir=" + selectedDir, + "--selected-gomod=" + filepath.Join(selectedDir, "go.mod"), + "--project-ext=.spx", + "--project-full-ext=main.spx", + "--pack-dir=assets", + "--pack-index=index.json", + "--declaration-file=" + filepath.Join(selectedDir, "gox.mod"), + "--declaration-sha256=" + strings.Repeat("a", 64), + "--go-command=" + goCommand, + "--graph-work-dir=" + root, + "--go-work=off", + } + return args +} + +func mustWriteDriverTestFile(t *testing.T, name, content string, mode os.FileMode) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(name), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(name, []byte(content), mode); err != nil { + t.Fatal(err) + } + if err := os.Chmod(name, mode); err != nil { + t.Fatal(err) + } +} + +func removeOptions(args []string, names ...string) []string { + remove := make(map[string]bool, len(names)) + for _, name := range names { + remove[name] = true + } + result := make([]string, 0, len(args)) + for _, arg := range args { + name, _, ok := strings.Cut(strings.TrimPrefix(arg, "--"), "=") + if strings.HasPrefix(arg, "--") && ok && remove[name] { + continue + } + result = append(result, arg) + } + return result +} + +func replaceOption(args []string, name, value string) []string { + result := append([]string(nil), args...) + prefix := "--" + name + "=" + for i, arg := range result { + if strings.HasPrefix(arg, prefix) { + result[i] = prefix + value + return result + } + } + return append(result, prefix+value) +} + +func optionValue(args []string, name string) string { + prefix := "--" + name + "=" + for _, arg := range args { + if strings.HasPrefix(arg, prefix) { + return strings.TrimPrefix(arg, prefix) + } + } + return "" +} diff --git a/internal/xgodriver/build.go b/internal/xgodriver/build.go new file mode 100644 index 000000000..acb86d59d --- /dev/null +++ b/internal/xgodriver/build.go @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xgodriver + +import ( + "context" + "fmt" + + "github.com/goplus/spx/v3/internal/launchpack" + "github.com/goplus/spx/v3/internal/projectpolicy" +) + +func buildLauncher(ctx context.Context, cfg Config, snapshot projectpolicy.PortableConfigSnapshot, streams IO) error { + if err := verifyLauncherPackage(ctx, cfg, streams.Env); err != nil { + return err + } + verifier, err := newGraphVerifier(ctx, cfg, streams.Env) + if err != nil { + return fmt.Errorf("xgodriver: snapshot launcher graph: %w", err) + } + packCfg := cfg.launchpackConfig(snapshot, streams) + packCfg.VerifyGraph = verifier.verify + if cfg.DriverOrigin.IsLocal() { + packCfg.VerifyBridge = cfg.verifyBridge(ctx, streams) + } + if _, err := launchpack.BuildLauncher(ctx, packCfg); err != nil { + return fmt.Errorf("xgodriver: build launcher: %w", err) + } + if err := verifyBuiltDriverOrigin(ctx, cfg.Output, cfg.DriverOrigin, cfg, streams.Env); err != nil { + return fmt.Errorf("xgodriver: verify generated launcher provenance: %w", err) + } + return nil +} diff --git a/internal/xgodriver/build_provenance.go b/internal/xgodriver/build_provenance.go new file mode 100644 index 000000000..f22f6faf1 --- /dev/null +++ b/internal/xgodriver/build_provenance.go @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xgodriver + +import ( + "context" + "debug/buildinfo" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + runtimedebug "runtime/debug" +) + +func verifyBuiltDriverOrigin(ctx context.Context, name string, want ModuleOrigin, cfg Config, baseEnv []string) error { + info, err := buildinfo.ReadFile(name) + if err != nil { + return err + } + replacementPath, err := effectiveLocalReplacementPath(ctx, cfg, baseEnv, want) + if err != nil { + return err + } + return verifyBuildInfoOrigin(info, want, replacementPath) +} + +func effectiveLocalReplacementPath(ctx context.Context, cfg Config, baseEnv []string, want ModuleOrigin) (string, error) { + if want.Replace == nil || want.Replace.Version != "" { + return "", nil + } + if err := cfg.validateGraphInputs(); err != nil { + return "", err + } + args := append([]string{"list"}, cfg.GraphFlags...) + args = append(args, "-m", "-json", want.Selected.Path) + command := exec.CommandContext(ctx, cfg.GoCommand, args...) + command.Dir = cfg.GraphWorkDir + command.Env = hostGoEnv(cfg, baseEnv) + output, err := command.Output() + if err != nil { + return "", fmt.Errorf("xgodriver: resolve effective module for build provenance: %w", err) + } + var listed listedModule + if err := json.Unmarshal(output, &listed); err != nil { + return "", fmt.Errorf("xgodriver: decode effective module for build provenance: %w", err) + } + got, err := normalizeListedOrigin(&listed) + if err != nil { + return "", fmt.Errorf("xgodriver: invalid effective module for build provenance: %w", err) + } + if !got.Equal(want) { + return "", fmt.Errorf("xgodriver: effective module does not match resolved driver provenance") + } + if listed.Replace == nil || listed.Replace.Version != "" || listed.Replace.Path == "" { + return "", fmt.Errorf("xgodriver: effective module is missing its local replacement path") + } + return listed.Replace.Path, nil +} + +func verifyBuildInfoOrigin(info *runtimedebug.BuildInfo, want ModuleOrigin, effectiveReplacementPath string) error { + if info == nil { + return fmt.Errorf("missing Go build info") + } + var got *runtimedebug.Module + if info.Main.Path == want.Selected.Path { + got = &info.Main + } else { + for _, dependency := range info.Deps { + if dependency != nil && dependency.Path == want.Selected.Path { + got = dependency + break + } + } + } + if got == nil { + return fmt.Errorf("built artifact does not contain module %q", want.Selected.Path) + } + if want.Main { + if got.Version != "" && got.Version != "(devel)" { + return fmt.Errorf("main module build version is %q", got.Version) + } + } else if got.Version != want.Selected.Version { + return fmt.Errorf("selected module version is %q, want %q", got.Version, want.Selected.Version) + } + if want.Replace == nil { + if got.Replace != nil { + return fmt.Errorf("built module has unexpected replacement %s@%s", got.Replace.Path, got.Replace.Version) + } + return nil + } + if got.Replace == nil { + return fmt.Errorf("built module is missing its resolved replacement") + } + if want.Replace.Version != "" { + if got.Replace.Path != want.Replace.Path || got.Replace.Version != want.Replace.Version { + return fmt.Errorf("built module replacement is %s@%s, want %s@%s", got.Replace.Path, got.Replace.Version, want.Replace.Path, want.Replace.Version) + } + return nil + } + if !isLocalReplacementVersion(got.Replace.Version) { + return fmt.Errorf("built local replacement unexpectedly has version %q", got.Replace.Version) + } + if filepath.IsAbs(got.Replace.Path) { + if !sameExistingPath(got.Replace.Path, want.Replace.Dir) { + return fmt.Errorf("built local replacement %q does not match %q", got.Replace.Path, want.Replace.Dir) + } + return nil + } + if got.Replace.Path != effectiveReplacementPath { + return fmt.Errorf("built relative local replacement is %q, want effective graph path %q", got.Replace.Path, effectiveReplacementPath) + } + return nil +} + +func isLocalReplacementVersion(version string) bool { + return version == "" || version == "(devel)" +} + +func sameExistingPath(first, second string) bool { + firstInfo, firstErr := os.Stat(first) + secondInfo, secondErr := os.Stat(second) + return firstErr == nil && secondErr == nil && os.SameFile(firstInfo, secondInfo) +} diff --git a/internal/xgodriver/config_adapter.go b/internal/xgodriver/config_adapter.go new file mode 100644 index 000000000..14708af13 --- /dev/null +++ b/internal/xgodriver/config_adapter.go @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xgodriver + +import ( + "context" + "runtime" + + "github.com/goplus/spx/v3/internal/launchpack" + "github.com/goplus/spx/v3/internal/projectpolicy" +) + +func (cfg Config) launchpackConfig(snapshot projectpolicy.PortableConfigSnapshot, streams IO) launchpack.Config { + origin := cfg.DriverOrigin + effective := origin.Effective() + packCfg := launchpack.Config{ + ProjectDir: cfg.ProjectDir, + ProjectFile: cfg.ProjectFile, + ProjectExt: cfg.Project.Extension, + PackDir: cfg.Project.PackDirectory, + PackIndex: cfg.Project.PackIndexFile, + PortableConfig: snapshot, + RuntimeIdentity: launchpack.RuntimeIdentity{GOOS: runtime.GOOS, GOARCH: runtime.GOARCH}, + Source: launchpack.SourceIdentity{ + SelectedPath: origin.Selected.Path, + SelectedVersion: origin.Selected.Version, + EffectivePath: effective.Path, + EffectiveVersion: effective.Version, + Main: origin.Main, + SourceMode: origin.IsLocal(), + }, + GoCommand: cfg.GoCommand, + WorkDir: cfg.GraphWorkDir, + GoWork: cfg.GoWork, + GraphFlags: append([]string(nil), cfg.GraphFlags...), + BuildFlags: append([]string(nil), cfg.BuildFlags...), + Output: cfg.Output, + IO: launchpack.IO{Stdin: streams.Stdin, Stdout: streams.Stdout, Stderr: streams.Stderr, Env: streams.Env}, + } + if origin.IsLocal() { + packCfg.RuntimeSourceRoot = effective.Dir + packCfg.BridgePackage = origin.Selected.Path + "/cmd/ispxnative" + } + return packCfg +} + +func (cfg Config) verifyBridge(ctx context.Context, streams IO) func(string) error { + return func(path string) error { + return verifyBuiltDriverOrigin(ctx, path, cfg.DriverOrigin, cfg, streams.Env) + } +} diff --git a/internal/xgodriver/config_adapter_test.go b/internal/xgodriver/config_adapter_test.go new file mode 100644 index 000000000..109d9ac0e --- /dev/null +++ b/internal/xgodriver/config_adapter_test.go @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xgodriver + +import ( + "path/filepath" + "runtime" + "testing" + + "github.com/goplus/spx/v3/internal/projectpolicy" +) + +func TestLaunchpackConfigMapsRuntimeBridgeAndEnvironment(t *testing.T) { + root := t.TempDir() + selected := filepath.Join(root, "spx") + cfg := Config{ + ProjectDir: filepath.Join(root, "project"), ProjectFile: filepath.Join(root, "project", "main.spx"), + Project: ProjectSnapshot{Extension: ".spx", PackDirectory: "assets", PackIndexFile: "index.json"}, + DriverOrigin: ModuleOrigin{ + Selected: ModuleRef{Path: spxModulePath, Version: "v3.2.0"}, + Replace: &ModuleRef{Path: selected, Dir: selected}, + }, + GoCommand: "/bin/go", GraphWorkDir: root, GoWork: "off", BuildFlags: []string{"-v=true"}, + } + env := []string{"PATH=/bin", "SPX_RUNTIME_CACHE=/tmp/cache"} + got := cfg.launchpackConfig(projectpolicy.PortableConfigSnapshot{}, IO{Env: env}) + if got.RuntimeSourceRoot != selected || got.BridgePackage != spxModulePath+"/cmd/ispxnative" { + t.Fatalf("source inputs = %q, %q", got.RuntimeSourceRoot, got.BridgePackage) + } + if got.Source.EffectivePath != selected || got.Source.EffectiveVersion != "" || !got.Source.SourceMode { + t.Fatalf("Source = %#v", got.Source) + } + if len(got.IO.Env) != 2 || got.IO.Env[1] != env[1] { + t.Fatalf("IO.Env = %#v, want %#v", got.IO.Env, env) + } + if got.RuntimeIdentity.GOOS != runtime.GOOS || got.RuntimeIdentity.GOARCH != runtime.GOARCH { + t.Fatalf("RuntimeIdentity = %#v", got.RuntimeIdentity) + } +} + +func TestLaunchpackConfigUsesPublishedBundleWithoutSourceInputs(t *testing.T) { + root := t.TempDir() + cfg := Config{ + ProjectDir: filepath.Join(root, "project"), + Project: ProjectSnapshot{Extension: ".spx", PackDirectory: "assets", PackIndexFile: "index.json"}, + DriverOrigin: ModuleOrigin{Selected: ModuleRef{ + Path: spxModulePath, Version: "v3.2.4", Dir: filepath.Join(root, "module-cache"), + }}, + } + got := cfg.launchpackConfig(projectpolicy.PortableConfigSnapshot{}, IO{}) + if got.Source.SourceMode || got.Source.SelectedVersion != "v3.2.4" { + t.Fatalf("Source = %#v", got.Source) + } + if got.RuntimeSourceRoot != "" || got.BridgePackage != "" { + t.Fatalf("published source inputs = %q, %q", got.RuntimeSourceRoot, got.BridgePackage) + } +} + +func TestValidateSPXRequestRequiresPackMetadata(t *testing.T) { + if err := validateSPXRequest(Config{}); err == nil { + t.Fatal("missing SPX pack metadata was accepted") + } + if err := validateSPXRequest(Config{Project: ProjectSnapshot{PackDirectory: "assets", PackIndexFile: "index.json"}}); err != nil { + t.Fatalf("complete SPX pack metadata was rejected: %v", err) + } +} + +func TestValidateDriverOrigin(t *testing.T) { + tests := []struct { + name string + origin ModuleOrigin + ok bool + }{ + {"main module", ModuleOrigin{Selected: ModuleRef{Path: spxModulePath}, Main: true}, true}, + {"local replace", ModuleOrigin{Selected: ModuleRef{Path: spxModulePath, Version: "v3.2.4"}, Replace: &ModuleRef{Path: "/tmp/spx", Dir: "/tmp/spx"}}, true}, + {"released dependency", ModuleOrigin{Selected: ModuleRef{Path: spxModulePath, Version: "v3.2.4"}}, true}, + {"released prerelease", ModuleOrigin{Selected: ModuleRef{Path: spxModulePath, Version: "v3.3.0-rc.1"}}, true}, + {"pseudo version", ModuleOrigin{Selected: ModuleRef{Path: spxModulePath, Version: "v3.2.5-0.20260821120000-0123456789ab"}}, false}, + {"versioned replace", ModuleOrigin{Selected: ModuleRef{Path: spxModulePath, Version: "v3.2.4"}, Replace: &ModuleRef{Path: spxModulePath, Version: "v3.2.3"}}, false}, + {"foreign module", ModuleOrigin{Selected: ModuleRef{Path: "example.com/spx", Version: "v3.2.4"}}, false}, + {"foreign main module", ModuleOrigin{Selected: ModuleRef{Path: "example.com/spx"}, Main: true}, false}, + {"noncanonical version", ModuleOrigin{Selected: ModuleRef{Path: spxModulePath, Version: "3.2.4"}}, false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateDriverOrigin(test.origin) + if (err == nil) != test.ok { + t.Fatalf("validateDriverOrigin() error = %v, want success %t", err, test.ok) + } + }) + } +} diff --git a/internal/xgodriver/driver.go b/internal/xgodriver/driver.go new file mode 100644 index 000000000..afe4a5af7 --- /dev/null +++ b/internal/xgodriver/driver.go @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xgodriver + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/goplus/spx/v3/internal/projectpolicy" + "github.com/goplus/spx/v3/x/xgolauncher" + "golang.org/x/mod/module" + "golang.org/x/mod/semver" +) + +const ( + activeEnvironment = "SPX_XGO_DRIVER_ACTIVE" + spxModulePath = "github.com/goplus/spx/v3" +) + +// IO contains inherited streams and the caller's complete environment. +type IO struct { + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer + Env []string +} + +// Execute runs one parsed driver request. +func Execute(ctx context.Context, cfg Config, streams IO) (xgolauncher.ProcessStatus, error) { + if ctx == nil { + return xgolauncher.ProcessStatus{}, errors.New("xgodriver: nil context") + } + if err := validateSPXRequest(cfg); err != nil { + return xgolauncher.ProcessStatus{}, err + } + if err := cfg.validateGraphInputs(); err != nil { + return xgolauncher.ProcessStatus{}, err + } + if err := validateDriverOrigin(cfg.DriverOrigin); err != nil { + return xgolauncher.ProcessStatus{}, err + } + if hasEnvironment(streams.Env, activeEnvironment) { + return xgolauncher.ProcessStatus{}, errors.New("xgodriver: recursive project-driver invocation rejected") + } + snapshot, err := projectpolicy.SnapshotPortableConfig(cfg.ProjectDir) + if err != nil { + return xgolauncher.ProcessStatus{}, fmt.Errorf("xgodriver: %w", err) + } + if err := VerifyDeclaration(cfg.Declaration); err != nil { + return xgolauncher.ProcessStatus{}, err + } + if err := verifyDriverPackage(ctx, cfg, streams.Env); err != nil { + return xgolauncher.ProcessStatus{}, err + } + switch cfg.Action { + case ActionRun: + return runProject(ctx, cfg, snapshot, streams) + case ActionBuild: + if err := buildLauncher(ctx, cfg, snapshot, streams); err != nil { + return xgolauncher.ProcessStatus{}, err + } + return xgolauncher.ProcessStatus{}, nil + default: + return xgolauncher.ProcessStatus{}, fmt.Errorf("xgodriver: unsupported action %q", cfg.Action) + } +} + +func validateDriverOrigin(origin ModuleOrigin) error { + if origin.Selected.Path != spxModulePath { + return fmt.Errorf("xgodriver: driver module %q is not supported", origin.Selected.Path) + } + if origin.IsLocal() { + return nil + } + if origin.Main || origin.Replace != nil { + return errors.New("xgodriver: published mode requires an unreplaced dependency") + } + version := origin.Selected.Version + if !semver.IsValid(version) || semver.Canonical(version) != version || module.IsPseudoVersion(version) { + return fmt.Errorf("xgodriver: published driver requires an exact canonical release version, got %q", version) + } + return nil +} + +// validateSPXRequest applies SPX-only requirements not shared by drivers. +func validateSPXRequest(cfg Config) error { + if cfg.Project.PackDirectory == "" || cfg.Project.PackIndexFile == "" { + return errors.New("xgodriver: SPX driver requires pack metadata") + } + if cfg.Project.PackDirectory == "." { + return errors.New("xgodriver: SPX driver requires a dedicated pack directory below the project root") + } + return nil +} + +func hasEnvironment(env []string, key string) bool { + if env == nil { + env = os.Environ() + } + for _, entry := range env { + name, value, ok := strings.Cut(entry, "=") + if ok && name == key && value != "" { + return true + } + } + return false +} diff --git a/internal/xgodriver/driver_test.go b/internal/xgodriver/driver_test.go new file mode 100644 index 000000000..ae988c730 --- /dev/null +++ b/internal/xgodriver/driver_test.go @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xgodriver + +import ( + "context" + "debug/buildinfo" + "os" + "os/exec" + "path/filepath" + runtimedebug "runtime/debug" + "testing" +) + +func TestVerifyBuildInfoOrigin(t *testing.T) { + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + localReplacement := filepath.Join(root, "framework") + if err := os.MkdirAll(localReplacement, 0o700); err != nil { + t.Fatal(err) + } + tests := []struct { + name string + info *runtimedebug.BuildInfo + want ModuleOrigin + replacementPath string + ok bool + }{ + { + name: "workspace main", + info: &runtimedebug.BuildInfo{Main: runtimedebug.Module{Path: "example.com/driver", Version: "(devel)"}}, + want: ModuleOrigin{Selected: ModuleRef{Path: "example.com/driver"}, Main: true}, ok: true, + }, + { + name: "versioned dependency replacement", + info: &runtimedebug.BuildInfo{Deps: []*runtimedebug.Module{{ + Path: "example.com/driver", Version: "v1.2.3", + Replace: &runtimedebug.Module{Path: "example.com/fork", Version: "v1.4.0"}, + }}}, + want: ModuleOrigin{ + Selected: ModuleRef{Path: "example.com/driver", Version: "v1.2.3"}, + Replace: &ModuleRef{Path: "example.com/fork", Version: "v1.4.0"}, + }, ok: true, + }, + { + name: "relative local dependency replacement", + info: &runtimedebug.BuildInfo{Deps: []*runtimedebug.Module{{ + Path: "example.com/driver", Version: "v1.2.3", + Replace: &runtimedebug.Module{Path: "../framework"}, + }}}, + want: ModuleOrigin{ + Selected: ModuleRef{Path: "example.com/driver", Version: "v1.2.3"}, + Replace: &ModuleRef{Path: localReplacement, Dir: localReplacement}, + }, + replacementPath: "../framework", ok: true, + }, + { + name: "relative local dependency replacement devel version", + info: &runtimedebug.BuildInfo{Deps: []*runtimedebug.Module{{ + Path: "example.com/driver", Version: "v1.2.3", + Replace: &runtimedebug.Module{Path: "../framework", Version: "(devel)"}, + }}}, + want: ModuleOrigin{ + Selected: ModuleRef{Path: "example.com/driver", Version: "v1.2.3"}, + Replace: &ModuleRef{Path: localReplacement, Dir: localReplacement}, + }, + replacementPath: "../framework", ok: true, + }, + { + name: "relative local replacement drift", + info: &runtimedebug.BuildInfo{Deps: []*runtimedebug.Module{{ + Path: "example.com/driver", Version: "v1.2.3", + Replace: &runtimedebug.Module{Path: "../framework"}, + }}}, + want: ModuleOrigin{ + Selected: ModuleRef{Path: "example.com/driver", Version: "v1.2.3"}, + Replace: &ModuleRef{Path: localReplacement, Dir: localReplacement}, + }, + replacementPath: "./framework", + }, + { + name: "selected version drift", + info: &runtimedebug.BuildInfo{Main: runtimedebug.Module{Path: "example.com/driver", Version: "v1.2.4"}}, + want: ModuleOrigin{Selected: ModuleRef{Path: "example.com/driver", Version: "v1.2.3"}}, + }, + { + name: "replacement drift", + info: &runtimedebug.BuildInfo{Main: runtimedebug.Module{ + Path: "example.com/driver", Version: "v1.2.3", + Replace: &runtimedebug.Module{Path: "example.com/other", Version: "v1.4.0"}, + }}, + want: ModuleOrigin{ + Selected: ModuleRef{Path: "example.com/driver", Version: "v1.2.3"}, + Replace: &ModuleRef{Path: "example.com/fork", Version: "v1.4.0"}, + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := verifyBuildInfoOrigin(test.info, test.want, test.replacementPath) + if (err == nil) != test.ok { + t.Fatalf("verifyBuildInfoOrigin() error = %v, want success %t", err, test.ok) + } + }) + } +} + +func TestVerifyBuildInfoOriginWithRealLocalReplacement(t *testing.T) { + if testing.Short() { + t.Skip("builds a local replacement fixture") + } + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + app := filepath.Join(root, "app") + framework := filepath.Join(root, "framework") + mustWriteDriverTestFile(t, filepath.Join(framework, "go.mod"), "module example.test/framework\n\ngo 1.25\n", 0o600) + mustWriteDriverTestFile(t, filepath.Join(framework, "framework.go"), "package framework\n", 0o600) + mustWriteDriverTestFile(t, filepath.Join(app, "go.mod"), `module example.test/app + +go 1.25 + +require example.test/framework v1.2.3 + +replace example.test/framework => ../framework +`, 0o600) + mustWriteDriverTestFile(t, filepath.Join(app, "main.go"), "package main\nimport _ \"example.test/framework\"\nfunc main() {}\n", 0o600) + + artifact := filepath.Join(root, "app.bin") + build := exec.Command("go", "build", "-buildvcs=false", "-o", artifact, ".") + build.Dir = app + build.Env = append(sanitizeEnvironment(os.Environ()), "GOFLAGS=", "GOWORK=off") + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build local replacement fixture: %v\n%s", err, output) + } + info, err := buildinfo.ReadFile(artifact) + if err != nil { + t.Fatal(err) + } + want := ModuleOrigin{ + Selected: ModuleRef{Path: "example.test/framework", Version: "v1.2.3"}, + Replace: &ModuleRef{Path: framework, Dir: framework}, + } + if err := verifyBuildInfoOrigin(info, want, "../framework"); err != nil { + t.Fatalf("verify real local replacement: %v", err) + } +} + +func TestVerifyDriverPackageUsesGraphWorkDir(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + app := filepath.Join(root, "app") + framework := filepath.Join(root, "framework") + project := filepath.Join(framework, "example") + mustWriteDriverTestFile(t, filepath.Join(app, "go.mod"), `module example.test/app + +go 1.25 + +require example.test/framework v1.2.3 //xgo:class + +replace example.test/framework => ../framework +`, 0o600) + mustWriteDriverTestFile(t, filepath.Join(framework, "go.mod"), "module example.test/framework\n\ngo 1.25\n", 0o600) + mustWriteDriverTestFile(t, filepath.Join(framework, "cmd", "driver", "main.go"), "package main\nfunc main() {}\n", 0o600) + if err := os.MkdirAll(project, 0o700); err != nil { + t.Fatal(err) + } + goCommand, err := exec.LookPath("go") + if err != nil { + t.Fatal(err) + } + goCommand, err = filepath.EvalSymlinks(goCommand) + if err != nil { + t.Fatal(err) + } + cfg := Config{ + ProjectDir: project, GraphWorkDir: app, GoCommand: goCommand, GoWork: "off", + DriverPackage: "example.test/framework/cmd/driver", + DriverOrigin: ModuleOrigin{ + Selected: ModuleRef{Path: "example.test/framework", Version: "v1.2.3"}, + Replace: &ModuleRef{Path: framework, Dir: framework, GoMod: filepath.Join(framework, "go.mod")}, + }, + } + if err := verifyDriverPackage(context.Background(), cfg, os.Environ()); err != nil { + t.Fatalf("driver validation with caller graph work dir: %v", err) + } + cfg.GraphWorkDir = project + if err := verifyDriverPackage(context.Background(), cfg, os.Environ()); err == nil { + t.Fatal("driver validation unexpectedly preserved the caller graph from the dependency project directory") + } +} diff --git a/internal/xgodriver/graph.go b/internal/xgodriver/graph.go new file mode 100644 index 000000000..29be4e527 --- /dev/null +++ b/internal/xgodriver/graph.go @@ -0,0 +1,237 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xgodriver + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" +) + +type graphFile struct { + path, digest string + present bool +} + +type graphIdentity struct { + selection string + files []graphFile +} + +type graphVerifier struct { + cfg Config + env []string + expected graphIdentity +} + +func newGraphVerifier(ctx context.Context, cfg Config, baseEnv []string) (graphVerifier, error) { + env := hostGoEnv(cfg, baseEnv) + identity, err := snapshotGraph(ctx, cfg, env) + if err != nil { + return graphVerifier{}, err + } + return graphVerifier{cfg: cfg, env: env, expected: identity}, nil +} + +func (v graphVerifier) verify(ctx context.Context) error { + current, err := snapshotGraph(ctx, v.cfg, v.env) + if err != nil { + return err + } + if current.selection != v.expected.selection { + return fmt.Errorf("module selection changed") + } + if !sameGraphFiles(current.files, v.expected.files) { + return fmt.Errorf("graph input changed") + } + return nil +} + +func snapshotGraph(ctx context.Context, cfg Config, env []string) (graphIdentity, error) { + selection, err := queryModuleSelection(ctx, cfg, env) + if err != nil { + return graphIdentity{}, err + } + files, err := snapshotGraphFiles(ctx, cfg, env) + if err != nil { + return graphIdentity{}, err + } + return graphIdentity{selection: selection, files: files}, nil +} + +func queryModuleSelection(ctx context.Context, cfg Config, env []string) (string, error) { + const format = `{{.Path}}\t{{.Version}}\t{{.Main}}{{with .Replace}}\t{{.Path}}\t{{.Version}}{{end}}` + args := append([]string{"list", "-m"}, cfg.GraphFlags...) + args = append(args, "-f="+format, "all") + var stderr bytes.Buffer + command := exec.CommandContext(ctx, cfg.GoCommand, args...) + command.Dir, command.Env, command.Stderr = cfg.GraphWorkDir, env, &stderr + output, err := command.Output() + if err != nil { + if message := strings.TrimSpace(stderr.String()); message != "" { + return "", fmt.Errorf("xgodriver: query module graph: %w: %s", err, message) + } + return "", fmt.Errorf("xgodriver: query module graph: %w", err) + } + digest := sha256.Sum256(output) + return hex.EncodeToString(digest[:]), nil +} + +type graphPath struct { + path string + required bool +} + +func snapshotGraphFiles(ctx context.Context, cfg Config, env []string) ([]graphFile, error) { + mod := "" + for _, flag := range cfg.GraphFlags { + if path, ok := graphModfilePath(flag); ok { + mod = path + } + } + if mod == "" { + var err error + mod, err = queryGoEnv(ctx, cfg, env, "GOMOD") + if err != nil { + return nil, err + } + if mod == "" || mod == os.DevNull { + return nil, fmt.Errorf("xgodriver: active Go module file is unavailable") + } + } + paths := []graphPath{{path: mod, required: true}} + if cfg.GoWork != "" && cfg.GoWork != "off" { + paths = append(paths, graphPath{path: cfg.GoWork, required: true}) + } + for _, flag := range cfg.GraphFlags { + if path, ok := graphModfilePath(flag); ok { + paths = append(paths, graphPath{path: path, required: true}) + } + } + if effective := cfg.DriverOrigin.Effective(); cfg.DriverOrigin.IsLocal() && effective.GoMod != "" { + paths = append(paths, graphPath{path: effective.GoMod, required: true}) + } + paths = graphRelatedFiles(paths) + return readGraphFiles(paths) +} + +func queryGoEnv(ctx context.Context, cfg Config, env []string, name string) (string, error) { + var stderr bytes.Buffer + command := exec.CommandContext(ctx, cfg.GoCommand, "env", name) + command.Dir, command.Env, command.Stderr = cfg.GraphWorkDir, env, &stderr + output, err := command.Output() + if err != nil { + if message := strings.TrimSpace(stderr.String()); message != "" { + return "", fmt.Errorf("xgodriver: query Go env %s: %w: %s", name, err, message) + } + return "", fmt.Errorf("xgodriver: query Go env %s: %w", name, err) + } + return strings.TrimSpace(string(output)), nil +} + +func graphModfilePath(flag string) (string, bool) { + if path, ok := strings.CutPrefix(flag, "-modfile="); ok { + return path, path != "" + } + if path, ok := strings.CutPrefix(flag, "--modfile="); ok { + return path, path != "" + } + return "", false +} + +func graphRelatedFiles(paths []graphPath) []graphPath { + result := make(map[string]bool, len(paths)*2) + add := func(path string, required bool) { result[path] = result[path] || required } + for _, item := range paths { + if item.path == "" { + continue + } + path := filepath.Clean(item.path) + if !filepath.IsAbs(path) { + if absolute, err := filepath.Abs(path); err == nil { + path = absolute + } + } + add(path, item.required) + switch filepath.Base(path) { + case "go.mod": + add(filepath.Join(filepath.Dir(path), "go.sum"), false) + case "go.work": + add(filepath.Join(filepath.Dir(path), "go.work.sum"), false) + default: + if strings.HasSuffix(path, ".mod") { + add(strings.TrimSuffix(path, ".mod")+".sum", false) + } + } + } + items := make([]graphPath, 0, len(result)) + for path, required := range result { + items = append(items, graphPath{path: path, required: required}) + } + sort.Slice(items, func(i, j int) bool { return items[i].path < items[j].path }) + return items +} + +func readGraphFiles(paths []graphPath) ([]graphFile, error) { + files := make([]graphFile, 0, len(paths)) + for _, item := range paths { + before, err := os.Lstat(item.path) + if os.IsNotExist(err) { + if item.required { + return nil, fmt.Errorf("xgodriver: graph input %q is missing", item.path) + } + files = append(files, graphFile{path: item.path}) + continue + } + if err != nil { + return nil, fmt.Errorf("xgodriver: inspect graph input %q: %w", item.path, err) + } + if before.Mode()&os.ModeSymlink != 0 || !before.Mode().IsRegular() { + return nil, fmt.Errorf("xgodriver: graph input %q is not a regular non-symlink file", item.path) + } + data, err := os.ReadFile(item.path) + if err != nil { + return nil, fmt.Errorf("xgodriver: read graph input %q: %w", item.path, err) + } + after, err := os.Lstat(item.path) + if err != nil || after.Mode()&os.ModeSymlink != 0 || !after.Mode().IsRegular() || !os.SameFile(before, after) { + return nil, fmt.Errorf("xgodriver: graph input %q changed while reading", item.path) + } + digest := sha256.Sum256(data) + files = append(files, graphFile{path: item.path, digest: hex.EncodeToString(digest[:]), present: true}) + } + return files, nil +} + +func sameGraphFiles(left, right []graphFile) bool { + if len(left) != len(right) { + return false + } + for i := range left { + if left[i] != right[i] { + return false + } + } + return true +} diff --git a/internal/xgodriver/graph_test.go b/internal/xgodriver/graph_test.go new file mode 100644 index 000000000..51234a69f --- /dev/null +++ b/internal/xgodriver/graph_test.go @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xgodriver + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestGraphVerifierDetectsModuleSelectionAndGoModDrift(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + root := t.TempDir() + dependency := filepath.Join(root, "dependency") + mustWriteDriverTestFile(t, filepath.Join(dependency, "go.mod"), "module example.test/dependency\n\ngo 1.25\n", 0o600) + writeModule := func(version string) { + mustWriteDriverTestFile(t, filepath.Join(root, "go.mod"), "module example.test/app\n\ngo 1.25\n\nrequire example.test/dependency "+version+"\n\nreplace example.test/dependency => ./dependency\n", 0o600) + } + writeModule("v1.0.0") + goCommand, err := exec.LookPath("go") + if err != nil { + t.Skip(err) + } + goCommand, err = filepath.EvalSymlinks(goCommand) + if err != nil { + t.Fatal(err) + } + cfg := Config{GoCommand: goCommand, GraphWorkDir: root, GoWork: "off", GraphFlags: []string{"-mod=mod"}} + verifier, err := newGraphVerifier(context.Background(), cfg, os.Environ()) + if err != nil { + t.Fatal(err) + } + if err := verifier.verify(context.Background()); err != nil { + t.Fatalf("initial graph callback: %v", err) + } + + writeModule("v1.1.0") + if err := verifier.verify(context.Background()); err == nil || !strings.Contains(err.Error(), "module selection changed") { + t.Fatalf("module selection drift error = %v", err) + } + + writeModule("v1.0.0") + if err := os.WriteFile(filepath.Join(root, "go.sum"), []byte("\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := verifier.verify(context.Background()); err == nil || !strings.Contains(err.Error(), "graph input changed") { + t.Fatalf("go.mod/go.sum drift error = %v", err) + } +} + +func TestGraphVerifierCallbackWiring(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + root := t.TempDir() + mustWriteDriverTestFile(t, filepath.Join(root, "go.mod"), "module example.test/app\n\ngo 1.25\n", 0o600) + goCommand, err := exec.LookPath("go") + if err != nil { + t.Skip(err) + } + goCommand, err = filepath.EvalSymlinks(goCommand) + if err != nil { + t.Fatal(err) + } + cfg := Config{GoCommand: goCommand, GraphWorkDir: root, GoWork: "off", GraphFlags: []string{"-mod=readonly"}} + verifier, err := newGraphVerifier(context.Background(), cfg, os.Environ()) + if err != nil { + t.Fatal(err) + } + callback := verifier.verify + if callback == nil { + t.Fatal("graph callback is nil") + } + if err := callback(context.Background()); err != nil { + t.Fatalf("wired graph callback: %v", err) + } + if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.test/changed\n\ngo 1.25\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := callback(context.Background()); err == nil { + t.Fatal("wired graph callback accepted changed module") + } +} diff --git a/internal/xgodriver/metadata.go b/internal/xgodriver/metadata.go new file mode 100644 index 000000000..eae3dc692 --- /dev/null +++ b/internal/xgodriver/metadata.go @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xgodriver + +import ( + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "fmt" + "io" + "os" + + "github.com/goplus/mod/xgomod" +) + +// VerifyDeclaration checks the metadata file XGo used for discovery. +func VerifyDeclaration(declaration xgomod.FileIdentity) (err error) { + expected, err := hex.DecodeString(declaration.SHA256) + if err != nil || len(expected) != sha256.Size { + return fmt.Errorf("xgodriver: invalid declaration SHA-256 %q", declaration.SHA256) + } + + before, err := os.Lstat(declaration.Path) + if err != nil { + return fmt.Errorf("xgodriver: lstat declaring metadata %q: %w", declaration.Path, err) + } + if !before.Mode().IsRegular() { + return fmt.Errorf("xgodriver: declaring metadata %q is not a regular non-symlink file", declaration.Path) + } + + file, err := os.Open(declaration.Path) + if err != nil { + return fmt.Errorf("xgodriver: open declaring metadata %q: %w", declaration.Path, err) + } + defer func() { + if closeErr := file.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("xgodriver: close declaring metadata %q: %w", declaration.Path, closeErr) + } + }() + + opened, err := file.Stat() + if err != nil { + return fmt.Errorf("xgodriver: stat opened declaring metadata %q: %w", declaration.Path, err) + } + if !opened.Mode().IsRegular() || !os.SameFile(before, opened) { + return fmt.Errorf("xgodriver: declaring metadata %q changed while opening", declaration.Path) + } + + hasher := sha256.New() + if _, err := io.Copy(hasher, file); err != nil { + return fmt.Errorf("xgodriver: hash declaring metadata %q: %w", declaration.Path, err) + } + after, err := os.Lstat(declaration.Path) + if err != nil { + return fmt.Errorf("xgodriver: re-lstat declaring metadata %q: %w", declaration.Path, err) + } + if !after.Mode().IsRegular() || !os.SameFile(opened, after) { + return fmt.Errorf("xgodriver: declaring metadata %q changed while reading", declaration.Path) + } + if subtle.ConstantTimeCompare(hasher.Sum(nil), expected) != 1 { + return fmt.Errorf("xgodriver: declaring metadata %q changed after XGo discovery", declaration.Path) + } + return nil +} diff --git a/internal/xgodriver/metadata_test.go b/internal/xgodriver/metadata_test.go new file mode 100644 index 000000000..31b97e20c --- /dev/null +++ b/internal/xgodriver/metadata_test.go @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xgodriver + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/goplus/mod/xgomod" +) + +func TestVerifyDeclaration(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "gox.mod") + data := []byte("xgo 1.8\nproject main.spx Game example/spx\n") + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(data) + declaration := xgomod.FileIdentity{Path: path, SHA256: hex.EncodeToString(digest[:])} + if err := VerifyDeclaration(declaration); err != nil { + t.Fatalf("VerifyDeclaration() error: %v", err) + } + + if err := os.WriteFile(path, append(data, []byte("pack assets index.json\n")...), 0o600); err != nil { + t.Fatal(err) + } + if err := VerifyDeclaration(declaration); err == nil || !strings.Contains(err.Error(), "changed after XGo discovery") { + t.Fatalf("changed metadata error = %v", err) + } +} + +func TestVerifyDeclarationRejectsInvalidInputs(t *testing.T) { + dir := t.TempDir() + regular := filepath.Join(dir, "gox.mod") + if err := os.WriteFile(regular, []byte("xgo 1.8\n"), 0o600); err != nil { + t.Fatal(err) + } + validDigest := sha256.Sum256([]byte("xgo 1.8\n")) + + tests := []struct { + name string + declaration xgomod.FileIdentity + want string + }{ + {"bad digest", xgomod.FileIdentity{Path: regular, SHA256: "bad"}, "invalid declaration SHA-256"}, + {"missing", xgomod.FileIdentity{Path: filepath.Join(dir, "missing"), SHA256: hex.EncodeToString(validDigest[:])}, "lstat declaring metadata"}, + {"directory", xgomod.FileIdentity{Path: dir, SHA256: hex.EncodeToString(validDigest[:])}, "not a regular non-symlink file"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := VerifyDeclaration(test.declaration) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("VerifyDeclaration() error = %v, want containing %q", err, test.want) + } + }) + } +} + +func TestVerifyDeclarationRejectsSymlink(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target.mod") + link := filepath.Join(dir, "gox.mod") + data := []byte("xgo 1.8\n") + if err := os.WriteFile(target, data, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, link); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + digest := sha256.Sum256(data) + err := VerifyDeclaration(xgomod.FileIdentity{Path: link, SHA256: hex.EncodeToString(digest[:])}) + if err == nil || !strings.Contains(err.Error(), "not a regular non-symlink file") { + t.Fatalf("VerifyDeclaration() error = %v", err) + } +} diff --git a/internal/xgodriver/provenance.go b/internal/xgodriver/provenance.go new file mode 100644 index 000000000..7eb457f5d --- /dev/null +++ b/internal/xgodriver/provenance.go @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xgodriver + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "runtime" +) + +type listedPackage struct { + ImportPath string `json:"ImportPath"` + Module *listedModule `json:"Module"` +} + +type listedModule struct { + Path string `json:"Path"` + Version string `json:"Version"` + Dir string `json:"Dir"` + GoMod string `json:"GoMod"` + Main bool `json:"Main"` + Replace *listedModule `json:"Replace"` +} + +func verifyLauncherPackage(ctx context.Context, cfg Config, baseEnv []string) error { + return verifyPackageOrigin(ctx, cfg, baseEnv, "github.com/goplus/spx/v3/x/xgolauncher", "launcher") +} + +func verifyDriverPackage(ctx context.Context, cfg Config, baseEnv []string) error { + return verifyPackageOrigin(ctx, cfg, baseEnv, cfg.DriverPackage, "driver") +} + +func verifyPackageOrigin(ctx context.Context, cfg Config, baseEnv []string, importPath, label string) error { + if err := cfg.validateGraphInputs(); err != nil { + return err + } + for _, flag := range cfg.GraphFlags { + if flag == "-mod=vendor" { + return fmt.Errorf("xgodriver: project driver does not support vendor mode; use -mod=readonly or -mod=mod") + } + } + args := append([]string{"list"}, cfg.GraphFlags...) + args = append(args, "-json", importPath) + command := exec.CommandContext(ctx, cfg.GoCommand, args...) + command.Dir = cfg.GraphWorkDir + command.Env = hostGoEnv(cfg, baseEnv) + output, err := command.Output() + if err != nil { + return fmt.Errorf("xgodriver: validate %s package: %w", label, err) + } + var listed listedPackage + if err := json.Unmarshal(output, &listed); err != nil { + return fmt.Errorf("xgodriver: decode %s package identity: %w", label, err) + } + if listed.ImportPath != importPath || listed.Module == nil { + return fmt.Errorf("xgodriver: %s package resolved to unexpected identity %q", label, listed.ImportPath) + } + got, err := normalizeListedOrigin(listed.Module) + if err != nil { + return fmt.Errorf("xgodriver: invalid %s package origin: %w", label, err) + } + if !got.Equal(cfg.DriverOrigin) { + return fmt.Errorf("xgodriver: %s package origin does not match resolved driver provenance", label) + } + return nil +} + +func normalizeListedOrigin(module *listedModule) (ModuleOrigin, error) { + if module == nil { + return ModuleOrigin{}, fmt.Errorf("missing module") + } + origin := ModuleOrigin{Selected: ModuleRef{Path: module.Path, Version: module.Version}, Main: module.Main} + if module.Replace == nil { + origin.Selected.Dir, origin.Selected.GoMod = module.Dir, module.GoMod + } else { + replacement := ModuleRef{ + Path: module.Replace.Path, Version: module.Replace.Version, + Dir: module.Replace.Dir, GoMod: module.Replace.GoMod, + } + if replacement.Version == "" { + replacement.Path = replacement.Dir + } + origin.Replace = &replacement + } + if err := origin.Validate(); err != nil { + return ModuleOrigin{}, err + } + return origin, nil +} + +func hostGoEnv(cfg Config, base []string) []string { + env := sanitizeEnvironment(base) + return append(env, + "GOFLAGS=", "GOWORK="+cfg.GoWork, "GOOS="+runtime.GOOS, "GOARCH="+runtime.GOARCH, "CGO_ENABLED=0", + ) +} diff --git a/internal/xgodriver/run.go b/internal/xgodriver/run.go new file mode 100644 index 000000000..038da852e --- /dev/null +++ b/internal/xgodriver/run.go @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package xgodriver + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/goplus/spx/v3/internal/interpruntime" + "github.com/goplus/spx/v3/internal/launchpack" + "github.com/goplus/spx/v3/internal/processsupervisor" + "github.com/goplus/spx/v3/internal/projectpolicy" + "github.com/goplus/spx/v3/x/xgolauncher" +) + +func runProject(ctx context.Context, cfg Config, snapshot projectpolicy.PortableConfigSnapshot, streams IO) (xgolauncher.ProcessStatus, error) { + packCfg := cfg.launchpackConfig(snapshot, streams) + if cfg.DriverOrigin.IsLocal() { + verifier, err := newGraphVerifier(ctx, cfg, streams.Env) + if err != nil { + return xgolauncher.ProcessStatus{}, fmt.Errorf("xgodriver: snapshot source graph: %w", err) + } + packCfg.VerifyGraph = verifier.verify + packCfg.VerifyBridge = cfg.verifyBridge(ctx, streams) + } + assets, err := launchpack.PrepareAssets(ctx, packCfg) + if err != nil { + return xgolauncher.ProcessStatus{}, fmt.Errorf("xgodriver: prepare assets: %w", err) + } + if assets.Cleanup != nil { + defer assets.Cleanup() + } + return runEngine(ctx, cfg, assets, snapshot, streams) +} + +func runEngine(ctx context.Context, cfg Config, assets launchpack.Assets, snapshot projectpolicy.PortableConfigSnapshot, streams IO) (xgolauncher.ProcessStatus, error) { + sessionDir, err := os.MkdirTemp("", "spx-driver-run-") + if err != nil { + return xgolauncher.ProcessStatus{}, fmt.Errorf("xgodriver: create run session: %w", err) + } + if hasBuildFlag(cfg.BuildFlags, "work") { + if streams.Stderr != nil { + _, _ = fmt.Fprintf(streams.Stderr, "SPXWORK=%s\n", sessionDir) + } + } else { + defer os.RemoveAll(sessionDir) + } + configDir, configIdentity, err := materializePortableConfigSnapshot(sessionDir, cfg.ProjectDir, snapshot) + if err != nil { + return xgolauncher.ProcessStatus{}, err + } + roots := interpruntime.Roots{ + ProjectDir: cfg.ProjectDir, + AssetDir: filepath.Join(cfg.ProjectDir, filepath.FromSlash(cfg.Project.PackDirectory)), + SessionDir: sessionDir, + } + if err := assets.Verify(); err != nil { + return xgolauncher.ProcessStatus{}, fmt.Errorf("xgodriver: verify runtime assets: %w", err) + } + if err := interpruntime.PrepareSession(interpruntime.SessionConfig{Roots: roots, BridgePath: assets.BridgePath}); err != nil { + return xgolauncher.ProcessStatus{}, fmt.Errorf("xgodriver: prepare interpreted session: %w", err) + } + command, err := interpruntime.PrepareCommand(ctx, interpruntime.CommandConfig{ + Roots: roots, Executable: assets.EnginePath, Args: cfg.ApplicationArgs, + Env: append(sanitizeEnvironment(streams.Env), activeEnvironment+"=1"), + Stdin: streams.Stdin, Stdout: streams.Stdout, Stderr: streams.Stderr, + PathPolicy: interpruntime.RejectPath, + }) + if err != nil { + return xgolauncher.ProcessStatus{}, fmt.Errorf("xgodriver: prepare Engine: %w", err) + } + command.Env = append(command.Env, + interpruntime.PortableConfigDirEnv+"="+configDir, + interpruntime.PortableConfigIdentityEnv+"="+configIdentity, + ) + status, err := processsupervisor.Run(ctx, command) + if err != nil { + return xgolauncher.ProcessStatus{}, fmt.Errorf("xgodriver: run Engine: %w", err) + } + return xgolauncher.ProcessStatus{Code: status.Code, Signal: status.Signal}, nil +} + +func materializePortableConfigSnapshot(sessionDir, projectDir string, snapshot projectpolicy.PortableConfigSnapshot) (string, string, error) { + if err := snapshot.Verify(projectDir); err != nil { + return "", "", fmt.Errorf("xgodriver: %w", err) + } + identity, err := snapshot.Identity() + if err != nil { + return "", "", fmt.Errorf("xgodriver: identify portable config: %w", err) + } + configDir := filepath.Join(sessionDir, "portable-config") + if err := os.Mkdir(configDir, 0o700); err != nil { + return "", "", fmt.Errorf("xgodriver: create portable config directory: %w", err) + } + if !snapshot.Present() { + return configDir, identity, nil + } + file, err := os.OpenFile(filepath.Join(configDir, ".config"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return "", "", fmt.Errorf("xgodriver: create portable config: %w", err) + } + data := snapshot.Bytes() + written, writeErr := file.Write(data) + if writeErr == nil && written != len(data) { + writeErr = io.ErrShortWrite + } + closeErr := file.Close() + if writeErr != nil { + return "", "", fmt.Errorf("xgodriver: write portable config: %w", writeErr) + } + if closeErr != nil { + return "", "", fmt.Errorf("xgodriver: close portable config: %w", closeErr) + } + return configDir, identity, nil +} + +func hasBuildFlag(flags []string, name string) bool { + for _, flag := range flags { + if flag == "-"+name || flag == "-"+name+"=true" { + return true + } + } + return false +} + +func sanitizeEnvironment(env []string) []string { + if env == nil { + env = os.Environ() + } + result := make([]string, 0, len(env)) + for _, entry := range env { + key, _, ok := strings.Cut(entry, "=") + if ok && (key == activeEnvironment || key == "GOFLAGS" || key == "GOWORK" || key == "GOOS" || key == "GOARCH" || key == "CGO_ENABLED") { + continue + } + result = append(result, entry) + } + return result +}