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..cbcee44f2 --- /dev/null +++ b/.github/scripts/driverbundle/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 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 "-h", "--help", "help": + usage(os.Stdout) + return + default: + err = fmt.Errorf("unknown command %q (want package, verify, assemble, or verify-release)", 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 [flags]") +} diff --git a/.github/scripts/driverbundle/main_test.go b/.github/scripts/driverbundle/main_test.go new file mode 100644 index 000000000..f1889f374 --- /dev/null +++ b/.github/scripts/driverbundle/main_test.go @@ -0,0 +1,134 @@ +/* + * 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" + "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") + } +} diff --git a/.github/scripts/driverbundle/package.go b/.github/scripts/driverbundle/package.go new file mode 100644 index 000000000..02aca529a --- /dev/null +++ b/.github/scripts/driverbundle/package.go @@ -0,0 +1,206 @@ +/* + * 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" +) + +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() + spec, err := driverbundle.HostSpecFor(lock.RuntimeVersion, goos, goarch) + if err != nil { + return err + } + if err := validateInputBasenames(spec, 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, spec.Engine.Name, spec.Engine.Mode) + if err != nil { + return fmt.Errorf("package Engine: %w", err) + } + files = append(files, engine) + pack, err := addZipFile(archive, packPath, spec.Pack.Name, spec.Pack.Mode) + if err != nil { + return fmt.Errorf("package PCK: %w", err) + } + files = append(files, pack) + bridge, err := addZipFile(archive, bridgePath, spec.Bridge.Name, spec.Bridge.Mode) + 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 validateInputBasenames(spec driverbundle.HostSpec, 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: spec.GOOS, GOARCH: spec.GOARCH, Name: filepath.Base(outputPath), Size: 1, + SHA256: validDigest, EngineInterfaceDigest: interfaceDigest, + Files: []driverbundle.File{ + {Name: filepath.Base(enginePath), Mode: spec.Engine.Mode, Size: 1, SHA256: validDigest}, + {Name: filepath.Base(packPath), Mode: spec.Pack.Mode, Size: 1, SHA256: validDigest}, + {Name: filepath.Base(bridgePath), Mode: spec.Bridge.Mode, Size: 1, SHA256: validDigest}, + }, + } + if err := bundle.ValidateForRuntime(spec.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..7577a78f3 --- /dev/null +++ b/.github/scripts/driverbundle/release.go @@ -0,0 +1,142 @@ +/* + * 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" + "path/filepath" + "slices" + "strings" + + "github.com/goplus/spx/v3/internal/driverbundle" + "github.com/goplus/spx/v3/internal/release" +) + +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 +} + +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 + manifestPath := "" + var descriptors descriptorInputs + flags.StringVar(&lockPath, "lock", lockPath, "runtime lock JSON") + flags.StringVar(&spxVersion, "spx-version", spxVersion, "SPX release version") + flags.StringVar(&manifestPath, "manifest", "", "output driver-manifest.json 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 --spx-version VERSION --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) == "" { + return errors.New("manifest path is required") + } + targets := driverbundle.SupportedTargets() + if len(descriptors) != len(targets) { + return fmt.Errorf("descriptor count = %d, want %d", len(descriptors), len(targets)) + } + lock, err := loadDriverLock(lockPath) + 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) + } + key := bundle.GOOS + "/" + bundle.GOARCH + if _, ok := seen[key]; ok { + return fmt.Errorf("duplicate driver target %s/%s", bundle.GOOS, bundle.GOARCH) + } + zipPath := filepath.Join(filepath.Dir(descriptorPath), bundle.Name) + seen[key] = struct{}{} + sources[key] = zipPath + bundles = append(bundles, bundle) + } + slices.SortFunc(bundles, compareDriverBundles) + manifest := driverbundle.Manifest{ + Schema: driverbundle.ManifestSchema, + SPXVersion: spxVersion, + RuntimeVersion: lock.RuntimeVersion, + Bundles: bundles, + } + manifestData, err := manifest.JSON() + if err != nil { + return err + } + if err := ensureParent(manifestPath); err != nil { + return fmt.Errorf("prepare manifest 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 := verifyBundleFileForRuntime(destination, bundle, lock.RuntimeVersion); err != nil { + return fmt.Errorf("verify 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) + } + 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) +} diff --git a/.github/scripts/driverbundle/verify.go b/.github/scripts/driverbundle/verify.go new file mode 100644 index 000000000..84d70ce35 --- /dev/null +++ b/.github/scripts/driverbundle/verify.go @@ -0,0 +1,122 @@ +/* + * 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 verifyBundleFileForRuntime(zipPath, bundle, lock.RuntimeVersion) +} + +func verifyBundleFileForRuntime(zipPath string, bundle driverbundle.Bundle, runtimeVersion string) error { + if err := bundle.ValidateForRuntime(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..5ada3ae32 --- /dev/null +++ b/.github/scripts/driverbundle/verify_release.go @@ -0,0 +1,84 @@ +/* + * 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" + "path/filepath" + "strings" + + "github.com/goplus/spx/v3/internal/driverbundle" +) + +func runVerifyRelease(args []string) error { + flags := flag.NewFlagSet("driverbundle verify-release", flag.ContinueOnError) + flags.SetOutput(os.Stderr) + directory, expectedVersion := ".", "" + lockPath := "internal/release/runtime.lock.json" + flags.StringVar(&lockPath, "lock", lockPath, "runtime lock JSON") + flags.StringVar(&directory, "directory", directory, "download directory") + flags.StringVar(&expectedVersion, "spx-version", "", "expected SPX release 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(), " ")) + } + if strings.TrimSpace(expectedVersion) == "" { + return fmt.Errorf("--spx-version is required") + } + manifestPath := filepath.Join(directory, driverbundle.ManifestName) + data, err := readRegularFile(manifestPath) + if err != nil { + return fmt.Errorf("read public driver manifest: %w", err) + } + lock, err := loadDriverLock(lockPath) + if err != nil { + return err + } + manifest, err := driverbundle.ParseForVersions(data, expectedVersion, lock.RuntimeVersion) + if err != nil { + return fmt.Errorf("parse public driver manifest: %w", err) + } + + expectedFiles := map[string]struct{}{driverbundle.ManifestName: {}} + for _, bundle := range manifest.Bundles { + expectedFiles[bundle.Name] = struct{}{} + zipPath := filepath.Join(directory, bundle.Name) + if err := verifyBundleFileForRuntime(zipPath, bundle, lock.RuntimeVersion); 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)) + } + 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..188a230e8 --- /dev/null +++ b/.github/scripts/driverbundle/verify_release_test.go @@ -0,0 +1,144 @@ +/* + * 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" + "testing" + + "github.com/goplus/spx/v3/internal/driverbundle" + "github.com/goplus/spx/v3/internal/release" +) + +func TestVerifyReleaseChecksVersionsAndBundles(t *testing.T) { + lock, err := release.RuntimeLockForVersion("2.4.3") + 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])) + } + manifest := driverbundle.Manifest{ + Schema: driverbundle.ManifestSchema, SPXVersion: "v3.2.4", RuntimeVersion: lock.RuntimeVersion, + 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) + } + + args := []string{"--lock", lockPath, "--directory", directory, "--spx-version", manifest.SPXVersion} + if err := runVerifyRelease(args); err != nil { + t.Fatal(err) + } + if err := runVerifyRelease([]string{"--lock", lockPath, "--directory", directory, "--spx-version", "v9.9.9"}); err == nil { + t.Fatal("accepted mismatched SPX version") + } + otherLock, err := release.RuntimeLockForVersion("2.4.2") + if err != nil { + t.Fatal(err) + } + otherLockData, err := otherLock.JSON() + if err != nil { + t.Fatal(err) + } + otherLockPath := filepath.Join(t.TempDir(), "runtime.lock.json") + if err := os.WriteFile(otherLockPath, otherLockData, 0o600); err != nil { + t.Fatal(err) + } + if err := runVerifyRelease([]string{"--lock", otherLockPath, "--directory", directory, "--spx-version", manifest.SPXVersion}); err == nil { + t.Fatal("accepted mismatched runtime version") + } + + if err := os.WriteFile(filepath.Join(directory, bundles[0].Name), []byte("tampered"), 0o600); err != nil { + t.Fatal(err) + } + if err := runVerifyRelease(args); err == nil { + t.Fatal("accepted tampered release") + } +} + +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..839ffd485 --- /dev/null +++ b/.github/scripts/driverbundle/workflow_test.py @@ -0,0 +1,143 @@ +#!/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_versioned(self): + self.assertIn("workflow_call:", self.driver) + self.assertNotIn("workflow_dispatch:", self.driver) + self.assertIn("driver-v> "$GITHUB_OUTPUT"', script) self.assertIn("dist/runtime", script) self.assertIn("dist/product", script) + def test_publish_release_requires_verified_driver(self): + setup = self.jobs["setup"] + self.assertIn("driver_tag: ${{ steps.release.outputs.driver_tag }}", setup) + driver = self.jobs["driver-release"] + self.assertIn("uses: ./.github/workflows/release_driver.yml", driver) + self.assertIn("needs.driver-release.result=='success'", job_condition(self.jobs["publish-spx"])) + + def test_runtime_publish_reuses_version_and_revalidates_draft(self): + script = step_script( + self.jobs["publish-runtime"], "Publish immutable runtime release" + ) + self.assertIn('if [ "$RUNTIME_STATE" = ready ]; then', script) + self.assertIn("Reusing verified public runtime release", script) + self.assertLess( + script.index('if [ "$RUNTIME_STATE" = ready ]; then'), + script.index('gh release view "$RELEASE_TAG"'), + ) + self.assertIn("--json isDraft,tagName,targetCommitish", script) + self.assertIn('--verify-manifest "published/$RUNTIME_MANIFEST"', script) + self.assertIn("Reusing public runtime release for $RUNTIME_VERSION", script) + self.assertNotIn("public_release_target", script) + self.assertNotIn('git/ref/tags/$RELEASE_TAG', script) + self.assertIn("Runtime draft $RELEASE_TAG targets", script) + self.assertIn("Runtime draft changed before publication", script) + self.assertIn("Runtime release was not published with the expected version", script) + self.assertLess( + script.index("Runtime draft $RELEASE_TAG targets"), + script.index("gh release delete-asset"), + ) + self.assertLess( + script.index("Runtime draft changed before publication"), + script.index('gh release edit "$RELEASE_TAG"'), + ) + + def test_setup_reuses_public_runtime_by_version_and_keeps_spx_target_strict(self): + script = step_script( + self.jobs["setup"], "Validate immutable publish targets" + ) + self.assertIn("--json isDraft,targetCommitish,assets", script) + self.assertIn('git/ref/tags/$tag', script) + self.assertIn('refs/tags/$tag', script) + self.assertIn('^[0-9a-f]{40}$', script) + self.assertIn("tag $tag and release target disagree", script) + self.assertIn('if [ "$RUNTIME_STATE" != ready ]; then', script) + self.assertIn('validate_target "$RUNTIME_TAG" "$RUNTIME_MANIFEST" Runtime', script) + self.assertIn('validate_target "$RELEASE_TAG" SHA256SUMS SPX', script) + + def test_spx_publish_revalidates_draft_and_public_identity(self): + prepare = step_script(self.jobs["publish-spx"], "Prepare SPX release draft") + finalize = step_script(self.jobs["finalize-spx"], "Publish completed SPX release") + + self.assertIn("SPX draft $RELEASE_TAG targets", prepare) + self.assertIn("SPX draft changed after upload", prepare) + self.assertLess( + prepare.index("SPX draft $RELEASE_TAG targets"), + prepare.index("gh release delete-asset"), + ) + self.assertLess( + prepare.index('gh release upload "$RELEASE_TAG"'), + prepare.index("SPX draft changed after upload"), + ) + self.assertIn('git/ref/tags/$RELEASE_TAG', finalize) + self.assertIn("Published SPX identity changed", finalize) + self.assertLess( + finalize.index('gh release edit "$RELEASE_TAG"'), + finalize.index("Published SPX identity changed"), + ) + def test_publish_dev_uses_exact_sha_and_oidc(self): block = self.jobs["publish-dev-web-package"] self.assertIn(" uses: ./.github/workflows/publish_web_package.yml", block) @@ -389,6 +468,7 @@ def test_release_gate_is_operation_aware_and_fail_closed(self): "setup", "assemble", "publish-runtime", + "driver-release", "publish-spx", "publish-web-package", "finalize-spx", @@ -401,14 +481,27 @@ def test_release_gate_is_operation_aware_and_fail_closed(self): self.assertIn(" NEEDS_JSON: ${{ toJSON(needs) }}", block) script = step_script(block, "Require the selected operation's terminal jobs") + final_release = { + job: "success" + for job in ( + "setup", + "assemble", + "publish-runtime", + "driver-release", + "publish-spx", + "publish-web-package", + "finalize-spx", + ) + } cases = ( ("dry-run", {"setup": "success", "assemble": "success"}, True), ("publish-runtime", {"setup": "success", "assemble": "success", "publish-runtime": "success"}, True), - ("publish-release", {job: "success" for job in terminal_jobs[:6]}, True), + ("publish-release", final_release, True), + ("publish-release", {"setup": "success", "assemble": "success", "publish-runtime": "success", "publish-spx": "success", "publish-web-package": "success", "finalize-spx": "success"}, False), ("publish-dev-npm", {"dev-npm-guard": "success", "publish-dev-web-package": "success"}, True), ("dry-run", {"setup": "success", "assemble": "skipped"}, False), ("publish-runtime", {"setup": "success", "assemble": "success", "publish-runtime": "skipped"}, False), - ("publish-release", {"setup": "success", "assemble": "success", "publish-runtime": "success", "publish-spx": "success", "publish-web-package": "skipped"}, False), + ("publish-release", {"setup": "success", "assemble": "success", "publish-runtime": "success", "driver-release": "success", "publish-spx": "success", "publish-web-package": "skipped"}, False), ("publish-dev-npm", {"dev-npm-guard": "skipped", "publish-dev-web-package": "skipped"}, False), ("publish-dev-npm", {"dev-npm-guard": "success", "publish-dev-web-package": "failure"}, False), ("unknown", {}, False), diff --git a/.github/scripts/runtime/manifest.go b/.github/scripts/runtime/manifest.go index 1aa284170..cf9099295 100644 --- a/.github/scripts/runtime/manifest.go +++ b/.github/scripts/runtime/manifest.go @@ -101,15 +101,18 @@ func runReleaseManifest(args []string) error { if len(specs) != 0 || checksumsPath != "" || spxCommit != "" || moduleTree != "" || runtimePackSourceHash != "" || buildRecipeHash != "" { return errors.New("generation flags cannot be combined with --verify-manifest") } - manifest, err := release.LoadRuntimeManifest(verifyManifest) + data, err := os.ReadFile(verifyManifest) if err != nil { - return err + return fmt.Errorf("read runtime manifest: %w", err) } - if err := manifest.ValidateForLock(lock); err != nil { + manifest, err := release.ParseRuntimeManifestForRelease(data, lock.RuntimeVersion, lock.RequiredAssets) + if err != nil { return err } if assetDirectory != "" { - return manifest.VerifyFiles(assetDirectory) + if err := manifest.VerifyFiles(assetDirectory); err != nil { + return err + } } return nil } diff --git a/.github/scripts/runtime/manifest_test.go b/.github/scripts/runtime/manifest_test.go index e7f07e505..1909443f1 100644 --- a/.github/scripts/runtime/manifest_test.go +++ b/.github/scripts/runtime/manifest_test.go @@ -56,8 +56,19 @@ func TestRunReleaseManifestGenerateAndVerify(t *testing.T) { }); err != nil { t.Fatalf("verify generated manifest: %v", err) } + manifest, err := release.LoadRuntimeManifest(manifestPath) + if err != nil { + t.Fatal(err) + } + manifest.RuntimeABI++ + manifest.ReleaseRepository = "example/runtime" + manifest.LockSHA256 = strings.Repeat("0", 64) + manifest.Provenance.GodotCommit = strings.Repeat("5", 40) + if err := release.WriteRuntimeManifest(manifestPath, manifest); err != nil { + t.Fatal(err) + } if err := runReleaseManifest([]string{"--verify-manifest", manifestPath}); err != nil { - t.Fatalf("validate generated manifest metadata: %v", err) + t.Fatalf("validate same-version manifest with different build metadata: %v", err) } corruptPath := filepath.Join(dir, lock.RequiredAssets[0]) diff --git a/.github/scripts/runtime/resolution.go b/.github/scripts/runtime/resolution.go index 958de5fd4..577860f7c 100644 --- a/.github/scripts/runtime/resolution.go +++ b/.github/scripts/runtime/resolution.go @@ -10,8 +10,6 @@ import ( "net/http" "net/url" "os" - "os/exec" - "path/filepath" "slices" "strings" "time" @@ -27,8 +25,6 @@ const ( type runtimeResolutionConfig struct { LockPath string - RepoRoot string - Revision string GitHubAPIURL string GitHubToken string HTTPClient *http.Client @@ -64,8 +60,6 @@ func runRuntimeResolution(args []string) error { config := runtimeResolutionConfig{} var githubOutput string fs.StringVar(&config.LockPath, "lock", "internal/release/runtime.lock.json", "runtime lock JSON") - fs.StringVar(&config.RepoRoot, "repo-root", ".", "SPX repository root") - fs.StringVar(&config.Revision, "revision", "HEAD", "SPX revision to verify") fs.StringVar(&config.GitHubAPIURL, "github-api-url", "https://api.github.com", "GitHub API base URL") fs.StringVar(&githubOutput, "github-output", "", "optional GitHub step output file") fs.Usage = func() { @@ -144,16 +138,9 @@ func resolveRuntime(ctx context.Context, config runtimeResolutionConfig) (runtim if status != http.StatusOK { return runtimeResolution{}, runtimeConflict(tag, "download manifest: HTTP %d %s", status, http.StatusText(status)) } - manifest, err := release.ParseRuntimeManifest(body) - if err != nil { + if _, err := release.ParseRuntimeManifestForRelease(body, lock.RuntimeVersion, lock.RequiredAssets); err != nil { return runtimeResolution{}, runtimeConflict(tag, "parse manifest: %v", err) } - if err := manifest.ValidateForLock(lock); err != nil { - return runtimeResolution{}, runtimeConflict(tag, "%v", err) - } - if err := verifyCurrentRuntimeProvenance(config, lock, manifest); err != nil { - return runtimeResolution{}, runtimeConflict(tag, "%v", err) - } result.State = runtimeStateReady result.Reason = fmt.Sprintf("verified published %s", tag) @@ -261,53 +248,8 @@ func verifyPublishedAssetNames(lock release.RuntimeLock, assets []gitHubAsset) ( return manifestURL, nil } -func verifyCurrentRuntimeProvenance(config runtimeResolutionConfig, lock release.RuntimeLock, manifest release.RuntimeManifest) error { - repoRoot := filepath.Clean(config.RepoRoot) - revision := strings.TrimSpace(config.Revision) - if revision == "" { - return errors.New("SPX revision must not be empty") - } - - moduleTree, err := gitRevision(repoRoot, revision+":"+lock.Module.Path) - if err != nil { - return fmt.Errorf("resolve current module tree: %w", err) - } - if manifest.Provenance.ModuleTree != moduleTree { - return fmt.Errorf("module tree = %s, current %s requires %s; bump runtime_version", manifest.Provenance.ModuleTree, revision, moduleTree) - } - runtimePackDigest, err := release.RuntimePackSourceSHA256(repoRoot, revision) - if err != nil { - return err - } - if manifest.Provenance.RuntimePackSourceSHA256 != runtimePackDigest { - return fmt.Errorf("runtime pack source digest = %s, current %s requires %s; bump runtime_version", manifest.Provenance.RuntimePackSourceSHA256, revision, runtimePackDigest) - } - buildRecipeDigest, err := release.RuntimeBuildRecipeSHA256(repoRoot, revision) - if err != nil { - return err - } - if manifest.Provenance.BuildRecipeSHA256 != buildRecipeDigest { - return fmt.Errorf("runtime build recipe digest = %s, current %s requires %s; bump runtime_version", manifest.Provenance.BuildRecipeSHA256, revision, buildRecipeDigest) - } - return nil -} - -func gitRevision(repoRoot, revision string) (string, error) { - command := exec.Command("git", "-C", repoRoot, "rev-parse", "--verify", revision) - output, err := command.Output() - if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - if detail := strings.TrimSpace(string(exitErr.Stderr)); detail != "" { - return "", errors.New(detail) - } - } - return "", err - } - return strings.TrimSpace(string(output)), nil -} - func runtimeConflict(tag, format string, args ...any) error { - return fmt.Errorf("runtime %s conflicts with the current runtime identity: %s", tag, fmt.Sprintf(format, args...)) + return fmt.Errorf("runtime %s is not reusable: %s", tag, fmt.Sprintf(format, args...)) } func writeRuntimeResolutionOutputs(path string, resolution runtimeResolution) error { diff --git a/.github/scripts/runtime/resolution_test.go b/.github/scripts/runtime/resolution_test.go index 3302a450c..abee45a5d 100644 --- a/.github/scripts/runtime/resolution_test.go +++ b/.github/scripts/runtime/resolution_test.go @@ -6,7 +6,6 @@ import ( "net/http" "net/http/httptest" "os" - "os/exec" "path/filepath" "strings" "sync/atomic" @@ -93,33 +92,40 @@ func TestResolveRuntimeDraftIsMissing(t *testing.T) { } } -func TestResolveRuntimeRejectsPublishedProvenanceConflict(t *testing.T) { +func TestResolveRuntimeAcceptsSameVersionBuildMetadata(t *testing.T) { fixture := newRuntimeResolutionFixture(t) - conflicting := fixture.manifest - conflicting.Provenance.ModuleTree = strings.Repeat("0", 40) - server, _ := fixture.server(t, http.StatusOK, false, conflicting, nil) + published := fixture.manifest + published.RuntimeABI++ + published.ReleaseRepository = "example/runtime" + published.LockSHA256 = strings.Repeat("0", 64) + published.Provenance.ModuleTree = strings.Repeat("1", 40) + published.Provenance.BuildRecipeSHA256 = strings.Repeat("2", 64) + server, _ := fixture.server(t, http.StatusOK, false, published, nil) defer server.Close() fixture.config.GitHubAPIURL = server.URL fixture.config.HTTPClient = server.Client() - _, err := resolveRuntime(context.Background(), fixture.config) - if err == nil || !strings.Contains(err.Error(), "conflicts with the current runtime identity") || !strings.Contains(err.Error(), "module tree") { - t.Fatalf("resolveRuntime error = %v, want provenance conflict", err) + got, err := resolveRuntime(context.Background(), fixture.config) + if err != nil { + t.Fatal(err) + } + if got.State != runtimeStateReady { + t.Fatalf("resolveRuntime = %#v, want ready", got) } } -func TestResolveRuntimeRejectsPublishedBuildRecipeConflict(t *testing.T) { +func TestResolveRuntimeRejectsManifestVersionMismatch(t *testing.T) { fixture := newRuntimeResolutionFixture(t) conflicting := fixture.manifest - conflicting.Provenance.BuildRecipeSHA256 = strings.Repeat("0", 64) + conflicting.RuntimeVersion = "9.9.9" server, _ := fixture.server(t, http.StatusOK, false, conflicting, nil) defer server.Close() fixture.config.GitHubAPIURL = server.URL fixture.config.HTTPClient = server.Client() _, err := resolveRuntime(context.Background(), fixture.config) - if err == nil || !strings.Contains(err.Error(), "conflicts with the current runtime identity") || !strings.Contains(err.Error(), "runtime build recipe digest") { - t.Fatalf("resolveRuntime error = %v, want build-recipe conflict", err) + if err == nil || !strings.Contains(err.Error(), "is not reusable") || !strings.Contains(err.Error(), "does not match") { + t.Fatalf("resolveRuntime error = %v, want version conflict", err) } } @@ -133,7 +139,7 @@ func TestResolveRuntimeRejectsIncompletePublishedAssetSet(t *testing.T) { fixture.config.HTTPClient = server.Client() _, err := resolveRuntime(context.Background(), fixture.config) - if err == nil || !strings.Contains(err.Error(), "conflicts with the current runtime identity") || !strings.Contains(err.Error(), "release assets") { + if err == nil || !strings.Contains(err.Error(), "is not reusable") || !strings.Contains(err.Error(), "release assets") { t.Fatalf("resolveRuntime error = %v, want asset-set conflict", err) } } @@ -170,7 +176,7 @@ func TestResolveRuntimeRejectsMissingManifestOnPublicRelease(t *testing.T) { }) _, err := resolveRuntime(context.Background(), fixture.config) - if err == nil || !strings.Contains(err.Error(), "conflicts with the current runtime identity") || !strings.Contains(err.Error(), "download manifest") { + if err == nil || !strings.Contains(err.Error(), "is not reusable") || !strings.Contains(err.Error(), "download manifest") { t.Fatalf("resolveRuntime error = %v, want missing-manifest conflict", err) } } @@ -212,12 +218,7 @@ type runtimeResolutionFixture struct { func newRuntimeResolutionFixture(t *testing.T) runtimeResolutionFixture { t.Helper() - repoRoot := gitOutput(t, ".", "rev-parse", "--show-toplevel") - currentLockPath := filepath.Join(repoRoot, "internal", "release", "runtime.lock.json") - lock, err := loadRuntimeResolutionLock(currentLockPath) - if err != nil { - t.Fatal(err) - } + lock := release.DefaultRuntimeLock() lockData, err := lock.JSON() if err != nil { t.Fatal(err) @@ -226,17 +227,6 @@ func newRuntimeResolutionFixture(t *testing.T) runtimeResolutionFixture { if err := os.WriteFile(lockPath, lockData, 0o600); err != nil { t.Fatal(err) } - moduleTree := gitOutput(t, repoRoot, "rev-parse", "--verify", "HEAD:"+lock.Module.Path) - spxCommit := gitOutput(t, repoRoot, "rev-parse", "--verify", "HEAD^{commit}") - runtimePackDigest, err := release.RuntimePackSourceSHA256(repoRoot, "HEAD") - if err != nil { - t.Fatal(err) - } - buildRecipeDigest, err := release.RuntimeBuildRecipeSHA256(repoRoot, "HEAD") - if err != nil { - t.Fatal(err) - } - assetDir := t.TempDir() inputs := make([]release.RuntimeAssetInput, 0, len(lock.RequiredAssets)) for _, name := range lock.RequiredAssets { @@ -247,11 +237,11 @@ func newRuntimeResolutionFixture(t *testing.T) runtimeResolutionFixture { inputs = append(inputs, release.RuntimeAssetInput{Name: name, Path: path}) } manifest, err := release.GenerateRuntimeManifest(lock, release.RuntimeProvenance{ - SPXCommit: spxCommit, + SPXCommit: strings.Repeat("a", 40), GodotCommit: lock.Godot.Commit, - ModuleTree: moduleTree, - RuntimePackSourceSHA256: runtimePackDigest, - BuildRecipeSHA256: buildRecipeDigest, + ModuleTree: strings.Repeat("b", 40), + RuntimePackSourceSHA256: strings.Repeat("c", 64), + BuildRecipeSHA256: strings.Repeat("d", 64), Toolchain: lock.Toolchain, }, inputs) if err != nil { @@ -260,8 +250,6 @@ func newRuntimeResolutionFixture(t *testing.T) runtimeResolutionFixture { return runtimeResolutionFixture{ config: runtimeResolutionConfig{ LockPath: lockPath, - RepoRoot: repoRoot, - Revision: "HEAD", }, lock: lock, manifest: manifest, @@ -316,16 +304,6 @@ func (fixture runtimeResolutionFixture) server(t *testing.T, releaseStatus int, return server, requests } -func gitOutput(t *testing.T, repoRoot string, args ...string) string { - t.Helper() - commandArgs := append([]string{"-C", repoRoot}, args...) - output, err := exec.Command("git", commandArgs...).CombinedOutput() - if err != nil { - t.Fatalf("git %s: %v\n%s", strings.Join(commandArgs, " "), err, output) - } - return strings.TrimSpace(string(output)) -} - type roundTripFunc func(*http.Request) (*http.Response, error) func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { diff --git a/.github/scripts/runtime/version.go b/.github/scripts/runtime/version.go index 8a9e45278..144a9635d 100644 --- a/.github/scripts/runtime/version.go +++ b/.github/scripts/runtime/version.go @@ -33,6 +33,7 @@ var repositoryNamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+ type releaseDescription struct { ReleaseTag string `json:"release_tag"` Version string `json:"version"` + DriverTag string `json:"driver_tag"` RuntimeVersion string `json:"runtime_version"` RuntimeABI int `json:"runtime_abi"` RuntimeTag string `json:"runtime_tag"` @@ -114,6 +115,7 @@ func describeRelease() (releaseDescription, error) { return releaseDescription{ ReleaseTag: meta.SPXVersion, Version: strings.TrimPrefix(meta.SPXVersion, "v"), + DriverTag: "driver-" + meta.SPXVersion, RuntimeVersion: lock.RuntimeVersion, RuntimeABI: lock.RuntimeABI, RuntimeTag: lock.RuntimeReleaseTag(), @@ -141,6 +143,7 @@ func (d releaseDescription) githubOutputs() []githubOutput { return []githubOutput{ {name: "release_tag", value: d.ReleaseTag}, {name: "version", value: d.Version}, + {name: "driver_tag", value: d.DriverTag}, {name: "runtime_version", value: d.RuntimeVersion}, {name: "runtime_abi", value: strconv.Itoa(d.RuntimeABI)}, {name: "runtime_tag", value: d.RuntimeTag}, diff --git a/.github/scripts/runtime/version_test.go b/.github/scripts/runtime/version_test.go index 4342940dc..417f81c2c 100644 --- a/.github/scripts/runtime/version_test.go +++ b/.github/scripts/runtime/version_test.go @@ -36,6 +36,9 @@ func TestDescribeRelease(t *testing.T) { if description.ReleaseTag != meta.SPXVersion || description.Version != strings.TrimPrefix(meta.SPXVersion, "v") { t.Fatalf("unexpected SPX release: %#v", description) } + if description.DriverTag != "driver-"+meta.SPXVersion { + t.Fatalf("unexpected driver release: %#v", description) + } if description.RuntimeVersion != lock.RuntimeVersion || description.RuntimeABI != lock.RuntimeABI || description.RuntimeTag != lock.RuntimeReleaseTag() || description.RuntimeManifest != lock.Manifest { t.Fatalf("unexpected runtime release: %#v", description) } @@ -74,6 +77,7 @@ func TestWriteGitHubOutput(t *testing.T) { wantLines := []string{ "release_tag=" + description.ReleaseTag, "version=" + description.Version, + "driver_tag=" + description.DriverTag, "runtime_version=" + description.RuntimeVersion, "runtime_abi=" + strconv.Itoa(description.RuntimeABI), "runtime_tag=" + description.RuntimeTag, diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 97d61f99f..817fd5cf3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,6 +39,7 @@ jobs: outputs: release_tag: ${{ steps.release.outputs.release_tag }} version: ${{ steps.release.outputs.version }} + driver_tag: ${{ steps.release.outputs.driver_tag }} runtime_version: ${{ steps.release.outputs.runtime_version }} runtime_abi: ${{ steps.release.outputs.runtime_abi }} runtime_tag: ${{ steps.release.outputs.runtime_tag }} @@ -107,8 +108,6 @@ jobs: set -euo pipefail go run ./.github/scripts/runtime/resolution.go \ --lock internal/release/runtime.lock.json \ - --repo-root . \ - --revision HEAD \ --github-output "$GITHUB_OUTPUT" - name: Verify locked Godot source ancestry @@ -196,6 +195,7 @@ jobs: RELEASE_TAG: ${{ steps.release.outputs.release_tag }} RUNTIME_TAG: ${{ steps.release.outputs.runtime_tag }} RUNTIME_MANIFEST: ${{ steps.release.outputs.runtime_manifest }} + RUNTIME_STATE: ${{ steps.runtime.outputs.runtime_state }} REPOSITORY: ${{ steps.release.outputs.release_repository }} OPERATION: ${{ inputs.operation }} run: | @@ -207,17 +207,42 @@ jobs: local tag="$1" local marker="$2" 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 [ "$tag_commit" != "$GITHUB_SHA" ]; then + echo "[error] $label tag $tag points to $tag_commit, not this commit $GITHUB_SHA" >&2 exit 1 fi fi @@ -235,9 +260,11 @@ jobs: fi } - validate_target "$RUNTIME_TAG" "$RUNTIME_MANIFEST" Runtime true + if [ "$RUNTIME_STATE" != ready ]; then + validate_target "$RUNTIME_TAG" "$RUNTIME_MANIFEST" Runtime + fi if [ "$OPERATION" = publish-release ]; then - validate_target "$RELEASE_TAG" SHA256SUMS SPX false + validate_target "$RELEASE_TAG" SHA256SUMS SPX fi - name: Resolve SPX package targets @@ -383,6 +410,16 @@ jobs: with: engine_artifacts: ${{ needs.setup.outputs.runtime_state == 'missing' }} + driver-release: + name: Publish or reuse project driver + needs: [setup, publish-runtime] + if: ${{ !cancelled() && inputs.operation == 'publish-release' && needs.publish-runtime.result == 'success' }} + permissions: + contents: write + uses: ./.github/workflows/release_driver.yml + with: + release_tag: ${{ needs.setup.outputs.driver_tag }} + assemble: name: Assemble and verify releases needs: @@ -415,7 +452,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 @@ -437,6 +481,7 @@ jobs: run: bash .github/scripts/release/assemble.sh - name: Upload assembled runtime release + if: needs.setup.outputs.runtime_state == 'missing' || inputs.operation == 'dry-run' uses: actions/upload-artifact@v7 with: name: spx-runtime-release-${{ needs.setup.outputs.runtime_version }} @@ -454,7 +499,7 @@ jobs: retention-days: 14 publish-runtime: - name: Publish runtime release + name: Publish or reuse runtime release needs: [setup, assemble] if: ${{ !cancelled() && inputs.operation != 'dry-run' && needs.assemble.result == 'success' }} runs-on: ubuntu-22.04 @@ -479,6 +524,7 @@ jobs: --lock internal/release/runtime.lock.json - name: Download assembled runtime release + if: needs.setup.outputs.runtime_state == 'missing' uses: actions/download-artifact@v8 with: name: spx-runtime-release-${{ needs.setup.outputs.runtime_version }} @@ -491,25 +537,46 @@ jobs: RELEASE_TAG: ${{ needs.setup.outputs.runtime_tag }} RUNTIME_VERSION: ${{ needs.setup.outputs.runtime_version }} RUNTIME_MANIFEST: ${{ needs.setup.outputs.runtime_manifest }} + RUNTIME_STATE: ${{ needs.setup.outputs.runtime_state }} REPOSITORY: ${{ needs.setup.outputs.release_repository }} 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)" + if [ "$RUNTIME_STATE" = ready ]; then + echo "[info] Reusing verified public runtime release: $RELEASE_TAG" + exit 0 + fi + if [ "$RUNTIME_STATE" != missing ]; then + echo "[error] Invalid runtime release state: $RUNTIME_STATE" >&2 + exit 1 + fi + + if release_json="$(gh release view "$RELEASE_TAG" --repo "$REPOSITORY" --json isDraft,tagName,targetCommitish 2>/dev/null)"; then + is_draft="$(jq -r .isDraft <<< "$release_json")" if [ "$is_draft" != true ]; then + if [ "$(jq -r .tagName <<< "$release_json")" != "$RELEASE_TAG" ]; then + echo "[error] GitHub returned the wrong runtime release version" >&2 + exit 1 + fi mkdir -p published gh release download "$RELEASE_TAG" --repo "$REPOSITORY" --dir published (cd published && sha256sum -c SHA256SUMS) - find dist -maxdepth 1 -type f -printf '%f\n' | sort > published/local-assets.txt - gh release view "$RELEASE_TAG" --repo "$REPOSITORY" --json assets --jq '.assets[].name' | sort > published/remote-assets.txt - 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 - echo "[info] Identical immutable runtime release already exists: $RELEASE_TAG" - exit 0 + if ! diff -u \ + <(find dist -maxdepth 1 -type f -printf '%f\n' | sort) \ + <(gh release view "$RELEASE_TAG" --repo "$REPOSITORY" --json assets --jq '.assets[].name' | sort); then + echo "[error] Runtime tag $RELEASE_TAG has an unexpected asset set" >&2 + exit 1 fi - echo "[error] Runtime tag $RELEASE_TAG already exists with different provenance" >&2 + go run ./.github/scripts/runtime/manifest.go \ + --lock internal/release/runtime.lock.json \ + --verify-manifest "published/$RUNTIME_MANIFEST" \ + --asset-directory published + echo "[info] Reusing public runtime release for $RUNTIME_VERSION" + exit 0 + 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 @@ -520,17 +587,29 @@ jobs: --repo "$REPOSITORY" \ --target "$GITHUB_SHA" \ --title "SPX Runtime $RUNTIME_VERSION" \ - --notes "Content-verifiable SPX runtime assets. See $RUNTIME_MANIFEST for source and toolchain provenance." \ + --notes "Versioned SPX runtime assets. See $RUNTIME_MANIFEST for checksums." \ --draft 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 + release_json="$(gh release view "$RELEASE_TAG" --repo "$REPOSITORY" --json isDraft,tagName)" + if [ "$(jq -r .isDraft <<< "$release_json")" != false ] || \ + [ "$(jq -r .tagName <<< "$release_json")" != "$RELEASE_TAG" ]; then + echo "[error] Runtime release was not published with the expected version: $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-release] + if: ${{ !cancelled() && inputs.operation == 'publish-release' && needs.publish-runtime.result == 'success' && needs.driver-release.result == 'success' }} runs-on: ubuntu-22.04 permissions: contents: write @@ -550,9 +629,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 +674,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 +703,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 +781,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 +834,7 @@ jobs: - setup - assemble - publish-runtime + - driver-release - publish-spx - publish-web-package - finalize-spx @@ -684,7 +860,7 @@ jobs: required+=(setup assemble publish-runtime) ;; publish-release) - required+=(setup assemble publish-runtime publish-spx publish-web-package finalize-spx) + required+=(setup assemble publish-runtime driver-release publish-spx publish-web-package finalize-spx) ;; publish-dev-npm) required+=(dev-npm-guard publish-dev-web-package) diff --git a/.github/workflows/release_driver.yml b/.github/workflows/release_driver.yml new file mode 100644 index 000000000..ac45fb3ab --- /dev/null +++ b/.github/workflows/release_driver.yml @@ -0,0 +1,287 @@ +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: Resolve published runtime release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + output="$(mktemp)" + trap 'rm -f "$output"' EXIT + go run ./.github/scripts/runtime/resolution.go \ + --lock internal/release/runtime.lock.json \ + --github-output "$output" + if [ "$(grep '^runtime_state=' "$output" | tail -1 | cut -d= -f2-)" != ready ]; then + echo "[error] Driver release requires the declared runtime version to be published" >&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,tagName,targetCommitish 2>/dev/null)"; then + if [ "$(jq -r .tagName <<< "$release_json")" != "$DRIVER_TAG" ]; then + echo "[error] GitHub returned the wrong driver release version" >&2 + exit 1 + fi + is_draft="$(jq -r .isDraft <<< "$release_json")" + if [ "$is_draft" = true ]; then + target_commit="$(jq -r .targetCommitish <<< "$release_json")" + if [[ ! "$target_commit" =~ ^[0-9a-f]{40}$ ]] || [ "$target_commit" != "$GITHUB_SHA" ]; then + echo "[error] Driver draft $DRIVER_TAG targets $target_commit, not $GITHUB_SHA" >&2 + exit 1 + fi + state=draft + echo "[info] Resuming existing draft driver release: $DRIVER_TAG" + elif [ "$is_draft" = false ]; then + state=ready + echo "[info] Reusing public driver release for $DRIVER_TAG" + else + echo "[error] Driver release has an invalid draft state: $is_draft" >&2 + exit 1 + 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 reusable 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] + 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 platform driver artifacts + uses: actions/download-artifact@v8 + with: + pattern: spx-driver-* + path: dist/input + + - name: Assemble strict driver manifest + 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" --manifest dist/driver/driver-manifest.json) + for descriptor in "${descriptors[@]}"; do + args+=(--descriptor "$descriptor") + done + mkdir -p dist/driver + 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 + + publish: + name: Publish driver release + needs: [setup, assemble] + if: ${{ !cancelled() && needs.setup.outputs.driver_state != 'ready' && needs.assemble.result == 'success' }} + 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 "Versioned 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,tagName)" + if [ "$(jq -r .isDraft <<< "$release_json")" != false ] || \ + [ "$(jq -r .tagName <<< "$release_json")" != "$DRIVER_TAG" ]; then + echo "[error] Driver release was not published with the expected version: $DRIVER_TAG" >&2 + exit 1 + fi + + verify: + name: Verify public driver release + needs: [setup, publish] + if: >- + ${{ + always() && + !cancelled() && + needs.setup.result == 'success' && + ((needs.setup.outputs.driver_state == 'ready' && needs.publish.result == 'skipped') || + (needs.setup.outputs.driver_state != 'ready' && needs.publish.result == 'success')) + }} + 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 public 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 + require_public_release() { + local release_json + release_json="$(gh release view "$DRIVER_TAG" --repo "$REPOSITORY" --json isDraft,tagName)" + if [ "$(jq -r .isDraft <<< "$release_json")" != false ] || \ + [ "$(jq -r .tagName <<< "$release_json")" != "$DRIVER_TAG" ]; then + echo "[error] Driver release is not public with the expected version: $DRIVER_TAG" >&2 + return 1 + fi + } + require_public_release + gh release download "$DRIVER_TAG" --repo "$REPOSITORY" --dir public-driver + go run ./.github/scripts/driverbundle verify-release \ + --directory public-driver \ + --spx-version "$SPX_VERSION" + require_public_release 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/runner.yml b/.github/workflows/runner.yml index 1f7bc83f7..e1dcb102c 100644 --- a/.github/workflows/runner.yml +++ b/.github/workflows/runner.yml @@ -45,8 +45,6 @@ jobs: set -euo pipefail go run ./.github/scripts/runtime/resolution.go \ --lock internal/release/runtime.lock.json \ - --repo-root . \ - --revision HEAD \ --github-output "$GITHUB_OUTPUT" - name: Report runtime CI policy diff --git a/.github/workflows/static_checks.yml b/.github/workflows/static_checks.yml index ecfe070df..102df2472 100644 --- a/.github/workflows/static_checks.yml +++ b/.github/workflows/static_checks.yml @@ -53,8 +53,10 @@ 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 go run .github/scripts/runtime/digest.go pack-source HEAD >/dev/null go run .github/scripts/runtime/digest.go build-recipe HEAD >/dev/null diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bbd1fb028..bc7d39d06 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -225,9 +225,9 @@ and state which commit or branch was used for integration testing. Keep `GODOT_SRC` pointed at the intended Godot checkout and do not copy the external SPX module into the Godot tree. -Do not change runtime pins or release metadata merely to make local testing -work. Those files define published, cross-repository provenance and should only -change as part of an intentional runtime or release update. +Do not change runtime lock snapshots or release metadata merely to make local +testing work. Those files define published cross-repository build inputs and +should change only as part of an intentional runtime or release update. ## Communicating with maintainers diff --git a/Makefile b/Makefile index fd4897fa6..1cc410754 100644 --- a/Makefile +++ b/Makefile @@ -11,8 +11,7 @@ MACOS_GO_TOOLCHAIN := cmd/internal/macos_go_toolchain.sh # Keep go.sum optional so clean repos without it can still build buildctl. OPTIONAL_GO_SUM := $(wildcard go.sum) RUNTIME_LOCK_SNAPSHOTS := $(wildcard internal/release/runtime_locks/*.json) -RUNTIME_MANIFEST_PINS := $(wildcard internal/release/runtime_manifest_pins/*.json) -BUILDCTL_SOURCES := go.mod $(OPTIONAL_GO_SUM) $(MACOS_GO_TOOLCHAIN) internal/release/runtime.lock.json $(RUNTIME_LOCK_SNAPSHOTS) $(RUNTIME_MANIFEST_PINS) $(shell find cmd internal -type f -name '*.go' ! -name '*_test.go' | LC_ALL=C sort) +BUILDCTL_SOURCES := go.mod $(OPTIONAL_GO_SUM) $(MACOS_GO_TOOLCHAIN) internal/release/runtime.lock.json $(RUNTIME_LOCK_SNAPSHOTS) $(shell find cmd internal -type f -name '*.go' ! -name '*_test.go' | LC_ALL=C sort) BUILDCTL_CMD := $(BUILDCTL_BIN) BUILDCTL_TARGETS := setup setup-web dev doctor list-demos install clean-assets download download-engine build-editor build-desktop build-web build-wasm build-wasm-opt build-android build-ios install-apk editor template-editor run runnative rune runweb runwebworker export-pack export-web stop PRIMARY_HELP_TARGETS := setup setup-web dev doctor build-editor build-desktop build-web build-android build-ios list-demos editor template-editor run runnative rune runweb runwebworker format generate help-advanced 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..a3b192512 100644 --- a/docs/en/dev/engine/release.md +++ b/docs/en/dev/engine/release.md @@ -10,20 +10,20 @@ The same tool supports `--spx-version`, `--runtime-tag`, and the default runtime The first atomic external-module release is SPX `v3.2.0`, runtime `2.4.0` (tag `runtime-v2.4.0`), and runtime ABI `2`. The historical `v3.1.0 -> Godot spx2.3.0` mapping remains legacy; never move or reinterpret an existing tag. -## Release identity boundaries +## Release version and integrity boundaries -| Identity/artifact | Content or reuse inputs | +| Artifact | Build or verification inputs | | --- | --- | | Godot engine/editor/templates | Godot commit, `godot_modules/spx` tree (including the SCons profile), engine toolchain, and platform axes | -| `spx-runtime-assets.zip` | SPX runtime pack sources, the pinned pack build recipe, and the locked Godot engine used to export the pack | -| Complete runtime release | Canonical `runtime.lock.json` SHA, module tree, pack source, build recipe, and every asset checksum | +| `spx-runtime-assets.zip` | SPX runtime pack sources, the fixed pack build recipe, and the locked Godot engine used to export the pack | +| Complete runtime release | `runtime-v`, a same-version manifest, the required asset set, and every asset checksum | | SPX product packages | SPX release commit, selected atomic runtime, and platform packaging flow | SPX and Godot Actions both invoke `.github/scripts/runtime_build_contract.py` from the selected SPX commit to validate the lock and SCons profile. Engine cache toolchain digests are platform-scoped: native uses SCons, Web adds EMSDK, and Android adds JDK plus the NDK. An unknown NDK installer alias fails only an Android build; it does not block unrelated platforms. -The manifest records `module_tree`, `runtime_pack_source_sha256`, and `build_recipe_sha256` independently. The full lock SHA is also part of the runtime-release reuse contract, so changing the ABI, required assets, repository/manifest, Godot ref/version/commit, module path, or any toolchain field rejects reuse. Godot SCons caches use a narrower identity: changing only a version, release metadata, an asset list, or documentation does not recompile Godot, although it may require a new runtime-release identity. Documentation itself is outside both runtime digests. +The manifest records `module_tree`, `runtime_pack_source_sha256`, and `build_recipe_sha256` for build traceability and diagnostics; those fields do not decide whether a published runtime can be reused. Reuse requires only the tag `runtime-v` and a manifest whose `runtime_version` equals the version selected by the lock. If source, ABI, toolchain, or asset-contract changes make an artifact incompatible, the publisher must bump `runtime_version` first. Godot SCons caches use their own build-input digests, and documentation is outside both runtime and cache digests. -The module tree remains a strict digest of the complete `godot_modules/spx` tree. The pack-source digest is narrower: it follows the desktop `runtime export-pack` inputs and evaluates Go build constraints for the fixed Linux/amd64 CGO pack builder with no extra tags. It projects `export_presets.cfg` to the Linux preset, while hashing packaged files such as `gdspx.gdextension` in full. Independent commands such as `run`, `buildlauncher`, Web/mobile exporters, platform templates, and release orchestration stay outside it. The build-recipe digest follows the dedicated local Linux engine preparation and export-pack path; other platform dispatch, remote transport, local-manifest publication, and CI transport stay outside it. Changes to any selected input still reject reuse of the old runtime. +The module tree remains a strict digest of the complete `godot_modules/spx` tree. The pack-source digest is narrower: it follows the desktop `runtime export-pack` inputs and evaluates Go build constraints for the fixed Linux/amd64 CGO pack builder with no extra tags. It projects `export_presets.cfg` to the Linux preset, while hashing packaged files such as `gdspx.gdextension` in full. Independent commands such as `run`, `buildlauncher`, Web/mobile exporters, platform templates, and release orchestration stay outside it. The build-recipe digest follows the dedicated local Linux engine preparation and export-pack path; other platform dispatch, remote transport, local-manifest publication, and CI transport stay outside it. These digests help diagnose build differences but do not replace an explicit version bump. ## Freeze order @@ -44,7 +44,7 @@ The module tree remains a strict digest of the complete `godot_modules/spx` tree ``` A normal merge preserves the candidate as an ancestor. A squash or rebase changes the source identity; run `make pin-godot-unpublished GODOT_SHA=` and repeat the dry-run instead of publishing the old candidate SHA. -5. Only after the ancestry verifier succeeds may a new runtime be published. When the resolver says the runtime must be built, both publish operations execute the same strict verifier in release setup before the Godot/runtime build, and `publish-runtime` verifies it again after the long build immediately before creating or uploading the release. A fully verified public runtime is immutable reuse and does not depend on its historical source ref remaining available. +5. Only after the ancestry verifier succeeds may a new runtime be published. The release flow runs the verifier before building only when the selected `runtime-v` is not yet public, and repeats it immediately before creating or uploading that release. A public release with the same runtime version can be reused without its historical source ref remaining available. Never publish from a fork. The `publish-runtime` and `publish-release` operations are allowed only in the lock's `release_repository`, currently `goplus/spx`. @@ -65,17 +65,17 @@ 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. +Ordinary CI resolves the locked runtime release before starting a runtime consumer. The resolver reads release metadata and the manifest only; without downloading every runtime asset, it confirms the tag is `runtime-v`, checks that the manifest's `runtime_version` equals the version selected by the lock, and validates the required asset-name set. - If the runtime is absent or still a draft, CI skips the published Web product smoke and instead builds the current SPX module with the locked Godot source, runs the Linux SPX tests, and performs the Web normal compile smoke. -- If the runtime is public and matches the current identity, the next CI run skips that source integration rebuild and requires the Web normal product smoke against the published assets. -- A public release with a missing manifest, a different asset set/provenance, or a GitHub API error is neither state: resolution fails closed and the CI gate fails. +- If the runtime is public and its version matches, the next CI run skips that source integration rebuild and requires the Web normal product smoke against the published assets. +- A public release with a missing manifest, a version mismatch, an incomplete asset set, or a GitHub API error is neither state: resolution fails closed and the CI gate fails. This switch avoids both the publication circular dependency and duplicate runtime builds. Ordinary CI never builds a complete release runtime; only the release workflow does that. The release assembly still downloads every asset and verifies `SHA256SUMS` plus every manifest checksum before reuse or publication. -The canonical-ref ancestry rule is deliberately release-only. Ordinary runner and module-integration workflows may test an exact locked candidate SHA without requiring that it has already reached `godot.ref`. When no reusable public runtime exists, release setup runs the shared verifier: `dry-run` may continue only after the verifier positively classifies a fetchable exact commit as a pre-merge candidate, and records that state in the workflow summary; both publish operations require verified ancestry before building. Ref lookup, ambiguity, network, and comparison failures fail every release operation rather than being treated as a candidate result. A publish job that built a new runtime checks ancestry again immediately before publication. If the resolver has already verified an immutable public runtime, source ancestry is reported as not required and the release no longer depends on the historical ref. +The canonical-ref ancestry rule applies only when building and publishing a new runtime. Ordinary runner and module-integration workflows may test an exact locked candidate SHA without requiring that it has already reached `godot.ref`. When no public runtime with the selected version exists, release setup runs the shared verifier: `dry-run` may continue only after the verifier positively classifies a fetchable exact commit as a pre-merge candidate and records that state in the workflow summary; publication requires verified ancestry before the new runtime build starts. Ref lookup, ambiguity, network, and comparison failures block the new runtime publication. If a public release has the same runtime version and its manifest agrees, source ancestry is reported as not required. Use a frozen release branch in `goplus/spx` for the bootstrap operations: @@ -83,19 +83,20 @@ 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` | Publish or reuse runtime and driver, then publish SPX/npm in one run | 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` once from the frozen release branch. The workflow handles `runtime-v` first: a public release with that tag is reusable when its manifest declares the same runtime version; otherwise the workflow builds, verifies, and publishes the complete runtime. +3. After the runtime is ready, the same run calls the reusable driver workflow. The driver release is `driver-v`; in a public release manifest, `spx_version` must equal the selected SPX version and `runtime_version` must equal the runtime version selected by the current lock. A mismatch fails. If the release does not exist, four native hosts reuse the ready Engine/PCK, build the bridge from the current SPX source, and publish the manifest plus four platform ZIPs. +4. Once the driver is ready, the workflow continues to build and publish the SPX products and npm without generating an intermediate file, committing another revision, or dispatching the workflow again. An SPX-only upgrade reuses an unchanged runtime without rebuilding Godot, but still publishes the driver release for the new SPX version. -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 runtime manifest, `SHA256SUMS`, and required asset set must match exactly. The driver manifest and every platform ZIP must also pass name, host, entry, size, and SHA-256 checks. Runtime reuse compares only `runtime_version`; driver reuse compares only `spx_version` and `runtime_version`. Checksums and ZIP validation protect downloaded content without creating a second release identity. Unpublished runtime, driver, and SPX drafts are still created by the current run, and the SPX tag points to the final release commit. If runtime content changes incompatibly, bump `runtime_version` instead of replacing an existing tag. ## 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 \ @@ -110,7 +111,7 @@ Do not restore publication on every push to `dev`. Explicit publication avoids r ## Maintaining later versions -- If only SPX products change and both runtime artifact classes remain identical, an SPX-only mapping may retain the current runtime, but the release dry-run must first prove that the public runtime's complete provenance is reusable. `bump-release` deliberately does not make this reuse decision locally. +- If only SPX products change and the runtime version stays unchanged, an SPX-only mapping may retain the current runtime. The release flow reuses its public assets by runtime version and publishes the corresponding driver for the new SPX version. - If Godot, `godot_modules/spx`, toolchain inputs, or runtime pack output changes, advance both identities in one transaction: ```sh @@ -118,7 +119,7 @@ Do not restore publication on every push to `dev`. Explicit publication avoids r ``` Add `RUNTIME_ABI=N` only when the runtime ABI itself changes. The command uses authenticated `gh` API reads to require both current releases to be public and both target release/tag names to be unused before it writes. It then archives the previous SPX mapping, advances the current lock, creates the new immutable snapshot, and runs the release-metadata tests. It never publishes and never changes the Godot pin. If the current pair is still an unpublished candidate, keep those versions instead of archiving them as release history. -- Every atomic runtime definition must have an immutable lock snapshot at `internal/release/runtime_locks/.json`. Consumers use that snapshot, rather than the newer default lock, when validating a historical manifest. +- Every atomic runtime definition must have an immutable lock snapshot at `internal/release/runtime_locks/.json`. Historical versions use that snapshot to recover build configuration, platform asset names, and the SPX/runtime mapping rather than consulting the newer default lock. - After creating a new runtime version, use `make pin-godot GODOT_SHA=` for a strict pin, `make pin-godot-unpublished GODOT_SHA=` to replace its unpublished snapshot after the commit reaches the lock ref, or `make pin-godot-candidate GODOT_SHA=` for a verified pre-merge dry-run candidate. All three retain the current lock ref unless `GODOT_REF=...` is supplied, derive the snapshot filename from the current lock, and never touch other historical versions. - The project `go.mod` scaffold renders the declared SPX version automatically. The `v3.0.0` requirement in `internal/cmd/codegen/go.mod` is only the major-version floor for its local `replace`; do not bump either file during a release. - Once a runtime is public, freeze its snapshot permanently. Never use `sync --unpublished`, `pin-godot-unpublished`, or `pin-godot-candidate` for a public tag; use `make bump-release` with a new runtime version instead. Release setup runs `check`, and package initialization plus drift/catalog tests enforce the current mapping. 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..3d8570cac --- /dev/null +++ b/docs/en/dev/engine/xgo-project-driver-proposal-issue.md @@ -0,0 +1,671 @@ +# [Proposal] XGo Project Driver v1 and SPX Runtime Integration + +> Status: source mode and the published bundle path are implemented; an exact SPX module version selects its published driver directly, with SPX/runtime versions and asset integrity checked before use +> +> 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 a combined driver bundle v1: the exact canonical SPX module version directly selects `driver-v`, 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. Its manifest's `spx_version` must match the selected module version and `runtime_version` must match the current module lock; bundle sizes and SHA-256 values are still verified, while bundle 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 bundle for the exact module version. +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; selected exact module version and equal `spx_version` in the driver manifest in published mode | +| Engine Runtime/ABI | Engine, PCK, and interface compatibility | runtime version selected by the SPX runtime lock and equal `runtime_version` in runtime/driver manifests | + +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. + +Except for the isolated `pkg@version` classification probe, 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` | Classify only the requested version in a temporary `GOWORK=off`, `-mod=mod` graph; an ordinary target remains legacy, while a driver match is rejected explicitly | + +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` | + +Explicit CLI defaults `-trimpath=false` and `-buildvcs=auto` are equivalent to omission; +XGo accepts and normalizes them away instead of placing them on the wire. Other semantic +or unknown flags remain errors after a driver match. + +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 derives the `driver-v` release URL directly from the selected module version and downloads `driver-manifest.json` plus the independent driver-release host ZIP, not an existing standalone runtime ZIP. The manifest's `spx_version` must equal the selected module version and `runtime_version` must equal the runtime version selected by the current module lock; the manifest and ZIP are still strictly validated for schema, 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 `runtime-v` release selected by the lock is tried first and its manifest must declare the same runtime version; an exact-version local source/GOPATH runtime is used only when that release is unavailable; +2. published mode: the exact SPX module version selects the `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. + +A malformed manifest or any version, size, digest, or host mismatch fails closed in both modes before a runtime asset is used. + +`$GOPATH/bin` is not used to satisfy published mode. Published identity comes from the selected module/lock versions and their corresponding declarations in the manifest; a file name, existence check, or file size alone cannot establish content integrity. 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 inside the private staging directory allocated by XGo and must +produce its target at the designated staging path. On success, XGo validates and commits +only that target; other temporary or diagnostic files do not participate in commit and +are removed with the staging directory. The target must be a non-empty, non-symlink host +executable, then XGo 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 version, digest, runtime, ABI, or platform 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 and a `driver-v` host bundle whose `spx_version` and `runtime_version` respectively match the module and lock; a missing release, manifest version mismatch, or invalid release input fails closed; +- 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. Production publication advances runtime→driver→SPX in one `publish-release` run, keeping the driver bundle independent of `runtime_version`: + +1. **Runtime stage**: resolve the `runtime-v` selected by the lock. Reuse a public release with the same version, or build, verify, and publish the complete runtime assets when it does not exist. +2. **Driver bundle stage**: resolve `driver-v`. Reuse a public release when its manifest's `spx_version` and `runtime_version` respectively match the selected SPX and runtime versions, or build one ZIP per supported host from the exact SPX release source when the release does not exist. Each ZIP must contain exactly Engine, PCK, and bridge, with names, sizes, and SHA-256 values frozen in the manifest. +3. **Module stage**: after the driver is ready, 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 all three stages automatically. Driver publication is not dispatched separately, and the flow does not pause between stages to generate a file or require another commit. Runtime reuse compares only the `runtime_version` selected by the lock; driver reuse compares only the selected `spx_version` and `runtime_version`. Every stage still strictly verifies its manifest, asset set, checksums, and ZIP contents; those integrity checks do not create another release identity. + +The driver bundle URL and identity come only from the exact SPX module version and `driver-v`, never from `runtime_version`; the manifest runtime version only confirms that the bundle carries Engine/PCK for the current lock. If the matching release is incomplete or either manifest version differs, 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 the `driver-manifest.json` and host ZIP selected by its exact module version, requires their SPX/runtime versions to match the module and lock respectively, 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. + +## Normative v1 Contract + +This section is the executable contract shared by `goplus/mod/driverprotocol`, the +XGo dispatcher, and the SPX `xgodriver`. Adding a field or state requires updating +this section before changing the three repositories; unknown fields are never +silently ignored. + +### Metadata and capability negotiation + +The `gox.mod`/`gop.mod` parser accepts a syntactically valid +`driver vN ` with `N >= 1` and preserves the protocol value in resolved +metadata; the parser does not infer dispatcher capability. The XGo v1 dispatcher +executes only `v1`. Once a target's project metadata contains any `driver` +declaration, it is a driver match: `v2` or later must produce a terminal +`unsupported driver protocol` error, never masquerade as `NotHandled` and enter +GenGo. A future dispatcher may advertise a new protocol capability only after it +implements that protocol's argv, process, and transaction contracts. Raising the +declaring module's `xgo` directive raises the minimum XGo version but cannot +downgrade an unknown protocol to v1. + +`driver` belongs to the nearest preceding `project` and may appear at most once +for that project. Its protocol must match `v[1-9][0-9]*`, and its package must pass +Go import-path validation. A known directive with a wrong argument count, malformed +protocol, or malformed package is an error in both strict and lax metadata parsing. + +### Invocation frame and fields + +The driver executable receives an argv frame beginning with `xgo-driver-v1` and one +action: + +```text +xgo-driver-v1 run * * -- * +xgo-driver-v1 build * * --output= --final-output= +``` + +Every option is one `--name=value` argument. `run` requires exactly one protocol +delimiter: the first `--`. Every following element is passed unchanged, so a later +argument whose value is `--` is an ordinary application argument. `build` accepts +neither a protocol delimiter nor application arguments. Duplicate singular fields, +unknown or missing fields, partial replacement groups, and action-inapplicable fields +are rejected before driver execution. The protocol uses no request file and does not +consume stdin. + +| Field | Constraint | +| --- | --- | +| `project-dir` / `project-file` | Shared validation requires absolute clean paths and structurally requires the file to be top-level in the directory | +| `module-root` | Shared validation requires an absolute clean path that lexically contains `project-dir` | +| `project-ext` / `project-full-ext` | Non-empty and NUL-free; copied from XGo target resolution | +| `driver-package` | Valid Go import path inside the `selected-path` module | +| `selected-path` / `selected-version` | MVS logical selection; main version is empty, other modules have a canonical version | +| `origin-main` | Exactly `true` or `false` | +| `selected-dir` / `selected-gomod` | Required as a pair without a replacement; describes selected source only at the structural layer | +| `replace-path` / `replace-version` / `replace-dir` / `replace-gomod` | Required as a complete group with a replacement; then `selected-dir`/`selected-gomod` are forbidden | +| `declaration-file` / `declaration-sha256` | Declaring `gox.mod` or `gop.mod` and its lowercase SHA-256 | +| `go-command` / `graph-work-dir` / `go-work` | Go executable, working directory, and workspace used by graph operations; `go-work=off` disables workspace | +| `graph-flag` | Shared codec recognizes `-mod=mod|readonly|vendor`, `-modfile=`, and `-overlay=`; consumers decide whether special policies may execute | +| `build-flag` | Only `-v=true`, `-x=true`, `-work=true`, `-trimpath=true`, and `-buildvcs=false` are supported | +| `pack-dir` / `pack-index` | Optional but paired; directory is a clean relative path below the project root and index is a plain file name | +| `output` / `final-output` | Build-only, absolute and different; staging is XGo-private and final is the user-visible target | + +`ResolvedModule` equality includes `Main`, selected path/version/source fields, and +replacement information. A local replacement path uses clean absolute directory +spelling; a versioned replacement retains module path/version but is rejected by +SPX's published policy. The shared codec performs only filesystem-free structural +checks: absolute/clean paths, lexical containment, option groups, import/module +versions, and digest shapes. XGo resolves real paths and pins source provenance +during discovery. The SPX consumer repeats `Lstat`, `EvalSymlinks`, path equality, +regular-file/directory/executable type, containment, `SameFile` across reads, and +content-digest checks. Passing shared validation therefore does not assert that a +file exists or that a path is not a symlink. + +Before starting the driver, XGo enforces an argv/environment budget. On Unix, the +executable, NUL terminators for every argv and environment string, and one native +pointer per argv/environment entry (budgeted as 64-bit) share a `128 KiB` limit. On +Windows, the conservatively quoted UTF-16 command line is limited to `30,000` code +units and the environment block, including its terminating NUL, to `32,767` UTF-16 +code units. Either overflow returns `ErrDriverArgvTooLarge`; XGo must not truncate +arguments or switch to an undefined request file. + +### Graph, target, and flag policy + +For ordinary directory/file/package targets, XGo snapshots ambient `GOFLAGS` and +`GOWORK` once. It reads and parses `GOFLAGS`, then queries `GOWORK` with `GOFLAGS=`. +Every supported graph flag is subsequently passed as a distinct Go-command argv; +subprocess environments pin `GOFLAGS=` and pin `GOWORK` to a canonical go.work path +or `off`. This applies to discovery, driver-package validation, driver build, SPX +provenance, source-bridge build, and launcher build, preventing an ambient flag from +changing the graph at any stage. + +Inputs that require classification before rejection have distinct contracts: + +| Input | Discovery/classification | After a driver match | +| --- | --- | --- | +| `pkg@version` | Download and inspect the requested version and its class graph in a new temporary module with `GOWORK=off` and `GOFLAGS=-mod=mod`; never reuse caller-graph metadata | Report that v1 does not support `@version`; the probe classifies only and never executes a driver; return `NotHandled` when no driver matches | +| `-mod=vendor` | Only active main/workspace metadata is authoritative; an ordinary target without an external class marker may return `NotHandled`; fail closed before classification when external class metadata cannot be proven from the vendor snapshot | Any driver match reports vendor unsupported; shared codec acceptance exists only to faithfully represent and defensively reject the policy | +| `-overlay` | Use the overlay view only to decide whether target/metadata declares a driver; ordinary targets retain legacy behavior | Since v1 snapshots physical filesystem contents, report overlay unsupported and never pass overlay contents to the driver | + +A directory contains exactly one matching project file; a single-file target must be +that unique file. Multi-file targets and patterns containing `...` are rejected only +when they contain a driver-backed project; otherwise legacy handling remains intact. +All graph probes honor context cancellation and clean up their temporary graphs. + +### Dispatch state machine + +The XGo dispatcher has exactly two legal outcomes: + +```text +target -> graph -> class metadata -> driver match + |-- NotHandled -> legacy GenGo + `-- matched -> validate -> build driver -> invoke +``` + +`NotHandled` is the only result that permits the legacy path. Once matched, graph, +metadata, version, protocol, driver-build, driver-exit, and asset errors are terminal; +XGo must not retry GenGo. `XGO_DRIVER=off` is also an explicit error, not fallback. The +`SPX_XGO_DRIVER_ACTIVE` guard rejects recursive dispatch, including dispatch to another +driver. + +`run` resolves, builds a temporary driver, encodes argv, and launches it with inherited +stdin/stdout/stderr. `build` and `install` resolve all targets before creating staging or +an install directory. The driver must create one non-empty, non-symlink host executable +at the designated staging path; other files in that private directory are not committed +and are removed with it. XGo validates identity and performs the final same-filesystem +rename only at the commit point; failures before that point cannot alter an existing +output. `-work=true` retains diagnostics but does not change commit semantics. + +Only the host dispatcher owns signal subscriptions. Driver/Engine supervisors consume +cancellation and its cause. Once cancellation is observed, a child that exits 0 during +shutdown is still not success; Unix preserves a normal code or signal, while Windows +uses a Job Object and represents interrupts as 130. + +`cmd/xgodriver` exits with code `2` for argv/protocol parse or live-request validation +failure. Acquisition, graph, build, packaging, and other execution errors use code `1` +when no more specific child status exists. A normal non-zero Engine code is preserved. +On Unix, a signal status is reproduced by re-signalling the wrapper itself, with +`128+signal` only as the fallback when re-signalling fails. Windows has no POSIX signal +status, and a host interrupt returns `130`. Command errors on stderr have exactly one +`xgodriver: ` prefix. + +### XGo version checks + +The declaring module's `xgo` directive and the `driver v1` protocol baseline are +independent and combined by taking the maximum. The current baseline is `1.8.0`. +Source/workspace builds of XGo can expose a standard Go pseudo-version in build info +(for example `v1.2.0-pre.1.0.20260821130422-831eec0b6b4e`); this denotes a development +build and is compared using driver capability `1.8.0`, while diagnostics retain the +original version and capability. A real release prerelease such as `v1.8.0-rc1` is not +treated as development automatically. This rule applies only to XGo capability checks; +SPX published mode still rejects pseudo-versions of the SPX module. + +### SPX modes and environment inputs + +SPX uses source mode only for the main module, current workspace module, or an +unversioned local replacement. A canonical unreplaced +`github.com/goplus/spx/v3@vX.Y.Z` uses published mode; pseudo-versions, versioned +replacements, and foreign modules fail closed. + +The source bridge's build info must record the effective SPX module as its main module; +the generated launcher must record it as a dependency. For a source/workspace main +module, the Go-generated main build-info version (including a standard pseudo-version) +is diagnostic only and does not participate in identity comparison; module path and +verified graph/source snapshots fix its identity. That workspace dependency's version +in a launcher must be empty or `(devel)`, preventing accidental linkage to versioned +SPX source. + +Source-mode runtime precedence is fixed: explicit local runtime manifest -> verified +runtime release/cache (or `SPX_RUNTIME_ASSET_DIR` mirror) -> online acquisition +(skipped offline) -> exact-version source/GOPATH runtime fallback only when the release +is genuinely unavailable. The bridge package is fixed by the effective SPX source, +cannot be replaced by the environment, and is built for the host with +`CGO_ENABLED=1`. + +Published mode accepts only the combined driver bundle. Programmatic +`launchpack.Config` runtime source roots, runtime manifest paths, runtime asset +directories, and source bridge packages conflict with this mode and are rejected. +Inherited `SPX_RUNTIME_LOCAL_MANIFEST` and `SPX_RUNTIME_ASSET_DIR` values are ignored in +published mode, never participate in selection, and do not become errors merely by +being present or duplicated. Local/GOPATH +bridges likewise never participate in published selection, without requiring errors +for unrelated ambient variables. The only local published-artifact mirror is +`SPX_DRIVER_ASSET_DIR`, which must provide both the exact manifest and host bundle and +pass full verification. A missing or damaged explicit mirror fails without being +hidden by a warm cache or the network. + +The environment inputs are: + +| Variable | Semantics | +| --- | --- | +| `SPX_RUNTIME_LOCAL_MANIFEST` | Select and strictly verify a source-mode local runtime manifest; failure does not fall back; ignored and excluded from selection in published mode | +| `SPX_RUNTIME_ASSET_DIR` | Select a source-mode runtime-release mirror whose contents must match its manifest; ignored and excluded from selection in published mode | +| `SPX_DRIVER_ASSET_DIR` | Published-mode mirror containing `driver-manifest.json` and the host ZIP; must be an absolute clean path; source mode does not treat it as runtime input | +| `SPX_RUNTIME_CACHE` | Absolute, clean content-addressed cache root | +| `SPX_RUNTIME_OFFLINE` | `1/true/yes/on` forbids network and accepts only a complete verified cache/local hit | +| `GOWORK` / `GOFLAGS` | XGo pins `GOWORK` to a canonical path/`off`; Go subprocesses pin `GOFLAGS=` and receive graph/build flags only in argv | + +`ProjectDir`, `AssetDir`, and `SessionDir` are independent roots; user arguments cannot +override the driver's `SessionDir --path`. `.config`, pack indexes, module metadata, and +release manifests are consumed as snapshots and revalidated. The project source tree +must be unchanged after both successful and failed run/build operations. + +The SPX graph verifier hashes the complete `go list -m all` output and snapshots the +presence and contents of the effective active modfile (default `go.mod` or explicit +`-modfile`) and its matching sum, `go.work/go.work.sum`, and the effective local SPX +`go.mod`. Source-bridge and launcher builds repeat module selection and file snapshots +before and after critical +commands. A selection change, content change, identity change, or appearance/removal +of a required or optional graph file is terminal. Host Go environments used for graph, +provenance, and launcher operations pin host `GOOS/GOARCH`, `CGO_ENABLED=0`, +`GOFLAGS=`, and the request's `GOWORK`. Source-bridge builds additionally remove +inherited `CGO_*` variables and pin `CGO_ENABLED=1`. The Engine process does not inherit +these Go graph/target variables. + +### Published driver manifest and host ZIP + +`driver-manifest.json` is limited to `16 MiB` and uses strict JSON: unknown fields, +duplicate keys, trailing values, and wrong types are rejected. The complete schema is +below; array order is part of the v1 contract: + +```json +{ + "schema": 1, + "spx_version": "vX.Y.Z", + "runtime_version": "R", + "bundles": [ + { + "goos": "darwin", + "goarch": "amd64", + "name": "spx-driver-darwin-amd64.zip", + "size": 1, + "sha256": "<64 lowercase hex>", + "engine_interface_digest": "<64 lowercase hex>", + "files": [ + {"name": "gdspxrtR", "mode": 493, "size": 1, "sha256": "<64 lowercase hex>"}, + {"name": "gdspxrtR.pck", "mode": 420, "size": 1, "sha256": "<64 lowercase hex>"}, + {"name": "gdspx-darwin-amd64.dylib", "mode": 493, "size": 1, "sha256": "<64 lowercase hex>"} + ] + } + ] +} +``` + +The real `bundles` array contains exactly four elements in this order; the one-element +array above demonstrates fields only: + +| Order | Target | ZIP | Files, strictly Engine/PCK/bridge | +| --- | --- | --- | --- | +| 1 | `darwin/amd64` | `spx-driver-darwin-amd64.zip` | `gdspxrtR` `0755`; `gdspxrtR.pck` `0644`; `gdspx-darwin-amd64.dylib` `0755` | +| 2 | `darwin/arm64` | `spx-driver-darwin-arm64.zip` | `gdspxrtR` `0755`; `gdspxrtR.pck` `0644`; `gdspx-darwin-arm64.dylib` `0755` | +| 3 | `linux/amd64` | `spx-driver-linux-amd64.zip` | `gdspxrtR` `0755`; `gdspxrtR.pck` `0644`; `gdspx-linux-amd64.so` `0755` | +| 4 | `windows/amd64` | `spx-driver-windows-amd64.zip` | `gdspxrtR.exe` `0755`; `gdspxrtR.pck` `0644`; `gdspx-windows-amd64.dll` `0755` | + +Here `R` is `runtime_version` without a leading `v`. Every size is positive and equals +the actual byte count. Bundle SHA-256 covers the complete ZIP; file SHA-256 covers +uncompressed file bytes. `engine_interface_digest = +SHA256(ASCII("spx-engine-interface/v1") || 0x00 || hexDecode(engine.sha256) || +hexDecode(pck.sha256))`. `spx_version` equals the graph-selected exact module version, +and `runtime_version` equals the SPX runtime lock. Release tag and URL are derived only +from `driver-`. + +The release packager writes exactly three regular entries in Engine/PCK/bridge order, +using ZIP `Store`, UTC `1980-01-01T00:00:00Z`, the modes above, and portable basenames; +it emits no directory, extra, duplicate, or symlink entry. The same three inputs must +produce identical ZIP bytes. Consumers additionally verify the manifest's exact +archive size/SHA-256 and each file name/mode/size/SHA-256; a file name or interface +digest alone never establishes trust. + +### Archive and payload limits + +Every limit fails closed before extraction or launcher build; compression, ZIP64, and +manifest declarations cannot bypass it: + +| Object | v1 limits | Deterministic parameters | +| --- | --- | --- | +| Runtime/driver ZIP verifier | At most `10,000` entries; `512 MiB` per entry; `4 GiB` total uncompressed; `8 GiB` archive; `200:1` compression ratio. A driver ZIP additionally contains exactly 3 files | Release driver ZIP uses `Store`, 1980 epoch, canonical mode/order; manifest pins full archive/file digests | +| Canonical project ZIP | At most `10,000` files; `64 MiB` per file; `256 MiB` total input; `512 MiB` archive | UTF-8 slash paths in byte order, Deflate `BestCompression`, 1980 epoch, every entry `0644` | +| Embedded runtime payload ZIP | At most `10,000` entries including the top-level manifest; `512 MiB` per entry; `4 GiB` total; `8 GiB` archive; `1 MiB` payload manifest | Entry-name order, `Store`, 1980 epoch, executables `0755` and others `0644`; full payload and manifest SHA-256 | + +The untrusted-archive verifier used for runtime/driver ZIPs and the embedded payload +rejects absolute/`..`/backslash traversal, NUL, invalid UTF-8, duplicates, +Unicode-normalization or case-fold collisions, file-as-parent layouts, overlapping data +ranges, encrypted entries, and symlink/device/special entries. The project ZIP is a +constrained producer rather than a consumer of arbitrary external ZIPs: it applies the +corresponding portable-path, collision, regular non-symlink file, and size rules to its +allowlisted inputs before packaging. Multi-stage project/payload snapshots reject +changes at boundaries that perform double-read or identity checks. The driver-release +packager instead binds the bytes read from each opened regular file by exact size and +SHA-256, and later acquisition must match those digests. The complete project ZIP is +embedded with `Store` as `project/project.zip` without rewriting its canonical bytes. + +### Three-repository integration and release validation + +Local integration must use one workspace so the shared codec, XGo dispatcher, and SPX +driver do not resolve an older module-cache version. Recommended flow (`CODE` is the +common parent directory): + +```sh +integ=$(mktemp -d) +(cd "$integ" && GOWORK=off go work init \ + "$CODE/mod" "$CODE/xgo" "$CODE/spx") + +(cd "$CODE/mod" && GOWORK="$integ/go.work" \ + go test ./driverprotocol ./modfile ./modload ./xgomod) +(cd "$CODE/xgo" && GOWORK="$integ/go.work" \ + go test ./cmd/internal/projectdriver) +(cd "$CODE/spx" && GOWORK="$integ/go.work" \ + go test ./internal/driverbundle ./internal/envutil \ + ./internal/xgodriver ./internal/launchpack ./cmd/xgodriver) +``` + +The integration workspace is read-only for repository metadata; do not run `go work +sync` and commit the resulting `go.mod/go.sum` rewrites. The CLI smoke test builds a +temporary XGo from the same workspace, then runs a real SPX fixture, builds it, and +runs the standalone launcher. An uncommitted dirty SPX source checkout adds `+dirty` +to Go build info and is correctly rejected by source-identity validation; such local +integration must pass `-buildvcs=false` explicitly rather than weakening provenance: + +```sh +(cd "$CODE/xgo" && GOWORK="$integ/go.work" \ + go build -o "$integ/xgo" ./cmd/xgo) +(cd "$CODE/spx" && GOWORK="$integ/go.work" SPX_RUNTIME_OFFLINE=1 \ + "$integ/xgo" run -buildvcs=false ./test/CI --headless) +(cd "$CODE/spx" && GOWORK="$integ/go.work" SPX_RUNTIME_OFFLINE=1 \ + "$integ/xgo" build -buildvcs=false -o "$integ/spx-ci" ./test/CI) +"$integ/spx-ci" --headless +``` + +`xgo run` and the standalone launcher must each print `SPX_CI_TEST_OK`; `xgo build` +must succeed and produce a non-empty host executable. Full acceptance also covers +legacy behavior for ordinary projects, source mode for main/workspace/local replace, +published-bundle cache miss/hit/offline/concurrency/kill-recovery, argv/stdin/signals, +atomic build/install, same-size tampering, and real Darwin/Linux/Windows host artifacts. +Publication is ordered runtime -> `driver-v` bundle -> canonical SPX module; +the module tag must not be published while either prerequisite is incomplete. diff --git a/docs/zh/dev/engine/release.md b/docs/zh/dev/engine/release.md index 048a7f2ba..087e89594 100644 --- a/docs/zh/dev/engine/release.md +++ b/docs/zh/dev/engine/release.md @@ -10,20 +10,20 @@ go run ./.github/scripts/runtime/version.go --json 外置模块首个原子版本为 SPX `v3.2.0`、runtime `2.4.0`(tag `runtime-v2.4.0`)、runtime ABI `2`。历史 `v3.1.0 -> Godot spx2.3.0` 映射保持 legacy,不得移动或重新解释旧 tag。 -## 发布身份边界 +## 发布版本与完整性边界 -| 身份/产物 | 内容或复用输入 | +| 产物 | 构建或校验输入 | | --- | --- | | Godot engine/editor/template | Godot commit、`godot_modules/spx` tree(含 SCons profile)、引擎工具链、平台参数 | | `spx-runtime-assets.zip` | SPX runtime pack source、固定的 pack build recipe、生成 pack 所用的锁定 Godot 引擎 | -| 完整 runtime release | canonical `runtime.lock.json` SHA、module tree、pack source、build recipe、全部资产 checksum | +| 完整 runtime release | `runtime-v`、同版本 manifest、required asset 集合与全部资产 checksum | | SPX 产品包 | SPX release commit、选定的原子 runtime、各平台打包流程 | SPX 与 Godot Actions 都调用所选 SPX commit 中的 `.github/scripts/runtime_build_contract.py` 校验 lock 与 SCons profile。引擎 cache 的工具链摘要按平台收敛:native 只包含 SCons,Web 额外包含 EMSDK,Android 额外包含 JDK 与 NDK。未知的 NDK installer alias 只会阻断 Android 构建,不会误伤其他平台。 -manifest 分别记录 `module_tree`、`runtime_pack_source_sha256` 和 `build_recipe_sha256`。完整 lock SHA 也是 runtime release 的复用契约,因此 ABI、required assets、repository/manifest、Godot ref/version/commit、module path 或任一工具链字段变化都会拒绝复用旧 runtime。Godot SCons cache 使用更窄的独立身份;只改版本号、release 元数据、资产清单或文档不会重新编译 Godot,但可能要求新的 runtime release 身份。文档本身不进入这两类 runtime digest。 +manifest 记录 `module_tree`、`runtime_pack_source_sha256` 和 `build_recipe_sha256`,用于追踪构建来源和诊断问题;这些字段不参与已发布 runtime 的复用判定。复用只要求 release tag 为 `runtime-v<所选版本>`,且 manifest 的 `runtime_version` 等于 lock 选择的版本。若源码、ABI、工具链或资产契约发生了不兼容变化,发布者必须先提升 `runtime_version`。Godot SCons cache 使用独立的构建输入摘要;文档不进入 runtime 或 cache digest。 -module tree 仍严格覆盖完整的 `godot_modules/spx` tree。pack-source 摘要只跟踪 desktop `runtime export-pack` 的输入,并按固定的 Linux/amd64、CGO、无额外 tag 的 pack 构建目标解析 Go build constraints;`export_presets.cfg` 只投影 Linux preset,`gdspx.gdextension` 等实际进入 PCK 的文件仍完整计算。独立的 `run`、`buildlauncher`、Web/mobile exporter、平台模板和 release 编排不进入摘要。build-recipe 摘要只跟踪专用的本地 Linux 引擎准备与 export-pack 路径;其他平台分发、远程下载、本地 manifest 发布和 CI 运输层也不进入摘要。任一已选输入变化时仍会拒绝复用旧 runtime。 +module tree 仍严格覆盖完整的 `godot_modules/spx` tree。pack-source 摘要只跟踪 desktop `runtime export-pack` 的输入,并按固定的 Linux/amd64、CGO、无额外 tag 的 pack 构建目标解析 Go build constraints;`export_presets.cfg` 只投影 Linux preset,`gdspx.gdextension` 等实际进入 PCK 的文件仍完整计算。独立的 `run`、`buildlauncher`、Web/mobile exporter、平台模板和 release 编排不进入摘要。build-recipe 摘要只跟踪专用的本地 Linux 引擎准备与 export-pack 路径;其他平台分发、远程下载、本地 manifest 发布和 CI 运输层也不进入摘要。这些摘要帮助定位构建差异,但不会替代显式版本升级。 ## 冻结顺序 @@ -44,7 +44,7 @@ module tree 仍严格覆盖完整的 `godot_modules/spx` tree。pack-source 摘 ``` 普通 merge 会保留 candidate 的祖先关系;squash 或 rebase 会改变源码身份,此时运行 `make pin-godot-unpublished GODOT_SHA=` 固定最终 commit 并重跑 dry-run,不能发布旧 candidate SHA。 -5. ancestry verifier 成功后才能发布新的 runtime。resolver 判定必须构建 runtime 时,两种 publish 操作都会先在 release setup 中运行同一个严格 verifier,再启动 Godot/runtime 构建;长构建结束后,`publish-runtime` 还会在创建或上传 release 前立即复验。已经完整校验的公开 runtime 属于不可变复用,不要求其历史 source ref 永久存在。 +5. ancestry verifier 成功后才能发布新的 runtime。仅当所选 `runtime-v` 尚未公开时,发布流程才会在构建前运行 verifier,并在创建或上传 release 前立即复验。相同 runtime version 的公开 release 可直接复用,不要求其历史 source ref 永久存在。 不要从 fork 发布。`publish-runtime` 与 `publish-release` 操作只允许在 lock 的 `release_repository`(当前为 `goplus/spx`)执行。 @@ -65,37 +65,38 @@ 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。 +普通 CI 会在启动 runtime consumer 前解析 lock 对应的 runtime release。resolver 只读取 release metadata 与 manifest,不下载全部 runtime 资产;它会确认 tag 为 `runtime-v<所选版本>`、manifest 的 `runtime_version` 等于 lock 选择的版本,并校验 required asset 名称集合。 - runtime 不存在或仍为 draft 时,CI 跳过基于已发布 runtime 的 Web 产品 smoke,改为把当前 SPX module 放入 lock 的 Godot source,执行 Linux SPX tests 与 Web normal compile smoke。 -- runtime 已公开且与当前身份完全一致时,下一次 CI 会跳过 source integration 重编译,强制执行使用已发布资产的 Web normal 产品 smoke。 -- 已公开 release 缺 manifest、资产集合或 provenance 不一致,以及 GitHub API 异常,都不属于前两种状态;resolver 与 CI gate 会 fail closed。 +- runtime 已公开且版本一致时,下一次 CI 会跳过 source integration 重编译,强制执行使用已发布资产的 Web normal 产品 smoke。 +- 已公开 release 缺 manifest、版本不一致、资产集合不完整或 GitHub API 异常,都不属于前两种状态;resolver 与 CI gate 会 fail closed。 这个切换同时避免发布循环与 runtime 重复构建。普通 CI 不构建完整 release runtime;只有 release workflow 执行该构建。release assemble 在复用或发布前仍会下载全部资产,并校验 `SHA256SUMS` 与 manifest 中每个文件的 checksum。 -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。 +canonical-ref ancestry 规则刻意只用于构建和发布新的 runtime。普通 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 才会开始新的 runtime 构建。ref 查询失败、ref 歧义、网络异常或比较失败会阻断新 runtime 发布。若相同 runtime version 已公开且 manifest 版本匹配,summary 会标记 source ancestry 无需检查。 -三阶段自举仍在 `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、driver,并发布 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`:相同 tag 的公开 release 只要 manifest 中的 runtime version 一致即可复用;不存在时则构建、校验并发布完整 runtime。 +3. runtime 就绪后,同一次运行自动调用 reusable driver workflow。driver release 固定为 `driver-v`;公开 release 的 manifest 中,`spx_version` 必须等于所选 SPX version,`runtime_version` 必须等于当前 lock 选择的 runtime version,否则失败。不存在时,四个原生 host 复用已就绪的 Engine/PCK、从当前 SPX 源码构建 bridge,并发布 manifest 与四个平台 ZIP。 +4. driver 就绪后,流程继续构建并发布 SPX 产品与 npm,不需要中途生成文件、提交额外 commit 或重新触发 workflow。只升级 SPX、复用已有 runtime 时不会重新构建 Godot Engine,但仍会为新的 SPX version 生成对应 driver release。 -runtime manifest、`SHA256SUMS` 和 lock 的 required asset 集合必须完全一致;已公开 tag 的来源或资产不同会直接失败,不能覆盖。未公开的 runtime/SPX draft tag 必须指向当前 `GITHUB_SHA`;已公开 runtime 可来自前一阶段的 candidate commit,但只有完整复用契约一致时才能用于最终 SPX commit。SPX tag 始终指向最终 commit。如果合并修改了任一 runtime 身份输入,最终运行会拒绝复用,此时必须重新冻结并提升 `runtime_version`。 +runtime manifest、`SHA256SUMS` 和 required asset 集合必须完全一致;driver manifest 与每个平台 ZIP 也必须通过名称、host、entry、大小和 SHA-256 校验。runtime 复用只比较 `runtime_version`;driver 复用只比较 `spx_version` 与 `runtime_version`。checksum 与 ZIP 校验只保证下载内容完整,不形成第二套发布身份。未公开的 runtime、driver 和 SPX draft 仍必须由当前运行创建;SPX tag 始终指向最终发布 commit。若 runtime 内容发生不兼容变化,必须先提升 `runtime_version`,不能覆盖已有 tag。 ## 开发版 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 \ @@ -110,7 +111,7 @@ gh workflow run release.yml \ ## 后续版本维护 -- 仅 SPX 产品变化且 runtime 两类产物均未变化时,可以用 SPX-only mapping 保留 current runtime,但必须先由 release dry-run 证明公开 runtime 的完整 provenance 可复用;`bump-release` 不会在本地擅自做这个复用决定。 +- 仅 SPX 产品变化且 runtime 版本不变时,可以用 SPX-only mapping 保留 current runtime;发布流程会按 runtime version 复用公开资产,并为新的 SPX version 发布对应 driver。 - Godot、`godot_modules/spx`、toolchain 或 runtime pack 输出变化时,用一个事务同时提升两套身份: ```sh @@ -118,7 +119,7 @@ gh workflow run release.yml \ ``` 只有 runtime ABI 本身变化时才额外传 `RUNTIME_ABI=N`。该命令会先通过已认证的 `gh` API 只读检查确认两项 current release 已公开、两项目标 release/tag 均未占用,再归档上一条 SPX mapping、推进 current lock、创建新的不可变 snapshot,并执行 release metadata 测试;它不会发布,也不会修改 Godot pin。current pair 仍是未发布 candidate 时,应继续使用原版本,不能把它归档成发布历史。 -- 每个原子 runtime definition 都必须有不可变的 `internal/release/runtime_locks/.json` 快照;校验历史 manifest 时读取对应快照,不能读取更新后的默认 lock。 +- 每个原子 runtime definition 都必须有不可变的 `internal/release/runtime_locks/.json` 快照;历史版本根据该快照恢复构建配置、平台资产名称和 SPX/runtime 映射,不能读取更新后的默认 lock。 - 新 runtime version 创建后,严格固定使用 `make pin-godot GODOT_SHA=`;commit 已进入 lock ref 且需要替换未发布 snapshot 时使用 `make pin-godot-unpublished GODOT_SHA=`;合并前 dry-run candidate 使用 `make pin-godot-candidate GODOT_SHA=`。三者默认保留 current lock ref,只有传入 `GODOT_REF=...` 才会切换,并且只根据 current lock 推导 snapshot 文件名,不会触碰其他历史版本。 - 项目 `go.mod` scaffold 会自动渲染当前声明的 SPX 版本。`internal/cmd/codegen/go.mod` 中的 `v3.0.0` 只是本地 `replace` 所需的 major-version floor,发布时不要修改这两个文件。 - runtime 一旦公开,其 snapshot 永久冻结。公开 tag 绝不能使用 `sync --unpublished`、`pin-godot-unpublished` 或 `pin-godot-candidate`;必须通过 `make bump-release` 使用新的 runtime version。release setup 会运行 `check`,package 初始化、drift 与 catalog 测试也会共同校验 current mapping。 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..659c1ddb9 --- /dev/null +++ b/docs/zh/dev/engine/xgo-project-driver-proposal-issue.md @@ -0,0 +1,631 @@ +# [Proposal] XGo Project Driver v1 与 SPX 运行时集成 + +> 状态:source mode 与 published bundle 路径已实现;published driver 由 exact SPX module version 直接选择,并在使用前校验 SPX/runtime 版本与资产完整性 +> +> 范围:`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 的 exact version 直接选择 `driver-v`,每个 host ZIP 恰好包含 Engine、PCK 和 interpreter bridge。该 ZIP 是独立的 driver release artifact,不是现有 standalone Engine/PCK runtime ZIP。manifest 的 `spx_version` 必须与所选 module version 一致,`runtime_version` 必须与当前 module lock 一致;bundle 仍校验大小与 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 则使用 exact module version 对应的 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 所选的 exact module version 与 driver manifest 的同值 `spx_version` | +| Engine Runtime/ABI | Engine、PCK 与接口兼容性 | SPX runtime lock 选择的 runtime version,以及 runtime/driver manifest 的同值 `runtime_version` | + +`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 的源码身份。 + +除 `pkg@version` 的隔离分类探针外,同一份受支持 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` | 在 `GOWORK=off`、`-mod=mod` 的临时隔离 graph 中只分类所请求版本;未匹配 driver 时走 legacy,匹配后明确拒绝执行 | + +一个目录必须恰好对应一个 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` | + +CLI 显式默认值 `-trimpath=false` 与 `-buildvcs=auto` 等价于省略,XGo 接受后将其 +归一化掉,不写入 protocol;其他有语义或未知 flag 仍在 driver match 后拒绝。 + +其他 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 version 推导 `driver-v` release URL,下载 `driver-manifest.json` 和独立的 driver release host ZIP,而不是现有 standalone runtime ZIP。manifest 的 `spx_version` 必须等于所选 module version,`runtime_version` 必须等于当前 module lock 选择的 runtime version;manifest 与 ZIP 仍严格校验 schema、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 优先;否则先获取 lock 所选的 `runtime-v` release,并要求 manifest 中的 runtime version 一致;只有发布资源不可用时才使用 exact-version 的 source/GOPATH local runtime; +2. published mode:exact SPX module version 选择包含三个组件的 `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 格式错误或 version、size、digest、host 不匹配时,两种 mode 都会在使用任何 runtime 资源前 fail closed。 + +published mode 不使用 `$GOPATH/bin` 兜底。发布身份由所选 module/lock version 与 manifest 中对应的版本声明确定,文件名、存在性或大小本身不足以证明内容完整。无本地产物时,干净 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 目录内写入,并必须在指定 staging path 产生目标。 +返回成功后,XGo 只验证并提交该目标;其他临时或诊断文件不参与提交,并随 staging +目录清理。目标必须是非空、非 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 只接受 canonical SPX exact release module 以及 `spx_version`、`runtime_version` 分别匹配 module 与 lock 的 `driver-v` host bundle;缺少 release、manifest 版本不一致或发布输入无效时必须 fail closed; +- 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 支持是前置条件。正式发布在一次 `publish-release` 中按 runtime→driver→SPX 顺序推进,使 driver bundle 不与 `runtime_version` 绑定: + +1. **Runtime 阶段**:解析 lock 选择的 `runtime-v`。同版本公开 release 可直接复用;不存在时构建、校验并发布完整 runtime 资产。 +2. **Driver bundle 阶段**:解析 `driver-v`。manifest 的 `spx_version`、`runtime_version` 分别匹配所选 SPX 与 runtime version 时可直接复用公开 release;不存在时从 exact SPX release source 为每个支持的 host 构建 ZIP。每个 ZIP 必须恰好包含 Engine、PCK 和 bridge,并冻结名称、大小和 SHA-256。 +3. **Module 阶段**:driver 就绪后以 exact release tag/version 发布 canonical SPX module。发布前验证 exact-module 下载、manifest/ZIP 严格校验、cache/offline、source mode 与自包含 launcher。 + +统一的 `publish-release` 状态机自动执行三个阶段,不单独手工发布 driver,也不在阶段之间停下来生成文件或要求额外 commit。runtime 复用只比较 lock 选择的 `runtime_version`;driver 复用只比较所选 `spx_version` 与 `runtime_version`。每个阶段仍严格校验 manifest、资产集合、checksum 和 ZIP 内容,这些完整性检查不形成另一套发布身份。 + +driver bundle 的 URL 与身份只来自 exact SPX module version 和 `driver-v`,绝不从 `runtime_version` 推导;manifest 的 runtime version 只确认 bundle 使用当前 lock 对应的 Engine/PCK。对应版本的 release 尚未齐备或 manifest 版本不一致时,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 能按 exact module version 下载 `driver-manifest.json` 与 host ZIP,要求其中 SPX/runtime version 分别匹配 module 与 lock,并拒绝 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。 + +## v1 规范性合同 + +本节是 `goplus/mod/driverprotocol`、XGo dispatcher 和 SPX `xgodriver` +之间的可执行合同。实现或测试新增字段时,必须先更新本节,再同步三仓代码; +未知字段不得被“尽量忽略”。 + +### Metadata 与能力协商 + +`gox.mod`/`gop.mod` parser 接受语法正确的 `driver vN `,其中 `N >= 1`, +并把 protocol 原值保留在 resolved metadata 中;parser 不替 dispatcher 推断能力。 +XGo v1 dispatcher 只执行 `v1`。一旦 target 的 project metadata 存在任何 `driver` +声明,它就是 driver match:`v2` 或更高版本必须返回 `unsupported driver protocol` +之类的终止错误,不能伪装成 `NotHandled` 后进入 GenGo。未来 dispatcher 只有在同时 +实现对应 argv、进程和事务合同后才能声明新 protocol capability;提高 declaring +module 的 `xgo` directive 只能提高 XGo 最低版本,不能把未知 protocol 降级成 v1。 + +`driver` 归属于它前面最近的 `project`,每个 project 最多一个;protocol 必须匹配 +`v[1-9][0-9]*`,package 必须通过 Go import-path 校验。已知 directive 的参数个数、 +protocol 或 package 格式错误在 strict/lax metadata 解析中都属于错误。 + +### 调用帧与字段 + +driver 可执行文件的参数帧固定以 `xgo-driver-v1` 开始,随后是一个 action: + +```text +xgo-driver-v1 run * * -- * +xgo-driver-v1 build * * --output= --final-output= +``` + +所有 option 必须是单个 `--name=value` 参数。`run` 必须有且只有一个协议分隔符: +第一个 `--`;它之后的参数按元素原样传递,因此后续值为 `--` 的参数只是普通应用 +参数。`build` 不允许协议分隔符或应用参数。重复的单值字段、未知字段、缺失字段、 +半组 replacement 字段和 action 不适用的字段都必须在 driver 启动前拒绝。协议不使用 +request 文件,也不占用 stdin。 + +| 字段 | 约束 | +| --- | --- | +| `project-dir` / `project-file` | shared 层要求绝对 clean path,且 project file 在字符串结构上是目录顶层文件 | +| `module-root` | shared 层要求绝对 clean path,并在路径结构上包含 `project-dir` | +| `project-ext` / `project-full-ext` | 非空且不含 NUL;来自 XGo 的目标解析结果 | +| `driver-package` | 合法 Go import path,且位于 `selected-path` 模块内 | +| `selected-path` / `selected-version` | MVS 逻辑选择;main module 的 version 为空,其他 module 必须有 canonical version | +| `origin-main` | 只能是 `true` 或 `false` | +| `selected-dir` / `selected-gomod` | 无 replacement 时必须成组出现;只在结构层表达 selected source | +| `replace-path` / `replace-version` / `replace-dir` / `replace-gomod` | 有 replacement 时必须四项齐全;此时禁止 `selected-dir`/`selected-gomod` | +| `declaration-file` / `declaration-sha256` | declaring module 的 `gox.mod` 或 `gop.mod` 及其小写 SHA-256 | +| `go-command` / `graph-work-dir` / `go-work` | graph 使用的 Go 可执行文件、工作目录和 workspace;`go-work=off` 表示禁用 workspace | +| `graph-flag` | shared codec 识别 `-mod=mod|readonly|vendor`、`-modfile=`、`-overlay=`;后两种特殊策略由 consumer 决定是否执行 | +| `build-flag` | 只允许 `-v=true`、`-x=true`、`-work=true`、`-trimpath=true`、`-buildvcs=false` | +| `pack-dir` / `pack-index` | 可选但必须成组;目录是 project root 下的 clean relative path,index 是普通文件名 | +| `output` / `final-output` | 仅 build 使用,绝对且不同;前者是 XGo 私有 staging,后者是用户目标 | + +`ResolvedModule` 的身份比较同时包含 `Main`、selected path/version/源信息和 +replacement 信息。local replacement 的 `replace-path` 必须是 clean absolute +directory spelling;versioned replacement 仍须保留 module path/version,但 SPX +published policy 会拒绝它。shared codec 只做不读取文件系统的结构校验:绝对/clean +path、lexical containment、字段组合、import/module version 和 digest 形状。XGo 在 +discovery 时解析真实路径并固定来源;SPX consumer 收到后必须重新执行 `Lstat`、 +`EvalSymlinks`、路径相等性、普通文件/目录/可执行类型、containment、`SameFile`(读取 +跨越时)和内容摘要校验。因而“通过 shared validation”不表示文件存在,也不表示 +路径不是 symlink。 + +XGo 必须在启动 driver 前检查 argv/environment 预算。Unix 将 executable、每个 argv +和 env 字符串的 NUL、以及每个 argv/env/native pointer 按 64-bit pointer 计入同一个 +`128 KiB` 上限。Windows command line 使用 UTF-16 保守引用上界并限制为 `30,000` +code units,environment block(含终止 NUL)限制为 `32,767` UTF-16 code units。 +任一预算超限统一返回 `ErrDriverArgvTooLarge`,不得截断参数或改用未定义的 request file。 + +### Graph、target 与 flag policy + +对普通 directory/file/package target,XGo 只快照一次 ambient `GOFLAGS` 和 `GOWORK`。 +它先读取并解析 `GOFLAGS`,再在 `GOFLAGS=` 下查询 `GOWORK`;所有受支持 graph flag +随后都作为独立 argv 传给 Go command,子进程环境固定 `GOFLAGS=`,`GOWORK` 固定为 +canonical go.work path 或 `off`。这条规则适用于 discovery、driver package 校验、 +driver build、SPX provenance、source bridge 与 launcher build,避免 ambient flag +在任一阶段改变 graph。 + +三类需要分类后拒绝的输入必须区分: + +| 输入 | discovery/classification | driver match 后 | +| --- | --- | --- | +| `pkg@version` | 在新临时 module 中使用 `GOWORK=off`、`GOFLAGS=-mod=mod` 下载并解析所请求版本及其 class graph;不得复用 caller graph 的 metadata | 明确报 v1 不支持 `@version`;探针只分类,不执行 driver;未匹配时返回 `NotHandled` | +| `-mod=vendor` | 仅 active main/workspace module metadata 可作为权威来源;无 external class marker 的普通 target 可返回 `NotHandled`;external class metadata 无法由 vendor snapshot 证明时在分类前 fail closed | 任一 driver match 明确报 vendor unsupported;shared codec 接受该值仅用于忠实表达/防御性拒绝 | +| `-overlay` | 使用 overlay view 只判断 target/metadata 是否声明 driver,ordinary target 保持 legacy 行为 | 因 v1 snapshot 只消费 physical filesystem,明确报 overlay unsupported;不得把 overlay 内容交给 driver | + +directory 必须只含一个匹配 project file;single-file 必须就是该唯一文件。multi-file 和 +包含 `...` 的 pattern 只有在其中存在 driver-backed project 时才报不支持,否则仍由 +legacy 路径处理。所有 graph probe 都服从 context cancellation,临时 graph 必须清理。 + +### 调度状态机 + +XGo 的 dispatcher 只有以下两条合法路径: + +```text +target -> graph -> class metadata -> driver match + |-- NotHandled -> legacy GenGo + `-- matched -> validate -> build driver -> invoke +``` + +`NotHandled` 是唯一允许回到 legacy 的结果。匹配后发生的 graph、metadata、 +version、protocol、driver build、driver exit 或 asset 错误都必须原样终止,不能 +再次尝试 GenGo。`XGO_DRIVER=off` 也属于显式错误,不是 fallback。driver 的 +`SPX_XGO_DRIVER_ACTIVE` guard 禁止递归 dispatch(包括转发到另一个 driver)。 + +`run` 的顺序是解析、构建临时 driver、编码 argv、启动 driver;stdin/stdout/stderr +直接继承。`build` 和 `install` 先解析所有目标,再创建 staging 或 install 目录; +driver 必须在指定 staging path 产生一个非空、非符号链接的 host executable,目录内 +其他文件不参与提交并随私有目录清理。XGo 在 +同一 filesystem 上完成最后一次身份检查和 rename 后才提交目标;提交前的任何 +失败都不得改变已有输出。`-work=true` 只保留诊断目录,不改变提交语义。 + +进程边界只有宿主 dispatcher 持有信号订阅,driver/Engine 内部 supervisor 只消费 +取消及其 cause。取消一旦被观察,即使子进程在清理时返回 0,也不能报告成功;Unix +保留正常退出码或信号,Windows 使用 Job Object 管理进程树并把中断映射为 130。 + +`cmd/xgodriver` 的退出合同是:argv/protocol parse 或 live request validation 失败返回 +code `2`;acquisition、graph、build、packaging 或其他执行错误在没有更具体 child status +时返回 code `1`;Engine 的正常非零 code 原样返回。Unix 的 signal status 由 wrapper +对自身重发原始 signal(若重发失败才使用 `128+signal`);Windows 没有 POSIX signal +status,宿主中断返回 `130`。stderr 的命令错误只带一个 `xgodriver: ` 前缀。 + +### XGo 版本判定 + +声明模块的 `xgo` directive 与 `driver v1` 协议基线独立取最大值。当前基线是 +`1.8.0`。从源码或 workspace 直接构建的 XGo,Go build info 可能显示标准 +pseudo-version(例如 `v1.2.0-pre.1.0.20260821130422-831eec0b6b4e`);这类 +版本表示开发构建,按 driver capability `1.8.0` 比较,但错误信息仍显示原始 +版本和 capability。真正的 release prerelease(如 `v1.8.0-rc1`)不自动视为开发 +构建。该规则只适用于 XGo 自身能力检查,不改变 SPX published mode 对 pseudo +SPX module version 的拒绝。 + +### SPX mode 与环境输入 + +SPX 只在以下身份使用 source mode:main module、当前 workspace module、或无版本 +local replacement。其他 canonical `github.com/goplus/spx/v3@vX.Y.Z` 且无 +replacement 的 graph 使用 published mode;pseudo/versioned replacement/foreign +module 必须 fail closed。 + +source bridge 的 build info 必须把 effective SPX module 记为 main module;生成的 +launcher 必须把它记为 dependency。对 source/workspace 中的 main module,Go 生成的 +main build-info version(包括标准 pseudo-version)只用于诊断,不参与身份比较;其身份 +由 module path 与已校验的 graph/source snapshots 固定。launcher 中该 workspace +dependency 的 version 必须为空或 `(devel)`,避免误连 versioned SPX source。 + +source mode 的运行时输入优先级固定为:显式 local runtime manifest -> 已校验 +runtime release/cache(或 `SPX_RUNTIME_ASSET_DIR` mirror)-> 在线获取(offline 时跳过) +-> release 确实不可用时的 exact-version source/GOPATH runtime fallback。bridge package +固定来自 effective SPX source,不能由环境改成其他 package,并以 host +`CGO_ENABLED=1` 构建。 + +published mode 只接受 combined driver bundle。programmatic `launchpack.Config` 中的 +runtime source root、runtime manifest path、runtime asset directory 或 source bridge +package 与该模式冲突,必须拒绝。继承的 `SPX_RUNTIME_LOCAL_MANIFEST` 与 +`SPX_RUNTIME_ASSET_DIR` 在 published mode 中均忽略,既不参与选择,也不因存在或重复而 +报错。本机/GOPATH bridge 同样永不参与 published selection,但无需为无关的 +ambient 环境变量额外报错。唯一允许的本地发布镜像是 `SPX_DRIVER_ASSET_DIR`,且必须 +同时提供并通过 exact manifest/bundle 校验;显式 mirror 缺失或损坏时必须失败,不能 +被 warm cache 或网络隐藏。 + +相关环境变量如下: + +| 变量 | 语义 | +| --- | --- | +| `SPX_RUNTIME_LOCAL_MANIFEST` | source mode 指定并严格校验 local runtime manifest;失败不回退;published mode 忽略且不参与选择 | +| `SPX_RUNTIME_ASSET_DIR` | source mode 指定 runtime release mirror;内容和 manifest 必须匹配;published mode 忽略且不参与选择 | +| `SPX_DRIVER_ASSET_DIR` | published mode 的 `driver-manifest.json` 与 host ZIP mirror,必须是 absolute clean path;source mode 不把它当 runtime 输入 | +| `SPX_RUNTIME_CACHE` | 绝对 clean 的 content-addressed cache 根目录 | +| `SPX_RUNTIME_OFFLINE` | `1/true/yes/on` 时禁止网络,只接受完整校验的 cache/local hit | +| `GOWORK` / `GOFLAGS` | `GOWORK` 由 XGo 固定为 canonical path/`off`,Go 子进程固定 `GOFLAGS=`;graph/build flags 只走 argv | + +`ProjectDir`、`AssetDir`、`SessionDir` 三根目录职责不能互换;用户参数不能覆盖 +driver 写入的 `SessionDir --path`。`.config`、pack index、module metadata 和 +release manifest 都按“快照后再校验”的规则消费,项目源目录在 run/build 成功或 +失败后都必须保持不变。 + +SPX graph verifier 对 `go list -m all` 的完整输出做 SHA-256,并快照 effective active +modfile(默认 `go.mod` 或显式 `-modfile`)及其 matching sum、`go.work/go.work.sum`、 +以及 effective local SPX `go.mod` 的存在状态和内容摘要。source bridge build 与 +launcher build 都在 +关键命令前后重做选择和文件快照;selection 或任一 required/optional graph file 的 +内容、出现/消失或身份变化都终止。用于 graph/provenance/launcher 的 host Go 环境固定 +host `GOOS/GOARCH`、`CGO_ENABLED=0`、`GOFLAGS=` 与 request 的 `GOWORK`;source bridge +额外移除继承的 `CGO_*` 后固定 `CGO_ENABLED=1`。Engine 进程不继承这些 Go graph/target +变量。 + +### Published driver manifest 与 host ZIP + +`driver-manifest.json` 最大 `16 MiB`,使用 strict JSON:未知字段、重复 key、尾随值、 +错误类型都拒绝。完整 schema 如下;数组顺序属于 v1 合同: + +```json +{ + "schema": 1, + "spx_version": "vX.Y.Z", + "runtime_version": "R", + "bundles": [ + { + "goos": "darwin", + "goarch": "amd64", + "name": "spx-driver-darwin-amd64.zip", + "size": 1, + "sha256": "<64 lowercase hex>", + "engine_interface_digest": "<64 lowercase hex>", + "files": [ + {"name": "gdspxrtR", "mode": 493, "size": 1, "sha256": "<64 lowercase hex>"}, + {"name": "gdspxrtR.pck", "mode": 420, "size": 1, "sha256": "<64 lowercase hex>"}, + {"name": "gdspx-darwin-amd64.dylib", "mode": 493, "size": 1, "sha256": "<64 lowercase hex>"} + ] + } + ] +} +``` + +实际 `bundles` 必须恰好四项并按以下顺序出现;上例单项只是字段示意: + +| 顺序 | target | ZIP | files,严格按 Engine/PCK/bridge 顺序 | +| --- | --- | --- | --- | +| 1 | `darwin/amd64` | `spx-driver-darwin-amd64.zip` | `gdspxrtR` `0755`; `gdspxrtR.pck` `0644`; `gdspx-darwin-amd64.dylib` `0755` | +| 2 | `darwin/arm64` | `spx-driver-darwin-arm64.zip` | `gdspxrtR` `0755`; `gdspxrtR.pck` `0644`; `gdspx-darwin-arm64.dylib` `0755` | +| 3 | `linux/amd64` | `spx-driver-linux-amd64.zip` | `gdspxrtR` `0755`; `gdspxrtR.pck` `0644`; `gdspx-linux-amd64.so` `0755` | +| 4 | `windows/amd64` | `spx-driver-windows-amd64.zip` | `gdspxrtR.exe` `0755`; `gdspxrtR.pck` `0644`; `gdspx-windows-amd64.dll` `0755` | + +其中 `R` 是不带前导 `v` 的 `runtime_version`。所有 size 必须为正数并与实际字节数 +相等;bundle SHA-256 覆盖整个 ZIP,file SHA-256 覆盖解压后的文件字节。 +`engine_interface_digest = SHA256(ASCII("spx-engine-interface/v1") || 0x00 || +hexDecode(engine.sha256) || hexDecode(pck.sha256))`。`spx_version` 必须等于 graph 选择的 +exact module version,`runtime_version` 必须等于 SPX runtime lock;release tag 和 URL +只由 `driver-` 决定。 + +release packager 必须按 Engine/PCK/bridge 顺序写恰好三个 regular entry,使用 ZIP +`Store`、UTC `1980-01-01T00:00:00Z`、上表 mode、portable basename,不写 directory、 +extra、duplicate 或 symlink entry。相同三个输入必须产生相同 ZIP bytes。consumer 还 +按 manifest 的 exact archive size/SHA-256 和逐文件 name/mode/size/SHA-256 校验,不得 +仅凭文件名或 interface digest 建立信任。 + +### Archive 与 payload 限额 + +所有限额在读取/解压或启动 launcher build 前 fail closed;不能通过压缩、ZIP64 或 +manifest 声明绕过: + +| 对象 | v1 限额 | 确定性参数 | +| --- | --- | --- | +| runtime/driver ZIP verifier | 最多 `10,000` entries;单 entry `512 MiB`;解压总计 `4 GiB`;archive `8 GiB`;compression ratio `200:1`。driver ZIP 另要求恰好 3 files | release driver ZIP 为 `Store`、1980 epoch、canonical mode/order;manifest 固定完整 archive/file digests | +| canonical project ZIP | 最多 `10,000` files;单文件 `64 MiB`;输入总计 `256 MiB`;archive `512 MiB` | UTF-8 slash path 字节序排序,Deflate `BestCompression`,1980 epoch,所有 entry `0644` | +| embedded runtime payload ZIP | 含 top-level manifest 少于等于 `10,000` entries;单 entry `512 MiB`;总计 `4 GiB`;archive `8 GiB`;payload manifest `1 MiB` | entry name 排序,`Store`,1980 epoch,可执行 `0755`、其他 `0644`;payload 与 manifest 均固定 SHA-256 | + +runtime/driver ZIP 与 embedded payload 的 untrusted-archive verifier 拒绝 +absolute/`..`/backslash traversal、NUL、非 UTF-8、duplicate、Unicode +normalization/case-fold collision、file-as-parent、重叠 data range、encrypted entry、 +symlink/device/special entry。project ZIP 是受约束的 producer:它在打包前对 allowlist +输入施加对应的 portable-path、collision、regular non-symlink file 与大小规则,而不是 +把任意外部 ZIP 当输入。多阶段 project/payload snapshot 在实现有双读/identity check 的 +边界拒绝变化;driver release packager 则固定已打开 regular file 所读字节的 size/SHA-256, +后续 acquisition 必须与这些 digest 完全相等。project ZIP 的完整字节作为 +`project/project.zip` 以 `Store` 内嵌,不二次改写。 + +### 三仓联调与发布验证 + +本地联调必须使用同一份 workspace,确保 shared codec、XGo dispatcher 和 SPX +driver 不落回 module cache 中的旧版本。推荐流程(`CODE` 为三个仓父目录): + +```sh +integ=$(mktemp -d) +(cd "$integ" && GOWORK=off go work init \ + "$CODE/mod" "$CODE/xgo" "$CODE/spx") + +(cd "$CODE/mod" && GOWORK="$integ/go.work" \ + go test ./driverprotocol ./modfile ./modload ./xgomod) +(cd "$CODE/xgo" && GOWORK="$integ/go.work" \ + go test ./cmd/internal/projectdriver) +(cd "$CODE/spx" && GOWORK="$integ/go.work" \ + go test ./internal/driverbundle ./internal/envutil \ + ./internal/xgodriver ./internal/launchpack ./cmd/xgodriver) +``` + +联调 workspace 只用于读取;不要执行 `go work sync` 后提交被回写的各仓 +`go.mod/go.sum`。CLI smoke 必须从 XGo 仓构建同一 workspace 中的临时 `xgo`,再对 +真实 SPX fixture 执行 `run`、`build` 和独立运行 launcher。未提交的 dirty SPX source +checkout 会把 Go build info 标成 `+dirty`,source identity 校验会按设计拒绝;这类 +本地联调应显式传 `-buildvcs=false`,而不是放宽 provenance 校验。例如: + +```sh +(cd "$CODE/xgo" && GOWORK="$integ/go.work" \ + go build -o "$integ/xgo" ./cmd/xgo) +(cd "$CODE/spx" && GOWORK="$integ/go.work" SPX_RUNTIME_OFFLINE=1 \ + "$integ/xgo" run -buildvcs=false ./test/CI --headless) +(cd "$CODE/spx" && GOWORK="$integ/go.work" SPX_RUNTIME_OFFLINE=1 \ + "$integ/xgo" build -buildvcs=false -o "$integ/spx-ci" ./test/CI) +"$integ/spx-ci" --headless +``` + +`xgo run` 与独立 launcher 都必须输出 `SPX_CI_TEST_OK`,`xgo build` 必须成功产生 +非空 host executable。完整验收还必须覆盖:普通项目 legacy 不变、source mode 的 +main/workspace/local replace、published bundle 的 cache miss/hit/offline/并发/ +kill-recovery、argv/stdin/信号、原子 build/install、同大小篡改,以及 Darwin、 +Linux、Windows 的真实 host artifact。发布顺序固定为 runtime -> `driver-v` bundle -> canonical SPX module;缺少任一前置产物时不得发布 module tag。 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/download_local.go b/internal/cmd/buildctl/engine/download_local.go index 7ce86e26d..79b7d9f9e 100644 --- a/internal/cmd/buildctl/engine/download_local.go +++ b/internal/cmd/buildctl/engine/download_local.go @@ -119,11 +119,12 @@ func loadEngineAssetManifest(env *engineDownloadEnv) error { manifestPath = src } - manifest, err := release.LoadRuntimeManifest(manifestPath) + data, err := os.ReadFile(manifestPath) if err != nil { - return err + return fmt.Errorf("read runtime manifest: %w", err) } - if err := manifest.ValidateForLock(lock); err != nil { + manifest, err := release.ParseRuntimeManifestForRelease(data, lock.RuntimeVersion, lock.RequiredAssets) + if err != nil { return err } env.manifest = &manifest diff --git a/internal/cmd/buildctl/engine/download_test.go b/internal/cmd/buildctl/engine/download_test.go index 07394adfa..6b5272997 100644 --- a/internal/cmd/buildctl/engine/download_test.go +++ b/internal/cmd/buildctl/engine/download_test.go @@ -173,6 +173,44 @@ func TestLoadEngineAssetManifestKeepsNonNotFoundFailuresClosed(t *testing.T) { } } +func TestLoadEngineAssetManifestAcceptsSameVersionBuildMetadata(t *testing.T) { + lock := release.DefaultRuntimeLock() + assets := make([]release.RuntimeAsset, 0, len(lock.RequiredAssets)) + for _, name := range lock.RequiredAssets { + assets = append(assets, release.RuntimeAsset{Name: name, Size: 1, SHA256: strings.Repeat("0", 64)}) + } + manifest := release.RuntimeManifest{ + Schema: release.RuntimeManifestSchema, + RuntimeVersion: lock.RuntimeVersion, + RuntimeABI: lock.RuntimeABI + 1, + ReleaseRepository: "example/runtime", + LockSHA256: strings.Repeat("1", 64), + Provenance: release.RuntimeProvenance{ + SPXCommit: strings.Repeat("2", 40), GodotCommit: strings.Repeat("3", 40), ModuleTree: strings.Repeat("4", 40), + RuntimePackSourceSHA256: strings.Repeat("5", 64), BuildRecipeSHA256: strings.Repeat("6", 64), Toolchain: lock.Toolchain, + }, + Assets: assets, + } + data, err := manifest.JSON() + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(data) + })) + defer server.Close() + + env := engineDownloadEnv{ + version: lock.RuntimeVersion, cacheDir: t.TempDir(), urlPrefix: server.URL + "/", + } + if err := loadEngineAssetManifest(&env); err != nil { + t.Fatalf("same-version manifest rejected stale build metadata: %v", err) + } + if env.manifest == nil || env.manifest.ReleaseRepository != "example/runtime" { + t.Fatalf("loaded manifest = %#v", env.manifest) + } +} + func TestLinkOrCopyFilePrefersHardLinkWhenAvailable(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("hard link behavior varies on Windows") diff --git a/internal/cmd/buildctl/prepare_test.go b/internal/cmd/buildctl/prepare_test.go index ae7b113ab..1233deaff 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 TestPublishedRuntimeUsesLockedPack(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.Runtime || got.SkipRuntimePack { + t.Fatalf("published runtime download config = %#v", got) + } +} + func TestSetupAssetsWeb(t *testing.T) { runner := newRuntimeFixtureRunner(t) installFakeEngineDownload(t) 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..cdcf5e09c --- /dev/null +++ b/internal/driverbundle/identity.go @@ -0,0 +1,135 @@ +/* + * 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" +) + +// Target identifies one host supported by the published driver release. +type Target struct { + GOOS string + GOARCH string +} + +// Component identifies one file in a combined Engine/PCK/bridge bundle. +type Component struct { + Name string + Mode uint32 +} + +// HostSpec is the canonical platform and component naming contract shared by +// manifest validation, release packaging, and launcher materialization. +type HostSpec struct { + Target + RuntimeVersion string + BundleName string + Engine Component + Pack Component + Bridge Component +} + +// SupportedTargets returns a copy of the supported host list in release order. +func SupportedTargets() []Target { + result := make([]Target, len(supportedTargets)) + copy(result, supportedTargets[:]) + return result +} + +// HostSpecFor returns the canonical component names and modes for one host. +func HostSpecFor(runtimeVersion, goos, goarch string) (HostSpec, error) { + if err := validateRuntimeVersion(runtimeVersion); err != nil { + return HostSpec{}, err + } + for _, target := range supportedTargets { + if target.GOOS != goos || target.GOARCH != goarch { + continue + } + names := expectedFileNames(runtimeVersion, goos, goarch) + return HostSpec{ + Target: Target{GOOS: goos, GOARCH: goarch}, + RuntimeVersion: runtimeVersion, + BundleName: expectedBundleName(goos, goarch), + Engine: Component{Name: names[0], Mode: 0o755}, + Pack: Component{Name: names[1], Mode: 0o644}, + Bridge: Component{Name: names[2], Mode: 0o755}, + }, nil + } + return HostSpec{}, fmt.Errorf("unsupported driver target %s/%s", goos, goarch) +} + +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..15c30f8d0 --- /dev/null +++ b/internal/driverbundle/manifest.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 driverbundle describes published SPX project-driver bundles. +package driverbundle + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/goplus/spx/v3/internal/strictjson" +) + +const ( + ManifestSchema = 1 + ManifestName = "driver-manifest.json" + SPXModulePath = "github.com/goplus/spx/v3" + ReleaseRepository = "goplus/spx" + EngineInterfaceDigestDomain = "spx-engine-interface/v1\x00" + MaxManifestSize int64 = 16 << 20 +) + +var ErrBundleNotFound = errors.New("driverbundle: bundle not found") + +// Manifest identifies all platform bundles in one driver release. +type Manifest struct { + Schema int `json:"schema"` + SPXVersion string `json:"spx_version"` + RuntimeVersion string `json:"runtime_version"` + 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"` +} + +// 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 +} + +// ParseForVersions strictly decodes a manifest and binds it to the selected +// driver and runtime versions without repeating structural validation. +func ParseForVersions(data []byte, spxVersion, runtimeVersion string) (Manifest, error) { + manifest, err := Parse(data) + if err != nil { + return Manifest{}, err + } + if err := manifest.validateVersions(spxVersion, runtimeVersion); 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) { + return ReleaseAssetURL(m.SPXVersion, name) +} + +func (m Manifest) ReleaseTag() string { return "driver-" + m.SPXVersion } + +// ManifestURL returns the release URL selected by an exact SPX version. +func ManifestURL(spxVersion string) (string, error) { + return ReleaseAssetURL(spxVersion, ManifestName) +} + +// ReleaseAssetURL returns the canonical driver release URL for one asset. +func ReleaseAssetURL(spxVersion, name string) (string, error) { + if err := validateSPXVersion(spxVersion); err != nil { + return "", err + } + if name == ManifestName { + if err := validateBaseName(name); err != nil { + return "", err + } + } else if err := validateBundleName(name); err != nil { + return "", err + } + return "https://github.com/" + ReleaseRepository + "/releases/download/driver-" + spxVersion + "/" + name, nil +} diff --git a/internal/driverbundle/manifest_test.go b/internal/driverbundle/manifest_test.go new file mode 100644 index 000000000..53058b93c --- /dev/null +++ b/internal/driverbundle/manifest_test.go @@ -0,0 +1,198 @@ +/* + * 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" +) + +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, SPXVersion: "v3.2.4", RuntimeVersion: "2.4.4", + 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) + } + wantManifestURL := "https://github.com/goplus/spx/releases/download/driver-v3.2.4/" + ManifestName + if gotURL, err := ManifestURL(got.SPXVersion); err != nil || gotURL != wantManifestURL { + t.Fatalf("ManifestURL = %q, %v, want %q", gotURL, err, wantManifestURL) + } + if _, err := got.DownloadURL("../bundle.zip"); err == nil { + t.Fatal("DownloadURL accepted an unsafe bundle name") + } + if _, err := ManifestURL("v3.2"); err == nil { + t.Fatal("ManifestURL accepted an invalid SPX version") + } +} + +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++ }}, + {"version", func(m *Manifest) { m.SPXVersion = "v3.2" }}, + {"runtime", func(m *Manifest) { m.RuntimeVersion = "v2.4.4" }}, + {"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 TestManifestValidateVersions(t *testing.T) { + manifest := testManifest() + if err := manifest.ValidateVersions("v3.2.4", "2.4.4"); err != nil { + t.Fatal(err) + } + if err := manifest.ValidateVersions("v3.2.5", "2.4.4"); err == nil || !strings.Contains(err.Error(), "SPX version") { + t.Fatalf("SPX version mismatch error = %v", err) + } + if err := manifest.ValidateVersions("v3.2.4", "2.4.3"); err == nil || !strings.Contains(err.Error(), "runtime version") { + t.Fatalf("runtime version mismatch error = %v", err) + } + if err := manifest.ValidateVersions("3.2.4", "2.4.4"); err == nil { + t.Fatal("ValidateVersions accepted a non-canonical expected SPX version") + } +} + +func TestHostSpecMatchesCanonicalBundleComponents(t *testing.T) { + spec, err := HostSpecFor("2.4.4", "linux", "amd64") + if err != nil { + t.Fatal(err) + } + if spec.BundleName != "spx-driver-linux-amd64.zip" || spec.Engine.Name != "gdspxrt2.4.4" || spec.Pack.Name != "gdspxrt2.4.4.pck" || spec.Bridge.Name != "gdspx-linux-amd64.so" { + t.Fatalf("HostSpecFor = %#v", spec) + } + if _, err := HostSpecFor("2.4.4", "freebsd", "amd64"); err == nil { + t.Fatal("HostSpecFor accepted unsupported target") + } + if got := SupportedTargets(); len(got) != 4 || got[0].GOOS != "darwin" || got[3].GOOS != "windows" { + t.Fatalf("SupportedTargets = %#v", got) + } +} diff --git a/internal/driverbundle/validation.go b/internal/driverbundle/validation.go new file mode 100644 index 000000000..c9ca709dc --- /dev/null +++ b/internal/driverbundle/validation.go @@ -0,0 +1,172 @@ +/* + * 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 ( + "fmt" + "regexp" +) + +var supportedTargets = [...]Target{ + {GOOS: "darwin", GOARCH: "amd64"}, + {GOOS: "darwin", GOARCH: "arm64"}, + {GOOS: "linux", GOARCH: "amd64"}, + {GOOS: "windows", GOARCH: "amd64"}, +} + +var ( + runtimeVersionPattern = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$`) + platformPattern = regexp.MustCompile(`^[a-z][a-z0-9._-]*$`) +) + +// 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 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 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 +} + +// ValidateVersions binds a manifest to the selected driver and runtime +// releases. Content integrity is carried by each bundle and file digest. +func (m Manifest) ValidateVersions(spxVersion, runtimeVersion string) error { + if err := m.Validate(); err != nil { + return err + } + return m.validateVersions(spxVersion, runtimeVersion) +} + +func (m Manifest) validateVersions(spxVersion, runtimeVersion string) error { + if err := validateSPXVersion(spxVersion); err != nil { + return fmt.Errorf("driverbundle: expected %w", err) + } + if err := validateRuntimeVersion(runtimeVersion); err != nil { + return fmt.Errorf("driverbundle: expected %w", err) + } + if m.SPXVersion != spxVersion { + return fmt.Errorf("driverbundle: manifest SPX version = %q, want %q", m.SPXVersion, spxVersion) + } + if m.RuntimeVersion != runtimeVersion { + return fmt.Errorf("driverbundle: manifest runtime version = %q, want %q", m.RuntimeVersion, runtimeVersion) + } + 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 + } + var spec HostSpec + if runtimeVersion != "" { + var err error + spec, err = HostSpecFor(runtimeVersion, b.GOOS, b.GOARCH) + if err != nil { + return err + } + if b.Name != spec.BundleName { + return fmt.Errorf("bundle name = %q, want %q", b.Name, spec.BundleName) + } + } + 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 + } + want := [...]Component{spec.Engine, spec.Pack, spec.Bridge} + for i, component := range want { + if b.Files[i].Name != component.Name || b.Files[i].Mode != component.Mode { + 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/envutil/env.go b/internal/envutil/env.go new file mode 100644 index 000000000..62e24f816 --- /dev/null +++ b/internal/envutil/env.go @@ -0,0 +1,154 @@ +/* + * 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 envutil contains the small, deterministic environment operations +// shared by SPX's driver and launcher paths. +package envutil + +import ( + "os" + "runtime" + "strings" +) + +// Assignment describes one environment value to append after replacing all +// existing entries with the same key. +type Assignment struct { + Key string + Value string +} + +func resolve(env []string) []string { + if env == nil { + return os.Environ() + } + return env +} + +func canonicalKey(key string) string { + if runtime.GOOS == "windows" { + return strings.ToLower(key) + } + return key +} + +// Lookup finds one key and reports whether it occurred more than once. +func Lookup(env []string, key string) (value string, found, duplicate bool) { + key = canonicalKey(key) + for _, entry := range env { + name, current, ok := strings.Cut(entry, "=") + if !ok || canonicalKey(name) != key { + continue + } + if found { + return "", true, true + } + value, found = current, true + } + return value, found, false +} + +// HasNonEmpty reports whether any occurrence of key has a non-empty value. +func HasNonEmpty(env []string, key string) bool { + key = canonicalKey(key) + for _, entry := range resolve(env) { + name, value, ok := strings.Cut(entry, "=") + if ok && canonicalKey(name) == key && value != "" { + return true + } + } + return false +} + +func filter(env []string, reject func(string) bool) []string { + base := resolve(env) + filtered := make([]string, 0, len(base)) + for _, entry := range base { + key, _, ok := strings.Cut(entry, "=") + if ok && reject(key) { + continue + } + filtered = append(filtered, entry) + } + return filtered +} + +// Without removes entries whose key is one of keys. +func Without(env []string, keys ...string) []string { + set := make(map[string]struct{}, len(keys)) + for _, key := range keys { + set[canonicalKey(key)] = struct{}{} + } + return filter(env, func(key string) bool { + _, found := set[canonicalKey(key)] + return found + }) +} + +// WithoutPrefixes removes entries whose key starts with any prefix. +func WithoutPrefixes(env []string, prefixes ...string) []string { + return filter(env, func(key string) bool { + key = canonicalKey(key) + for _, prefix := range prefixes { + if strings.HasPrefix(key, canonicalKey(prefix)) { + return true + } + } + return false + }) +} + +// SetMany replaces each assignment key and appends its value in declaration +// order. Empty values are intentional and are retained. +func SetMany(env []string, assignments ...Assignment) []string { + return setManyWithout(env, nil, assignments...) +} + +func setManyWithout(env []string, removeKeys []string, assignments ...Assignment) []string { + last := make(map[string]int, len(removeKeys)+len(assignments)) + for _, key := range removeKeys { + last[canonicalKey(key)] = -1 + } + for i, assignment := range assignments { + last[canonicalKey(assignment.Key)] = i + } + result := filter(env, func(key string) bool { + _, changed := last[canonicalKey(key)] + return changed + }) + for i, assignment := range assignments { + if last[canonicalKey(assignment.Key)] == i { + result = append(result, assignment.Key+"="+assignment.Value) + } + } + return result +} + +// HostGoEnvironment returns the deterministic environment for a host Go +// command. Ambient graph, target, and CGO selection cannot leak into it. +func HostGoEnvironment(env []string, goWork string, cgoEnabled bool, removeKeys ...string) []string { + cgo := "0" + if cgoEnabled { + cgo = "1" + } + return setManyWithout(env, removeKeys, + Assignment{Key: "GOFLAGS"}, + Assignment{Key: "GOWORK", Value: goWork}, + Assignment{Key: "GOOS", Value: runtime.GOOS}, + Assignment{Key: "GOARCH", Value: runtime.GOARCH}, + Assignment{Key: "CGO_ENABLED", Value: cgo}, + ) +} diff --git a/internal/envutil/env_test.go b/internal/envutil/env_test.go new file mode 100644 index 000000000..d0a08e14a --- /dev/null +++ b/internal/envutil/env_test.go @@ -0,0 +1,103 @@ +/* + * 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 envutil + +import ( + "reflect" + "runtime" + "testing" +) + +func TestLookupReportsDuplicatesWithoutLosingValue(t *testing.T) { + value, found, duplicate := Lookup([]string{"A=one", "B=two", "A=three"}, "A") + if value != "" || !found || !duplicate { + t.Fatalf("Lookup duplicate = %q, %t, %t", value, found, duplicate) + } + if value, found, duplicate := Lookup([]string{"A=one"}, "A"); value != "one" || !found || duplicate { + t.Fatalf("Lookup single = %q, %t, %t", value, found, duplicate) + } +} + +func TestHasNonEmptyChecksEveryOccurrence(t *testing.T) { + if !HasNonEmpty([]string{"A=", "A=value"}, "A") { + t.Fatal("HasNonEmpty missed a later non-empty value") + } + if HasNonEmpty([]string{"A=", "B=value"}, "A") { + t.Fatal("HasNonEmpty accepted only empty values") + } +} + +func TestSetManyReplacesKeysAndPreservesOrder(t *testing.T) { + got := SetMany([]string{"A=old", "malformed", "B=keep", "A=duplicate"}, Assignment{Key: "A", Value: "new"}, Assignment{Key: "C", Value: ""}) + want := []string{"malformed", "B=keep", "A=new", "C="} + if !reflect.DeepEqual(got, want) { + t.Fatalf("SetMany = %#v, want %#v", got, want) + } +} + +func TestSetManyUsesLastDuplicateAssignment(t *testing.T) { + got := SetMany([]string{"A=old", "B=keep"}, Assignment{Key: "A", Value: "first"}, Assignment{Key: "C", Value: "value"}, Assignment{Key: "A", Value: "last"}) + want := []string{"B=keep", "C=value", "A=last"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("SetMany duplicates = %#v, want %#v", got, want) + } +} + +func TestWithoutPrefixes(t *testing.T) { + got := WithoutPrefixes([]string{"CGO_ENABLED=1", "CGO_CFLAGS=x", "PATH=/bin"}, "CGO_") + want := []string{"PATH=/bin"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("WithoutPrefixes = %#v, want %#v", got, want) + } +} + +func TestEnvironmentKeyComparisonMatchesPlatform(t *testing.T) { + value, found, duplicate := Lookup([]string{"spx_flag=on"}, "SPX_FLAG") + wantValue, wantFound := "", false + if runtime.GOOS == "windows" { + wantValue, wantFound = "on", true + } + if value != wantValue || found != wantFound || duplicate { + t.Fatalf("Lookup case variant = %q, %t, %t; want %q, %t, false", value, found, duplicate, wantValue, wantFound) + } + + got := SetMany([]string{"Path=/old"}, Assignment{Key: "PATH", Value: "/new"}) + want := []string{"Path=/old", "PATH=/new"} + if runtime.GOOS == "windows" { + want = []string{"PATH=/new"} + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("SetMany case variants = %#v, want %#v", got, want) + } + + got = WithoutPrefixes([]string{"cgo_cflags=-unsafe", "PATH=/bin"}, "CGO_") + want = []string{"cgo_cflags=-unsafe", "PATH=/bin"} + if runtime.GOOS == "windows" { + want = []string{"PATH=/bin"} + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("WithoutPrefixes case variants = %#v, want %#v", got, want) + } +} + +func TestHostGoEnvironmentPinsGraphAndTarget(t *testing.T) { + got := HostGoEnvironment([]string{"PATH=/bin", "GOFLAGS=-mod=vendor", "GOWORK=/ambient", "GOOS=plan9", "GOARCH=386", "CGO_ENABLED=1", "SECRET=remove"}, "/graph/go.work", false, "SECRET") + want := []string{"PATH=/bin", "GOFLAGS=", "GOWORK=/graph/go.work", "GOOS=" + runtime.GOOS, "GOARCH=" + runtime.GOARCH, "CGO_ENABLED=0"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("HostGoEnvironment = %#v, want %#v", got, want) + } +} diff --git a/internal/launchpack/assets_verify.go b/internal/launchpack/assets_verify.go new file mode 100644 index 000000000..8548aec3b --- /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}, + } { + 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..f8a2db517 --- /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.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..553bb3758 --- /dev/null +++ b/internal/launchpack/driver_published.go @@ -0,0 +1,168 @@ +/* + * 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/envutil" + "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 +} + +func defaultDriverAssetDependencies() driverAssetDependencies { + return driverAssetDependencies{ + fetch: fetchReleaseURL, + cacheRoot: runtimebundle.DefaultCacheRoot, + } +} + +// AcquirePublishedDriver resolves one versioned 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) { + if err := validatePublishedSource(cfg.Source); err != nil { + return Assets{}, err + } + lock, err := runtimeLock(cfg) + if err != nil { + return Assets{}, err + } + return acquirePublishedDriverWith(ctx, cfg, cfg.IO, lock, defaultDriverAssetDependencies()) +} + +func acquirePublishedDriverWith(ctx context.Context, cfg Config, streams IO, lock release.RuntimeLock, 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 { + return Assets{}, errors.New("launchpack: incomplete published driver dependencies") + } + if err := lock.Validate(); err != nil { + return Assets{}, err + } + env := publishedDriverEnvironment(cfg, streams.Env) + assetDir, assetDirSet, duplicate := envutil.Lookup(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 + + spxVersion := cfg.Source.SelectedVersion + manifestURL, err := driverbundle.ManifestURL(spxVersion) + if err != nil { + return Assets{}, fmt.Errorf("launchpack: build published driver manifest URL: %w", err) + } + parseManifest := func(data []byte) (driverbundle.Manifest, error) { + return driverbundle.ParseForVersions(data, spxVersion, lock.RuntimeVersion) + } + manifest, manifestData, err := acquireVersionedReleaseManifest(ctx, versionedReleaseManifestSpec{ + CacheRoot: cacheRoot, Namespace: "driver", Version: spxVersion, + Name: driverbundle.ManifestName, URL: manifestURL, MirrorDir: assetDir, + Offline: offline, MaxSize: driverbundle.MaxManifestSize, Fetch: deps.fetch, + }, parseManifest, func(manifest driverbundle.Manifest) string { return manifest.SPXVersion }) + if err != nil { + return Assets{}, fmt.Errorf("launchpack: acquire published driver manifest: %w", err) + } + manifestDigest := digestBytes(manifestData) + 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) + } + releaseRoot := filepath.Join(cacheRoot, "downloads", "driver", spxVersion) + 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, manifestDigest, spxVersion, 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, manifestDigest, spxVersion, 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..9a56bd761 --- /dev/null +++ b/internal/launchpack/driver_published_acquire_test.go @@ -0,0 +1,215 @@ +/* + * 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 TestAcquirePublishedDriverUsesOneCombinedBundle(t *testing.T) { + fixture := newPublishedDriverFixture(t) + cacheRoot := t.TempDir() + calls := 0 + assets, err := acquirePublishedDriverWith(context.Background(), publishedDriverTestConfig(cacheRoot), IO{}, fixture.lock, 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 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 != digestBytes(fixture.manifestData) || 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.manifest.SPXVersion, 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.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, driverbundle.ManifestName), 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.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.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) + } + mirror := t.TempDir() + if err := os.WriteFile(filepath.Join(mirror, driverbundle.ManifestName), updated.manifestData, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(mirror, updated.bundle.Name), updated.bundleData, 0o600); err != nil { + t.Fatal(err) + } + secondCalls := 0 + cfg := publishedDriverTestConfig(cacheRoot) + cfg.DriverAssetDir = mirror + second, err := acquirePublishedDriverWith(context.Background(), cfg, IO{}, updated.lock, updated.dependencies(cacheRoot, updated.fetcher(nil, &secondCalls))) + if err != nil { + t.Fatalf("acquire updated published driver: %v", err) + } + defer second.Cleanup() + if secondCalls != 0 { + t.Fatalf("updated mirror fetch count = %d, want 0", 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 TestAcquirePublishedDriverRejectsManifestAndBundleMismatch(t *testing.T) { + fixture := newPublishedDriverFixture(t) + cacheRoot := t.TempDir() + badManifestData := bytes.Replace(fixture.manifestData, []byte(fixture.bundle.EngineInterfaceDigest), []byte(strings.Repeat("0", 64)), 1) + deps := fixture.dependencies(cacheRoot, fixture.fetcher(map[string][]byte{driverbundle.ManifestName: badManifestData}, new(int))) + if _, err := acquirePublishedDriverWith(context.Background(), publishedDriverTestConfig(cacheRoot), IO{}, fixture.lock, 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, 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 manifest 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, 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, 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..84dcdfdba --- /dev/null +++ b/internal/launchpack/driver_published_boundaries_test.go @@ -0,0 +1,263 @@ +/* + * 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, 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, deps); err == nil { + t.Fatal("offline cold published acquisition succeeded") + } + if calls != 0 { + t.Fatalf("offline fetch count = %d, want 0", calls) + } +} + +func TestAcquirePublishedDriverIgnoresSourceRuntimeEnvironment(t *testing.T) { + fixture := newPublishedDriverFixture(t) + cacheRoot := t.TempDir() + calls := 0 + streams := IO{Env: []string{ + runtimeLocalManifestEnv + "=/ambient/runtime.json", + runtimeLocalManifestEnv + "=/duplicate/runtime.json", + runtimeAssetDirEnv + "=/ambient/assets", + runtimeAssetDirEnv + "=/duplicate/assets", + }} + assets, err := acquirePublishedDriverWith(context.Background(), publishedDriverTestConfig(cacheRoot), streams, fixture.lock, fixture.dependencies(cacheRoot, fixture.fetcher(nil, &calls))) + if err != nil { + t.Fatal(err) + } + defer assets.Cleanup() + if calls != 2 { + t.Fatalf("published fetch count = %d, want 2", 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.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, driverbundle.ManifestName), 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, 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.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, driverbundle.ManifestName), 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, 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 TestPublishedDriverRejectsRuntimeVersionMismatch(t *testing.T) { + fixture := newPublishedDriverFixture(t) + historical, err := release.RuntimeLockForVersion("2.4.3") + if err != nil { + t.Fatal(err) + } + fixture.manifest.RuntimeVersion = historical.RuntimeVersion + for i := range fixture.manifest.Bundles { + bundle := &fixture.manifest.Bundles[i] + names := driverFixtureFileNames(historical.RuntimeVersion, bundle.GOOS, bundle.GOARCH) + for j := range bundle.Files { + bundle.Files[j].Name = names[j] + } + } + fixture.manifestData, err = fixture.manifest.JSON() + if err != nil { + t.Fatal(err) + } + deps := fixture.dependencies(t.TempDir(), fixture.fetcher(nil, new(int))) + if _, err := acquirePublishedDriverWith(context.Background(), publishedDriverTestConfig(deps.cacheRoot()), IO{}, fixture.lock, deps); err == nil { + t.Fatal("published driver accepted a mismatched runtime version") + } +} + +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..489fe66b0 --- /dev/null +++ b/internal/launchpack/driver_published_bundle.go @@ -0,0 +1,95 @@ +/* + * 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, manifestDigest, spxVersion string, 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") + } + components, err := driverbundle.HostSpecFor(lock.RuntimeVersion, runtime.GOOS, runtime.GOARCH) + if err != nil { + return Assets{}, err + } + paths := 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 + } + paths[file.Name] = path + } + enginePath, ok := paths[components.Engine.Name] + if !ok { + return Assets{}, fmt.Errorf("launchpack: published driver bundle is missing %s", components.Engine.Name) + } + packPath, ok := paths[components.Pack.Name] + if !ok { + return Assets{}, fmt.Errorf("launchpack: published driver bundle is missing %s", components.Pack.Name) + } + bridgePath, ok := paths[components.Bridge.Name] + if !ok { + return Assets{}, fmt.Errorf("launchpack: published driver bundle is missing %s", components.Bridge.Name) + } + fileDigest := func(name string) string { + for _, file := range bundle.Files { + if file.Name == name { + return file.SHA256 + } + } + return "" + } + engineDigest := fileDigest(components.Engine.Name) + packDigest := fileDigest(components.Pack.Name) + bridgeDigest := fileDigest(components.Bridge.Name) + return Assets{ + EnginePath: enginePath, PackPath: packPath, BridgePath: bridgePath, Lock: lock, + Published: &PublishedDriverIdentity{ + ManifestSHA256: manifestDigest, BundleSHA256: bundle.SHA256, BundleName: bundle.Name, + SPXVersion: spxVersion, + EngineSHA256: engineDigest, PackSHA256: packDigest, BridgeSHA256: bridgeDigest, + EngineInterfaceDigest: bundle.EngineInterfaceDigest, + }, + 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..1f4bba841 --- /dev/null +++ b/internal/launchpack/driver_published_fixture_test.go @@ -0,0 +1,163 @@ +/* + * 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" + "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 + 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)}, + }, + } + 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, SPXVersion: "v3.2.4", + RuntimeVersion: lock.RuntimeVersion, Bundles: bundles, + } + manifestData, err := manifest.JSON() + if err != nil { + t.Fatal(err) + } + return publishedDriverFixture{lock: lock, spec: spec, manifest: manifest, manifestData: manifestData, 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 driverbundle.ManifestName: + 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 }} +} + +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..d15e2322d --- /dev/null +++ b/internal/launchpack/driver_published_payload_test.go @@ -0,0 +1,192 @@ +/* + * 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"} { + 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: digestBytes(fixture.manifestData), BundleSHA256: fixture.bundle.SHA256, + BundleName: fixture.bundle.Name, SPXVersion: fixture.manifest.SPXVersion, + 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, + } + 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..a5fc2d14f --- /dev/null +++ b/internal/launchpack/driver_published_support.go @@ -0,0 +1,90 @@ +/* + * 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" + "path/filepath" + + "github.com/goplus/spx/v3/internal/driverbundle" + "github.com/goplus/spx/v3/internal/envutil" + "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 { + return environmentWithNonEmpty(base, + envutil.Assignment{Key: driverAssetDirEnv, Value: cfg.DriverAssetDir}, + envutil.Assignment{Key: runtimeCacheEnv, Value: cfg.RuntimeCacheRoot}, + ) +} + +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 +} 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..aa32bd3a6 100644 --- a/internal/launchpack/payload_files.go +++ b/internal/launchpack/payload_files.go @@ -23,9 +23,9 @@ import ( "fmt" "io" "os" - "runtime" - "strings" + "github.com/goplus/spx/v3/internal/driverbundle" + "github.com/goplus/spx/v3/internal/envutil" "github.com/goplus/spx/v3/internal/runtimebundle" "github.com/goplus/spx/v3/internal/runtimepayload" ) @@ -101,19 +101,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 { @@ -148,35 +149,10 @@ func hasBuildFlag(flags []string, name string) bool { func traceEnabled(flags []string) bool { return hasBuildFlag(flags, "x") || hasBuildFlag(flags, "v") } -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 == "GOFLAGS" || key == "GOWORK" || key == "GOOS" || key == "GOARCH" || key == "CGO_ENABLED") { - continue - } - result = append(result, entry) - } - return result -} - 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") + return envutil.HostGoEnvironment(base, cfg.GoWork, false) } func sourceBridgeEnv(cfg Config, base []string) []string { - env := sanitizeEnvironment(base) - filtered := env[:0] - for _, entry := range env { - key, _, ok := strings.Cut(entry, "=") - if ok && strings.HasPrefix(key, "CGO_") { - continue - } - filtered = append(filtered, entry) - } - return append(filtered, "GOFLAGS=", "GOWORK="+cfg.GoWork, "GOOS="+runtime.GOOS, "GOARCH="+runtime.GOARCH, "CGO_ENABLED=1") + return envutil.HostGoEnvironment(envutil.WithoutPrefixes(base, "CGO_"), cfg.GoWork, true) } diff --git a/internal/launchpack/payload_manifest.go b/internal/launchpack/payload_manifest.go new file mode 100644 index 000000000..f311aaa9b --- /dev/null +++ b/internal/launchpack/payload_manifest.go @@ -0,0 +1,98 @@ +/* + * 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"` +} + +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, + } + } + 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..e43ae0c3b 100644 --- a/internal/launchpack/runtime_assets.go +++ b/internal/launchpack/runtime_assets.go @@ -20,11 +20,9 @@ import ( "context" "errors" "fmt" - "os" - "path/filepath" "runtime" - "strings" + "github.com/goplus/spx/v3/internal/envutil" "github.com/goplus/spx/v3/internal/release" "github.com/goplus/spx/v3/internal/runtimebundle" ) @@ -38,18 +36,16 @@ const ( ) type runtimeAssetDependencies struct { - fetch runtimebundle.FetchFunc - cacheRoot func() string - manifestPin func(release.RuntimeLock) (release.RuntimeManifestPin, error) - goBin func(context.Context, Config, []string) (string, error) + fetch runtimebundle.FetchFunc + cacheRoot func() string + goBin func(context.Context, Config, []string) (string, error) } func defaultRuntimeAssetDependencies() runtimeAssetDependencies { return runtimeAssetDependencies{ - fetch: fetchRuntimeURL, - cacheRoot: runtimebundle.DefaultCacheRoot, - manifestPin: release.RuntimeManifestPinForLock, - goBin: resolveGoBin, + fetch: fetchReleaseURL, + cacheRoot: runtimebundle.DefaultCacheRoot, + goBin: resolveGoBin, } } @@ -79,7 +75,7 @@ func acquireRuntimeAssetsWith(ctx context.Context, cfg Config, streams IO, lock if err := ctx.Err(); err != nil { return Assets{}, err } - if dependencies.fetch == nil || dependencies.cacheRoot == nil || dependencies.manifestPin == nil { + if dependencies.fetch == nil || dependencies.cacheRoot == nil { return Assets{}, errors.New("launchpack: incomplete runtime acquisition dependencies") } env := runtimeEnvironment(cfg, streams.Env) @@ -107,23 +103,11 @@ func acquireRuntimeAssetsWith(ctx context.Context, cfg Config, streams IO, lock return materializeLocalRuntime(ctx, cacheRoot, lock, spec, local) } - _, assetDirSet, duplicate := environmentValue(env, runtimeAssetDirEnv) + _, assetDirSet, duplicate := envutil.Lookup(env, runtimeAssetDirEnv) if duplicate { return Assets{}, fmt.Errorf("launchpack: duplicate %s", runtimeAssetDirEnv) } - pin, err := dependencies.manifestPin(lock) - if err != nil { - publishedErr := fmt.Errorf("launchpack: resolve runtime manifest pin: %w", err) - if !assetDirSet && cfg.Source.SourceMode && errors.Is(err, release.ErrRuntimeManifestPinNotFound) { - return acquireSourceRuntime(ctx, cfg, env, cacheRoot, lock, spec, dependencies, publishedErr) - } - return Assets{}, publishedErr - } - if err := pin.ValidateForLock(lock); err != nil { - return Assets{}, err - } - - source, err := resolvePublishedRuntime(ctx, cacheRoot, lock, spec, pin, env, offline, dependencies) + source, err := resolvePublishedRuntime(ctx, cacheRoot, lock, env, offline, dependencies) if err == nil { var assets Assets assets, err = materializePublishedRuntime(ctx, cacheRoot, lock, spec, source, offline) @@ -134,140 +118,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) - } - 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 ctxErr := ctx.Err(); ctxErr != nil { + return Assets{}, ctxErr } - 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..1db286ec9 --- /dev/null +++ b/internal/launchpack/runtime_environment.go @@ -0,0 +1,77 @@ +/* + * 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" + "path/filepath" + "strings" + + "github.com/goplus/spx/v3/internal/envutil" +) + +func resolveRuntimeCacheRoot(env []string, defaultRoot func() string) (string, error) { + value, found, duplicate := envutil.Lookup(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 := envutil.Lookup(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 runtimeEnvironment(cfg Config, base []string) []string { + return environmentWithNonEmpty(base, + envutil.Assignment{Key: runtimeLocalManifestEnv, Value: cfg.RuntimeManifestPath}, + envutil.Assignment{Key: runtimeAssetDirEnv, Value: cfg.RuntimeAssetDir}, + envutil.Assignment{Key: runtimeCacheEnv, Value: cfg.RuntimeCacheRoot}, + ) +} + +func environmentWithNonEmpty(base []string, values ...envutil.Assignment) []string { + assignments := make([]envutil.Assignment, 0, len(values)) + for _, value := range values { + if value.Value == "" { + continue + } + assignments = append(assignments, value) + } + return envutil.SetMany(base, assignments...) +} diff --git a/internal/launchpack/runtime_fetch.go b/internal/launchpack/runtime_fetch.go index 558261b5f..59110b2c1 100644 --- a/internal/launchpack/runtime_fetch.go +++ b/internal/launchpack/runtime_fetch.go @@ -20,20 +20,24 @@ import ( "context" "crypto/sha256" "encoding/hex" + "errors" "fmt" "io" "net/http" "path/filepath" "time" + "github.com/goplus/spx/v3/internal/envutil" "github.com/goplus/spx/v3/internal/release" "github.com/goplus/spx/v3/internal/runtimebundle" ) var runtimeHTTPClient = &http.Client{Timeout: 30 * time.Minute} -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) +var errReleaseUnavailable = errors.New("launchpack: release unavailable") + +func resolvePublishedRuntime(ctx context.Context, cacheRoot string, lock release.RuntimeLock, env []string, offline bool, dependencies runtimeAssetDependencies) (runtimeAssetSource, error) { + assetDir, assetDirSet, duplicate := envutil.Lookup(env, runtimeAssetDirEnv) if duplicate { return runtimeAssetSource{}, fmt.Errorf("launchpack: duplicate %s", runtimeAssetDirEnv) } @@ -44,49 +48,30 @@ func resolvePublishedRuntime(ctx context.Context, cacheRoot string, lock release if !filepath.IsAbs(assetDir) || filepath.Clean(assetDir) != assetDir { return runtimeAssetSource{}, fmt.Errorf("launchpack: %s must be an absolute clean path", runtimeAssetDirEnv) } - data, err := readRegularFile(filepath.Join(assetDir, lock.Manifest)) - if err != nil { - return runtimeAssetSource{}, fmt.Errorf("launchpack: read local release manifest: %w", err) - } - return parseRuntimeAssetSource(lock, pin, data, assetDir, dependencies.fetch) } - manifestURL := lock.RuntimeAssetDownloadURL(lock.Manifest) - manifestRoot := filepath.Join(cacheRoot, "release-manifests") - manifestName := pin.SHA256 + "-" + pin.Name - manifestFile, err := runtimebundle.AcquireFile(ctx, manifestRoot, runtimebundle.FetchSpec{ - Name: manifestName, URL: manifestURL, Size: pin.Size, SHA256: pin.SHA256, - Offline: offline, Fetch: dependencies.fetch, + manifest, data, err := acquireVersionedReleaseManifest(ctx, versionedReleaseManifestSpec{ + CacheRoot: cacheRoot, + Namespace: "runtime", + Version: lock.RuntimeVersion, + Name: lock.Manifest, + URL: lock.RuntimeAssetDownloadURL(lock.Manifest), + MirrorDir: assetDir, + Offline: offline, + MaxSize: maxRuntimeManifestSize, + Fetch: dependencies.fetch, + }, func(data []byte) (release.RuntimeManifest, error) { + return release.ParseRuntimeManifestForRelease(data, lock.RuntimeVersion, lock.RequiredAssets) + }, func(manifest release.RuntimeManifest) string { + return manifest.RuntimeVersion }) if err != nil { - return runtimeAssetSource{}, fmt.Errorf("launchpack: acquire runtime manifest for %s/%s: %w", spec.GOOS, spec.GOARCH, err) - } - data, readErr := readRuntimeMetadata(manifestFile, manifestName) - closeErr := manifestFile.Close() - if readErr != nil { - return runtimeAssetSource{}, fmt.Errorf("launchpack: read acquired runtime manifest: %w", readErr) - } - if closeErr != nil { - return runtimeAssetSource{}, fmt.Errorf("launchpack: close acquired runtime manifest: %w", closeErr) + return runtimeAssetSource{}, fmt.Errorf("launchpack: acquire runtime release manifest: %w", err) } - return parseRuntimeAssetSource(lock, pin, data, "", dependencies.fetch) -} - -func parseRuntimeAssetSource(lock release.RuntimeLock, pin release.RuntimeManifestPin, data []byte, manifestDir string, fetch runtimebundle.FetchFunc) (runtimeAssetSource, error) { - if err := verifyRuntimeManifestPin(pin, data); err != nil { - return runtimeAssetSource{}, err - } - manifest, err := release.ParseRuntimeManifest(data) - if err != nil { - return runtimeAssetSource{}, err - } - if err := manifest.ValidateForLock(lock); err != nil { - return runtimeAssetSource{}, err + manifestDir := "" + if assetDirSet { + manifestDir = assetDir } - return runtimeAssetSource{manifest: manifest, manifestSHA256: pin.SHA256, manifestDir: manifestDir, fetch: fetch}, nil -} - -func (s runtimeAssetSource) manifestURL(name string) string { - return "https://github.com/" + s.manifest.ReleaseRepository + "/releases/download/runtime-v" + s.manifest.RuntimeVersion + "/" + name + return runtimeAssetSource{manifest: manifest, manifestSHA256: digestBytes(data), manifestDir: manifestDir, fetch: dependencies.fetch}, nil } func acquireReleaseAsset(ctx context.Context, root string, asset release.RuntimeAsset, url, localDir string, offline bool, fetch runtimebundle.FetchFunc) (*runtimebundle.AcquiredFile, error) { @@ -107,16 +92,6 @@ 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) - } - return nil -} - func copyLocalRuntimeAsset(ctx context.Context, path string, dst io.Writer) error { file, err := openPinnedFile("runtime release asset", path) if err != nil { @@ -158,19 +133,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..94034c5d8 --- /dev/null +++ b/internal/launchpack/runtime_local_manifest.go @@ -0,0 +1,90 @@ +/* + * 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/envutil" + "github.com/goplus/spx/v3/internal/release" +) + +func findExplicitLocalRuntimeManifest(env []string, lock release.RuntimeLock, spec release.HostRuntimeSpec) (localRuntimeSource, bool, error) { + path, found, duplicate := envutil.Lookup(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_local_test.go b/internal/launchpack/runtime_local_test.go index bfde2f369..9a8dd68d3 100644 --- a/internal/launchpack/runtime_local_test.go +++ b/internal/launchpack/runtime_local_test.go @@ -20,12 +20,14 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" "os" "path/filepath" "runtime" "testing" + "github.com/goplus/spx/v3/internal/envutil" "github.com/goplus/spx/v3/internal/release" ) @@ -57,7 +59,7 @@ func TestAcquireRuntimeAssetsPrefersExplicitLocalManifest(t *testing.T) { } assets, err := acquireRuntimeAssetsWith(context.Background(), cfg, IO{Env: []string{"SPX_RUNTIME_OFFLINE=1"}}, lock, runtimeAssetDependencies{ fetch: func(context.Context, string, io.Writer) error { return errors.New("network must not be used") }, - cacheRoot: func() string { return cfg.RuntimeCacheRoot }, manifestPin: release.RuntimeManifestPinForLock, + cacheRoot: func() string { return cfg.RuntimeCacheRoot }, }) if err != nil { t.Fatal(err) @@ -75,7 +77,7 @@ func TestAcquireRuntimeAssetsPrefersExplicitLocalManifest(t *testing.T) { cfg.Source.SourceMode = true assets, err = acquireRuntimeAssetsWith(context.Background(), cfg, IO{Env: []string{"SPX_RUNTIME_OFFLINE=1"}}, lock, runtimeAssetDependencies{ fetch: func(context.Context, string, io.Writer) error { return errors.New("network must not be used") }, - cacheRoot: func() string { return filepath.Join(root, "cache-source") }, manifestPin: release.RuntimeManifestPinForLock, + cacheRoot: func() string { return filepath.Join(root, "cache-source") }, }) if err != nil { t.Fatal(err) @@ -93,7 +95,7 @@ func TestAcquireRuntimeAssetsPrefersExplicitLocalManifest(t *testing.T) { func TestRuntimeEnvironmentConfigOverridesProcess(t *testing.T) { t.Setenv(runtimeCacheEnv, "/process-cache") env := runtimeEnvironment(Config{RuntimeCacheRoot: "/config-cache"}, nil) - value, found, duplicate := environmentValue(env, runtimeCacheEnv) + value, found, duplicate := envutil.Lookup(env, runtimeCacheEnv) if !found || duplicate || value != "/config-cache" { t.Fatalf("%s = %q, found %v, duplicate %v", runtimeCacheEnv, value, found, duplicate) } @@ -211,7 +213,7 @@ func TestAcquireRuntimeAssetsAutoDiscoveredManifestIdentityMismatchFailsClosed(t calls := 0 fetch := func(context.Context, string, io.Writer) error { calls++ - return errors.New("auto-discovered local manifest must not fetch") + return fmt.Errorf("%w: unpublished runtime", errReleaseUnavailable) } cfg := Config{RuntimeSourceRoot: sourceRoot, RuntimeCacheRoot: cacheRoot, Source: SourceIdentity{SourceMode: true}} env := IO{Env: []string{}} @@ -233,8 +235,8 @@ func TestAcquireRuntimeAssetsAutoDiscoveredManifestIdentityMismatchFailsClosed(t if _, err := acquireRuntimeAssetsWith(context.Background(), cfg, env, lock, dependencies); err == nil { t.Fatal("identity-mismatched auto-discovered manifest fell back to published runtime") } - if calls != 0 { - t.Fatalf("fetch count = %d, want 0", calls) + if calls != 2 { + t.Fatalf("fetch count = %d, want 2", calls) } } diff --git a/internal/launchpack/runtime_materialize.go b/internal/launchpack/runtime_materialize.go index 220e4726f..c8a960437 100644 --- a/internal/launchpack/runtime_materialize.go +++ b/internal/launchpack/runtime_materialize.go @@ -75,14 +75,14 @@ func materializePublishedRuntime(ctx context.Context, cacheRoot string, lock rel if !ok { return Assets{}, fmt.Errorf("launchpack: runtime manifest has no runtime pack asset %q", release.RuntimeAssetZipName) } - assetRoot := filepath.Join(cacheRoot, "release-assets", source.manifest.LockSHA256) + assetRoot := filepath.Join(cacheRoot, "release-assets", "runtime", lock.RuntimeVersion) assetDir := source.manifestDir - engineFile, err := acquireReleaseAsset(ctx, assetRoot, engineAsset, source.manifestURL(spec.ArchiveName), assetDir, offline, source.fetch) + engineFile, err := acquireReleaseAsset(ctx, assetRoot, engineAsset, lock.RuntimeAssetDownloadURL(spec.ArchiveName), assetDir, offline, source.fetch) if err != nil { return Assets{}, err } defer engineFile.Close() - packFile, err := acquireReleaseAsset(ctx, assetRoot, packAsset, source.manifestURL(release.RuntimeAssetZipName), assetDir, offline, source.fetch) + packFile, err := acquireReleaseAsset(ctx, assetRoot, packAsset, lock.RuntimeAssetDownloadURL(release.RuntimeAssetZipName), assetDir, offline, source.fetch) if err != nil { return Assets{}, err } diff --git a/internal/launchpack/runtime_published_test.go b/internal/launchpack/runtime_published_test.go index 30e4c0e36..ad6dbd452 100644 --- a/internal/launchpack/runtime_published_test.go +++ b/internal/launchpack/runtime_published_test.go @@ -27,10 +27,12 @@ import ( "path" "path/filepath" "runtime" + "slices" "strings" "testing" "github.com/goplus/spx/v3/internal/release" + "github.com/goplus/spx/v3/internal/runtimebundle" ) type publishedRuntimeFixture struct { @@ -161,15 +163,6 @@ func (f publishedRuntimeFixture) fetcher(replacements map[string][]byte, calls * func (f publishedRuntimeFixture) dependencies(cacheRoot string, fetch func(context.Context, string, io.Writer) error) runtimeAssetDependencies { return runtimeAssetDependencies{ fetch: fetch, cacheRoot: func() string { return cacheRoot }, - manifestPin: func(lock release.RuntimeLock) (release.RuntimeManifestPin, error) { - if lock.RuntimeVersion != f.lock.RuntimeVersion || lock.Manifest != f.lock.Manifest { - return release.RuntimeManifestPin{}, errors.New("fixture lock mismatch") - } - return release.RuntimeManifestPin{ - Schema: 1, RuntimeVersion: lock.RuntimeVersion, - Name: lock.Manifest, Size: int64(len(f.manifestData)), SHA256: digestBytes(f.manifestData), - }, nil - }, } } @@ -197,6 +190,40 @@ func TestAcquireRuntimeAssetsFromPublishedRelease(t *testing.T) { } } +func TestAcquireRuntimeAssetsDerivesURLsFromSelectedRuntimeVersion(t *testing.T) { + fixture := newPublishedRuntimeFixture(t) + fixture.manifest.ReleaseRepository = "example/runtime" + fixture.manifest.LockSHA256 = strings.Repeat("0", 64) + var err error + fixture.manifestData, err = fixture.manifest.JSON() + if err != nil { + t.Fatal(err) + } + + cacheRoot := t.TempDir() + var urls []string + calls := 0 + fetchFixture := fixture.fetcher(nil, &calls) + fetch := func(ctx context.Context, url string, dst io.Writer) error { + urls = append(urls, url) + return fetchFixture(ctx, url, dst) + } + assets, err := acquireRuntimeAssetsWith(context.Background(), publishedRuntimeConfig(cacheRoot), IO{Env: []string{}}, fixture.lock, fixture.dependencies(cacheRoot, fetch)) + if err != nil { + t.Fatal(err) + } + defer assets.Cleanup() + + want := []string{ + fixture.lock.RuntimeAssetDownloadURL(fixture.lock.Manifest), + fixture.lock.RuntimeAssetDownloadURL(fixture.spec.ArchiveName), + fixture.lock.RuntimeAssetDownloadURL(release.RuntimeAssetZipName), + } + if !slices.Equal(urls, want) { + t.Fatalf("runtime release URLs = %v, want %v", urls, want) + } +} + func TestAcquireRuntimeAssetsFromLocalReleaseDirectory(t *testing.T) { fixture := newPublishedRuntimeFixture(t) assetDir := t.TempDir() @@ -233,7 +260,7 @@ func TestAcquireRuntimeAssetsOfflineRejectsCorruptCachedReleaseData(t *testing.T { name: "manifest", path: func(_ *testing.T, fixture publishedRuntimeFixture, cacheRoot string) string { - return filepath.Join(cacheRoot, "release-manifests", digestBytes(fixture.manifestData)+"-"+fixture.lock.Manifest) + return filepath.Join(cacheRoot, "release-manifests", "runtime", fixture.lock.RuntimeVersion, fixture.lock.Manifest) }, }, { @@ -243,7 +270,7 @@ func TestAcquireRuntimeAssetsOfflineRejectsCorruptCachedReleaseData(t *testing.T if !ok { t.Fatal("fixture manifest has no host Engine asset") } - return filepath.Join(cacheRoot, "release-assets", fixture.manifest.LockSHA256, asset.SHA256+"-"+asset.Name) + return filepath.Join(cacheRoot, "release-assets", "runtime", fixture.lock.RuntimeVersion, asset.SHA256+"-"+asset.Name) }, }, { @@ -253,7 +280,7 @@ func TestAcquireRuntimeAssetsOfflineRejectsCorruptCachedReleaseData(t *testing.T if !ok { t.Fatal("fixture manifest has no runtime pack asset") } - return filepath.Join(cacheRoot, "release-assets", fixture.manifest.LockSHA256, asset.SHA256+"-"+asset.Name) + return filepath.Join(cacheRoot, "release-assets", "runtime", fixture.lock.RuntimeVersion, asset.SHA256+"-"+asset.Name) }, }, } @@ -337,7 +364,7 @@ func TestAcquireRuntimeAssetsRejectsOuterDigestMismatchAndRetries(t *testing.T) if !ok { t.Fatal("fixture manifest has no host Engine asset") } - if _, err := os.Stat(filepath.Join(cacheRoot, "release-assets", fixture.manifest.LockSHA256, engineAsset.SHA256+"-"+engineAsset.Name)); !os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(cacheRoot, "release-assets", "runtime", fixture.lock.RuntimeVersion, engineAsset.SHA256+"-"+engineAsset.Name)); !os.IsNotExist(err) { t.Fatalf("digest-mismatched archive was published, stat error = %v", err) } if _, err := os.Stat(filepath.Join(cacheRoot, "engine")); !os.IsNotExist(err) { @@ -354,11 +381,15 @@ func TestAcquireRuntimeAssetsRejectsOuterDigestMismatchAndRetries(t *testing.T) } } -func TestAcquireRuntimeAssetsRejectsManifestOutsidePin(t *testing.T) { +func TestAcquireRuntimeAssetsRejectsAssetOutsideVersionedManifest(t *testing.T) { fixture := newPublishedRuntimeFixture(t) forged := fixture.manifest forged.Assets = append([]release.RuntimeAsset(nil), forged.Assets...) - forged.Assets[0].SHA256 = strings.Repeat("0", 64) + for i := range forged.Assets { + if forged.Assets[i].Name == fixture.spec.ArchiveName { + forged.Assets[i].SHA256 = strings.Repeat("0", 64) + } + } data, err := forged.JSON() if err != nil { t.Fatal(err) @@ -366,10 +397,10 @@ func TestAcquireRuntimeAssetsRejectsManifestOutsidePin(t *testing.T) { calls := 0 dependencies := fixture.dependencies(t.TempDir(), fixture.fetcher(map[string][]byte{fixture.lock.Manifest: data}, &calls)) _, err = acquireRuntimeAssetsWith(context.Background(), publishedRuntimeConfig(t.TempDir()), IO{}, fixture.lock, dependencies) - if err == nil || !strings.Contains(err.Error(), "SHA-256") { - t.Fatalf("unpinned manifest error = %v", err) + if !errors.Is(err, runtimebundle.ErrDigestMismatch) { + t.Fatalf("versioned manifest asset error = %v, want ErrDigestMismatch", err) } - if calls != 1 { - t.Fatalf("fetch count = %d, want only the rejected manifest", calls) + if calls != 2 { + t.Fatalf("fetch count = %d, want manifest and rejected asset", calls) } } diff --git a/internal/launchpack/runtime_source_test.go b/internal/launchpack/runtime_source_test.go index a958bbd31..16f8db00b 100644 --- a/internal/launchpack/runtime_source_test.go +++ b/internal/launchpack/runtime_source_test.go @@ -19,12 +19,15 @@ package launchpack import ( "context" "errors" + "fmt" "io" "os" "runtime" + "strings" "testing" "github.com/goplus/spx/v3/internal/release" + "github.com/goplus/spx/v3/internal/runtimebundle" ) func TestSourceRuntimePrefersPublishedAssets(t *testing.T) { @@ -55,7 +58,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 +82,61 @@ 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 _, test := range []struct { + name string + replacements map[string][]byte + digestError bool + }{ + {name: "manifest", replacements: map[string][]byte{fixture.lock.Manifest: []byte("tampered manifest")}}, + {name: "archive", replacements: map[string][]byte{fixture.spec.ArchiveName: badArchive}, digestError: true}, + } { + t.Run(test.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(test.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 test.digestError && !errors.Is(err, runtimebundle.ErrDigestMismatch) { + t.Fatalf("archive integrity failure = %v, want ErrDigestMismatch", err) + } + if !test.digestError && (err == nil || !strings.Contains(err.Error(), "decode runtime manifest")) { + t.Fatalf("malformed manifest error = %v", 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() @@ -99,7 +157,7 @@ func TestPublishedFetchFailureDoesNotFallbackOutsideSourceMode(t *testing.T) { } } -func TestUnpublishedSourceRuntimeUsesGoBinWithoutFetch(t *testing.T) { +func TestUnpublishedSourceRuntimeFallsBackAfterManifestMiss(t *testing.T) { lock := release.DefaultRuntimeLock() spec, err := release.HostRuntimeSpecFor(lock, runtime.GOOS, runtime.GOARCH) if err != nil { @@ -110,7 +168,7 @@ func TestUnpublishedSourceRuntimeUsesGoBinWithoutFetch(t *testing.T) { fetchCalls, binCalls := 0, 0 dependencies := localRuntimeTestDependencies(cacheRoot, func(context.Context, string, io.Writer) error { fetchCalls++ - return errors.New("unpublished runtime must not fetch") + return fmt.Errorf("%w: unpublished runtime", errReleaseUnavailable) }) dependencies.goBin = func(context.Context, Config, []string) (string, error) { binCalls++ @@ -122,8 +180,8 @@ func TestUnpublishedSourceRuntimeUsesGoBinWithoutFetch(t *testing.T) { } defer assets.Cleanup() assertRuntimeFile(t, assets.PackPath, "dev-pack") - if fetchCalls != 0 || binCalls != 1 { - t.Fatalf("fetch calls = %d, Go-bin calls = %d; want 0, 1", fetchCalls, binCalls) + if fetchCalls != 1 || binCalls != 1 { + t.Fatalf("fetch calls = %d, Go-bin calls = %d; want 1, 1", fetchCalls, binCalls) } } @@ -169,48 +227,25 @@ func TestExplicitReleaseDirectoryFailureDoesNotUseGoBin(t *testing.T) { } } -func TestManifestPinErrorsDoNotUseGoBin(t *testing.T) { +func TestRuntimeManifestVersionMismatchDoesNotUseGoBin(t *testing.T) { fixture := newPublishedRuntimeFixture(t) - for _, test := range []struct { - name string - mutate func(runtimeAssetDependencies) runtimeAssetDependencies - }{ - { - name: "resolution", - mutate: func(dependencies runtimeAssetDependencies) runtimeAssetDependencies { - dependencies.manifestPin = func(release.RuntimeLock) (release.RuntimeManifestPin, error) { - return release.RuntimeManifestPin{}, errors.New("corrupt embedded pin") - } - return dependencies - }, - }, - { - name: "validation", - mutate: func(dependencies runtimeAssetDependencies) runtimeAssetDependencies { - valid := dependencies.manifestPin - dependencies.manifestPin = func(lock release.RuntimeLock) (release.RuntimeManifestPin, error) { - pin, err := valid(lock) - pin.RuntimeVersion = "9.9.9" - return pin, err - } - return dependencies - }, - }, - } { - t.Run(test.name, func(t *testing.T) { - cacheRoot := t.TempDir() - dependencies := test.mutate(fixture.dependencies(cacheRoot, fixture.fetcher(nil, new(int)))) - binCalls := 0 - dependencies.goBin = func(context.Context, Config, []string) (string, error) { - binCalls++ - return t.TempDir(), nil - } - if _, err := acquireRuntimeAssetsWith(context.Background(), sourceRuntimeConfig(t.TempDir(), cacheRoot), IO{Env: []string{}}, fixture.lock, dependencies); err == nil { - t.Fatal("manifest pin error was accepted") - } - if binCalls != 0 { - t.Fatalf("Go-bin calls = %d, want 0", binCalls) - } - }) + wrongVersion := fixture.manifest + wrongVersion.RuntimeVersion = "9.9.9" + data, err := wrongVersion.JSON() + if err != nil { + t.Fatal(err) + } + cacheRoot := t.TempDir() + dependencies := fixture.dependencies(cacheRoot, fixture.fetcher(map[string][]byte{fixture.lock.Manifest: data}, new(int))) + binCalls := 0 + dependencies.goBin = func(context.Context, Config, []string) (string, error) { + binCalls++ + return t.TempDir(), nil + } + if _, err := acquireRuntimeAssetsWith(context.Background(), sourceRuntimeConfig(t.TempDir(), cacheRoot), IO{Env: []string{}}, fixture.lock, dependencies); err == nil || !strings.Contains(err.Error(), "does not match") { + t.Fatalf("runtime manifest version mismatch = %v", err) + } + if binCalls != 0 { + t.Fatalf("Go-bin calls = %d, want 0", binCalls) } } diff --git a/internal/launchpack/runtime_source_validation_test.go b/internal/launchpack/runtime_source_validation_test.go index 00d7e4ee1..856c25ee8 100644 --- a/internal/launchpack/runtime_source_validation_test.go +++ b/internal/launchpack/runtime_source_validation_test.go @@ -19,6 +19,7 @@ package launchpack import ( "context" "errors" + "fmt" "io" "os" "os/exec" @@ -27,6 +28,7 @@ import ( "strings" "testing" + "github.com/goplus/spx/v3/internal/envutil" "github.com/goplus/spx/v3/internal/release" ) @@ -76,7 +78,7 @@ func TestSourceRuntimeRejectsInvalidInstalledPair(t *testing.T) { test.setup(t, bin) cacheRoot := t.TempDir() dependencies := localRuntimeTestDependencies(cacheRoot, func(context.Context, string, io.Writer) error { - return errors.New("fetch must not run") + return fmt.Errorf("%w: unpublished runtime", errReleaseUnavailable) }) dependencies.goBin = func(context.Context, Config, []string) (string, error) { return bin, nil } _, err := acquireRuntimeAssetsWith(context.Background(), sourceRuntimeConfig(t.TempDir(), cacheRoot), IO{Env: []string{}}, lock, dependencies) @@ -140,7 +142,7 @@ func TestSourceManifestUsesVersionValidationButExplicitStaysStrict(t *testing.T) } cacheRoot := t.TempDir() dependencies := localRuntimeTestDependencies(cacheRoot, func(context.Context, string, io.Writer) error { - return errors.New("unpublished runtime must not fetch") + return fmt.Errorf("%w: unpublished runtime", errReleaseUnavailable) }) binCalls := 0 dependencies.goBin = func(context.Context, Config, []string) (string, error) { @@ -174,10 +176,10 @@ func TestSourceRuntimePassesAcquisitionEnvironmentToGoBin(t *testing.T) { bin := writeInstalledRuntimeTest(t, spec, "engine", "pack") cacheRoot := t.TempDir() dependencies := localRuntimeTestDependencies(cacheRoot, func(context.Context, string, io.Writer) error { - return errors.New("fetch must not run") + return fmt.Errorf("%w: unpublished runtime", errReleaseUnavailable) }) dependencies.goBin = func(_ context.Context, _ Config, env []string) (string, error) { - value, found, duplicate := environmentValue(env, "GOPATH") + value, found, duplicate := envutil.Lookup(env, "GOPATH") if !found || duplicate || value != "/frozen-gopath" { return "", errors.New("acquisition environment was not forwarded") } @@ -238,14 +240,14 @@ func TestSourceRuntimeMissingFilesSuggestsMakeDev(t *testing.T) { lock := release.DefaultRuntimeLock() cacheRoot := t.TempDir() dependencies := localRuntimeTestDependencies(cacheRoot, func(context.Context, string, io.Writer) error { - return errors.New("fetch must not run") + return fmt.Errorf("%w: missing release", errReleaseUnavailable) }) dependencies.goBin = func(context.Context, Config, []string) (string, error) { return t.TempDir(), nil } _, err := acquireRuntimeAssetsWith(context.Background(), sourceRuntimeConfig(t.TempDir(), cacheRoot), IO{Env: []string{}}, lock, dependencies) if err == nil || !strings.Contains(err.Error(), "make dev") || !strings.Contains(err.Error(), "gdspxrt"+lock.RuntimeVersion) { t.Fatalf("missing local runtime error = %v", err) } - if !errors.Is(err, release.ErrRuntimeManifestPinNotFound) { + if !errors.Is(err, errReleaseUnavailable) { t.Fatalf("missing local runtime lost published cause: %v", err) } } diff --git a/internal/launchpack/service.go b/internal/launchpack/service.go index 70c7077a4..aff37db40 100644 --- a/internal/launchpack/service.go +++ b/internal/launchpack/service.go @@ -27,8 +27,11 @@ import ( // AcquireRuntimeAssets resolves and materializes one verified Engine/PCK pair. // Explicit local settings take priority. Source checkouts may use an exact -// GOPATH/bin runtime when the pinned release is unavailable. +// GOPATH/bin runtime when the selected versioned 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..a0d356524 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,28 @@ 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 + 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/launchpack/versioned_release_manifest.go b/internal/launchpack/versioned_release_manifest.go new file mode 100644 index 000000000..e63bc53dc --- /dev/null +++ b/internal/launchpack/versioned_release_manifest.go @@ -0,0 +1,262 @@ +/* + * 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" + "os" + "path/filepath" + "strings" + + "github.com/goplus/spx/v3/internal/runtimebundle" +) + +const versionedReleaseManifestCacheDirectory = "release-manifests" + +var errVersionedReleaseManifestTooLarge = errors.New("launchpack: release manifest exceeds size limit") + +// versionedReleaseManifestSpec identifies a manifest by its release version. +// Namespace, Version, and Name form its deterministic cache key. MirrorDir is +// an explicit local release mirror and always takes precedence over the cache. +type versionedReleaseManifestSpec struct { + CacheRoot string + Namespace string + Version string + Name string + URL string + MirrorDir string + Offline bool + MaxSize int64 + Fetch runtimebundle.FetchFunc +} + +// acquireVersionedReleaseManifest loads a version-addressed release manifest. +// Every mirror or cache hit is parsed again and checked against spec.Version. +// The returned bytes let callers derive a content digest without introducing a +// second pin or trust root. +func acquireVersionedReleaseManifest[T any]( + ctx context.Context, + spec versionedReleaseManifestSpec, + parse func([]byte) (T, error), + manifestVersion func(T) string, +) (T, []byte, error) { + var zero T + if ctx == nil { + return zero, nil, errors.New("launchpack: nil context") + } + if err := ctx.Err(); err != nil { + return zero, nil, err + } + if err := validateVersionedReleaseManifestSpec(spec); err != nil { + return zero, nil, err + } + if parse == nil || manifestVersion == nil { + return zero, nil, errors.New("launchpack: release manifest parser and version accessor are required") + } + + load := func(path string) (T, []byte, error) { + manifest, data, err := loadVersionedReleaseManifest(path, spec.MaxSize, parse) + if err != nil { + return zero, nil, err + } + if got := manifestVersion(manifest); got != spec.Version { + return zero, nil, fmt.Errorf("launchpack: release manifest version %q does not match %q", got, spec.Version) + } + return manifest, data, nil + } + + if spec.MirrorDir != "" { + path := filepath.Join(spec.MirrorDir, spec.Name) + manifest, data, err := load(path) + if err != nil { + return zero, nil, fmt.Errorf("launchpack: read mirrored release manifest: %w", err) + } + return manifest, data, nil + } + + cacheDirectory := filepath.Join( + spec.CacheRoot, + versionedReleaseManifestCacheDirectory, + spec.Namespace, + spec.Version, + ) + cachePath := filepath.Join(cacheDirectory, spec.Name) + if info, err := os.Lstat(cachePath); err == nil { + if info.Mode()&os.ModeSymlink == 0 && info.Mode().IsRegular() { + if _, data, loadErr := load(cachePath); loadErr == nil { + manifest, data, acquireErr := acquireCachedVersionedReleaseManifest(ctx, cacheDirectory, spec, data, parse, manifestVersion, true) + if acquireErr == nil { + return manifest, data, nil + } + if spec.Offline { + return zero, nil, fmt.Errorf("launchpack: offline cached release manifest is invalid: %w", acquireErr) + } + } else if spec.Offline { + return zero, nil, fmt.Errorf("launchpack: offline cached release manifest is invalid: %w", loadErr) + } + } else if spec.Offline { + return zero, nil, fmt.Errorf("launchpack: offline cached release manifest %q is not a regular non-symlink file", cachePath) + } + } else if !os.IsNotExist(err) { + return zero, nil, fmt.Errorf("launchpack: inspect cached release manifest: %w", err) + } + if spec.Offline { + return zero, nil, fmt.Errorf("%w for %s/%s/%s", runtimebundle.ErrOfflineCacheMiss, spec.Namespace, spec.Version, spec.Name) + } + if spec.Fetch == nil { + return zero, nil, errors.New("launchpack: release manifest fetcher is required") + } + + var downloaded bytes.Buffer + limited := &versionedReleaseManifestWriter{destination: &downloaded, remaining: spec.MaxSize} + if err := spec.Fetch(ctx, spec.URL, limited); err != nil { + return zero, nil, fmt.Errorf("launchpack: fetch release manifest: %w", err) + } + if limited.exceeded { + return zero, nil, errVersionedReleaseManifestTooLarge + } + if err := ctx.Err(); err != nil { + return zero, nil, err + } + data := downloaded.Bytes() + manifest, err := parse(data) + if err != nil { + return zero, nil, fmt.Errorf("launchpack: parse downloaded release manifest: %w", err) + } + if got := manifestVersion(manifest); got != spec.Version { + return zero, nil, fmt.Errorf("launchpack: downloaded release manifest version %q does not match %q", got, spec.Version) + } + return acquireCachedVersionedReleaseManifest(ctx, cacheDirectory, spec, data, parse, manifestVersion, false) +} + +func validateVersionedReleaseManifestSpec(spec versionedReleaseManifestSpec) error { + if spec.CacheRoot == "" || !filepath.IsAbs(spec.CacheRoot) || filepath.Clean(spec.CacheRoot) != spec.CacheRoot { + return fmt.Errorf("launchpack: release manifest cache root must be an absolute clean path") + } + for _, item := range []struct{ label, value string }{ + {"namespace", spec.Namespace}, {"version", spec.Version}, {"name", spec.Name}, + } { + if !validVersionedReleaseManifestKeyPart(item.value) { + return fmt.Errorf("launchpack: invalid release manifest %s %q", item.label, item.value) + } + } + if spec.MirrorDir != "" && (!filepath.IsAbs(spec.MirrorDir) || filepath.Clean(spec.MirrorDir) != spec.MirrorDir) { + return fmt.Errorf("launchpack: release manifest mirror must be an absolute clean path") + } + if spec.MaxSize <= 0 { + return errors.New("launchpack: release manifest size limit must be positive") + } + if strings.TrimSpace(spec.URL) == "" { + return errors.New("launchpack: release manifest URL is required") + } + return nil +} + +func validVersionedReleaseManifestKeyPart(value string) bool { + if value == "" || value == "." || value == ".." || value != strings.TrimSpace(value) || filepath.Base(value) != value { + return false + } + return !strings.ContainsAny(value, "/\\\x00") +} + +func loadVersionedReleaseManifest[T any](path string, maxSize int64, parse func([]byte) (T, error)) (T, []byte, error) { + var zero T + file, err := openPinnedFile("release manifest", path) + if err != nil { + return zero, nil, err + } + defer file.file.Close() + data, err := io.ReadAll(io.LimitReader(file.file, maxSize+1)) + if err != nil { + return zero, nil, err + } + if int64(len(data)) > maxSize { + return zero, nil, errVersionedReleaseManifestTooLarge + } + if err := file.verify(); err != nil { + return zero, nil, err + } + manifest, err := parse(data) + if err != nil { + return zero, nil, err + } + return manifest, data, nil +} + +func acquireCachedVersionedReleaseManifest[T any]( + ctx context.Context, + cacheDirectory string, + spec versionedReleaseManifestSpec, + data []byte, + parse func([]byte) (T, error), + manifestVersion func(T) string, + offline bool, +) (T, []byte, error) { + var zero T + file, err := runtimebundle.AcquireFile(ctx, cacheDirectory, runtimebundle.FetchSpec{ + Name: spec.Name, URL: spec.URL, Size: int64(len(data)), SHA256: digestBytes(data), Offline: offline, + Fetch: func(ctx context.Context, _ string, dst io.Writer) error { + if err := ctx.Err(); err != nil { + return err + } + _, err := io.Copy(dst, bytes.NewReader(data)) + return err + }, + }) + if err != nil { + return zero, nil, fmt.Errorf("launchpack: cache release manifest: %w", err) + } + cached, readErr := io.ReadAll(io.LimitReader(file, spec.MaxSize+1)) + closeErr := file.Close() + if readErr != nil { + return zero, nil, fmt.Errorf("launchpack: read cached release manifest: %w", readErr) + } + if closeErr != nil { + return zero, nil, fmt.Errorf("launchpack: close cached release manifest: %w", closeErr) + } + if int64(len(cached)) > spec.MaxSize { + return zero, nil, errVersionedReleaseManifestTooLarge + } + manifest, err := parse(cached) + if err != nil { + return zero, nil, fmt.Errorf("launchpack: parse cached release manifest: %w", err) + } + if got := manifestVersion(manifest); got != spec.Version { + return zero, nil, fmt.Errorf("launchpack: cached release manifest version %q does not match %q", got, spec.Version) + } + return manifest, cached, nil +} + +type versionedReleaseManifestWriter struct { + destination io.Writer + remaining int64 + exceeded bool +} + +func (w *versionedReleaseManifestWriter) Write(data []byte) (int, error) { + if int64(len(data)) > w.remaining { + w.exceeded = true + return 0, errVersionedReleaseManifestTooLarge + } + n, err := w.destination.Write(data) + w.remaining -= int64(n) + return n, err +} diff --git a/internal/launchpack/versioned_release_manifest_test.go b/internal/launchpack/versioned_release_manifest_test.go new file mode 100644 index 000000000..d40dec5d6 --- /dev/null +++ b/internal/launchpack/versioned_release_manifest_test.go @@ -0,0 +1,177 @@ +/* + * 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" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "testing" + + "github.com/goplus/spx/v3/internal/runtimebundle" +) + +type testVersionedManifest struct { + Version string `json:"version"` + Value string `json:"value"` +} + +func TestVersionedReleaseManifestPrefersLocalMirror(t *testing.T) { + cacheRoot, mirrorDir := t.TempDir(), t.TempDir() + spec := testVersionedReleaseManifestSpec(cacheRoot) + spec.MirrorDir = mirrorDir + writeVersionedReleaseManifestTestFile(t, testVersionedReleaseManifestPath(spec), []byte(`{"version":"1.2.3","value":"cache"}`)) + mirrorData := []byte(`{"version":"1.2.3","value":"mirror"}`) + writeVersionedReleaseManifestTestFile(t, filepath.Join(mirrorDir, spec.Name), mirrorData) + fetchCalls := 0 + spec.Fetch = func(context.Context, string, io.Writer) error { + fetchCalls++ + return nil + } + + manifest, data, err := acquireTestVersionedReleaseManifest(spec) + if err != nil { + t.Fatal(err) + } + if manifest.Value != "mirror" || !bytes.Equal(data, mirrorData) { + t.Fatalf("mirrored manifest = %#v, %q", manifest, data) + } + if fetchCalls != 0 { + t.Fatalf("fetch calls = %d, want 0", fetchCalls) + } +} + +func TestVersionedReleaseManifestRevalidatesAndRefreshesCache(t *testing.T) { + cacheRoot := t.TempDir() + spec := testVersionedReleaseManifestSpec(cacheRoot) + cachePath := testVersionedReleaseManifestPath(spec) + writeVersionedReleaseManifestTestFile(t, cachePath, []byte(`{"version":"9.9.9","value":"stale"}`)) + fresh := []byte(`{"version":"1.2.3","value":"fresh"}`) + fetchCalls := 0 + spec.Fetch = func(_ context.Context, _ string, dst io.Writer) error { + fetchCalls++ + _, err := dst.Write(fresh) + return err + } + + manifest, data, err := acquireTestVersionedReleaseManifest(spec) + if err != nil { + t.Fatal(err) + } + if manifest.Value != "fresh" || !bytes.Equal(data, fresh) || fetchCalls != 1 { + t.Fatalf("refreshed manifest = %#v, %q; fetch calls = %d", manifest, data, fetchCalls) + } + if cached, err := os.ReadFile(cachePath); err != nil || !bytes.Equal(cached, fresh) { + t.Fatalf("cached manifest = %q, err = %v", cached, err) + } + + spec.Offline = true + spec.Fetch = func(context.Context, string, io.Writer) error { + t.Fatal("offline cache hit fetched") + return nil + } + if manifest, data, err := acquireTestVersionedReleaseManifest(spec); err != nil || manifest.Value != "fresh" || !bytes.Equal(data, fresh) { + t.Fatalf("offline cache hit = %#v, %q, %v", manifest, data, err) + } + + if err := os.WriteFile(cachePath, []byte(`{"version":`), 0o644); err != nil { + t.Fatal(err) + } + if _, _, err := acquireTestVersionedReleaseManifest(spec); err == nil { + t.Fatal("offline corrupt cache was accepted") + } +} + +func TestVersionedReleaseManifestOfflineCacheMiss(t *testing.T) { + spec := testVersionedReleaseManifestSpec(t.TempDir()) + spec.Offline = true + spec.Fetch = nil + if _, _, err := acquireTestVersionedReleaseManifest(spec); !errors.Is(err, runtimebundle.ErrOfflineCacheMiss) { + t.Fatalf("offline cache miss = %v", err) + } +} + +func TestVersionedReleaseManifestRejectsOversizedDownloadWithoutPublishing(t *testing.T) { + spec := testVersionedReleaseManifestSpec(t.TempDir()) + spec.MaxSize = 8 + spec.Fetch = func(_ context.Context, _ string, dst io.Writer) error { + _, err := dst.Write([]byte(`{"version":"1.2.3"}`)) + return err + } + if _, _, err := acquireTestVersionedReleaseManifest(spec); !errors.Is(err, errVersionedReleaseManifestTooLarge) { + t.Fatalf("oversized manifest error = %v", err) + } + if _, err := os.Lstat(testVersionedReleaseManifestPath(spec)); !os.IsNotExist(err) { + t.Fatalf("oversized manifest was published: %v", err) + } +} + +func TestVersionedReleaseManifestFailedRefreshPreservesCache(t *testing.T) { + spec := testVersionedReleaseManifestSpec(t.TempDir()) + cachePath := testVersionedReleaseManifestPath(spec) + stale := []byte(`{"version":"9.9.9","value":"stale"}`) + writeVersionedReleaseManifestTestFile(t, cachePath, stale) + spec.Fetch = func(_ context.Context, _ string, dst io.Writer) error { + _, err := dst.Write([]byte(`{"version":"8.8.8","value":"wrong"}`)) + return err + } + if _, _, err := acquireTestVersionedReleaseManifest(spec); err == nil { + t.Fatal("downloaded wrong-version manifest was accepted") + } + if cached, err := os.ReadFile(cachePath); err != nil || !bytes.Equal(cached, stale) { + t.Fatalf("failed refresh changed cache to %q, err = %v", cached, err) + } +} + +func testVersionedReleaseManifestSpec(cacheRoot string) versionedReleaseManifestSpec { + return versionedReleaseManifestSpec{ + CacheRoot: cacheRoot, + Namespace: "driver", + Version: "1.2.3", + Name: "manifest.json", + URL: "https://example.invalid/manifest.json", + MaxSize: 1024, + } +} + +func testVersionedReleaseManifestPath(spec versionedReleaseManifestSpec) string { + return filepath.Join(spec.CacheRoot, versionedReleaseManifestCacheDirectory, spec.Namespace, spec.Version, spec.Name) +} + +func acquireTestVersionedReleaseManifest(spec versionedReleaseManifestSpec) (testVersionedManifest, []byte, error) { + return acquireVersionedReleaseManifest(context.Background(), spec, func(data []byte) (testVersionedManifest, error) { + var manifest testVersionedManifest + err := json.Unmarshal(data, &manifest) + return manifest, err + }, func(manifest testVersionedManifest) string { + return manifest.Version + }) +} + +func writeVersionedReleaseManifestTestFile(t *testing.T, path string, data []byte) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/internal/release/release_meta.go b/internal/release/release_meta.go index 105971e7b..b6471ad5e 100644 --- a/internal/release/release_meta.go +++ b/internal/release/release_meta.go @@ -63,8 +63,8 @@ type spxRuntimeMapping struct { // Legacy releases predate immutable runtime lock snapshots and therefore keep // their historical split asset locations here. Atomic releases are derived -// from versioned snapshots below, so their repository/tag/manifest cannot -// drift from the provenance used to verify them. +// from versioned snapshots below, so their repository/tag/manifest stay tied +// to the selected runtime version. var legacyRuntimeReleaseDefinitions = []RuntimeRelease{ newLegacyRuntimeRelease("2.2.0", "v2.0.0", "gdspxrt.pck.2.2.0.zip"), newLegacyRuntimeRelease("2.2.1", "v2.0.1", RuntimeAssetZipName), diff --git a/internal/release/runtime_manifest.go b/internal/release/runtime_manifest.go index c11b99313..e21e9ea0e 100644 --- a/internal/release/runtime_manifest.go +++ b/internal/release/runtime_manifest.go @@ -113,8 +113,7 @@ func GenerateRuntimeManifest(lock RuntimeLock, provenance RuntimeProvenance, inp return manifest, nil } -// ParseRuntimeManifest decodes and structurally validates a manifest. Use -// ValidateForLock as well when consuming a release for a known lock. +// ParseRuntimeManifest decodes and structurally validates a manifest. func ParseRuntimeManifest(data []byte) (RuntimeManifest, error) { var manifest RuntimeManifest if err := strictjson.Decode(data, &manifest); err != nil { @@ -126,6 +125,23 @@ func ParseRuntimeManifest(data []byte) (RuntimeManifest, error) { return manifest, nil } +// ParseRuntimeManifestForRelease decodes a manifest and binds it to the +// selected runtime version and required release asset set without repeating +// structural validation. +func ParseRuntimeManifestForRelease(data []byte, runtimeVersion string, requiredAssets []string) (RuntimeManifest, error) { + manifest, err := ParseRuntimeManifest(data) + if err != nil { + return RuntimeManifest{}, err + } + if err := manifest.validateForVersion(runtimeVersion); err != nil { + return RuntimeManifest{}, err + } + if err := manifest.validateRequiredAssets(requiredAssets); err != nil { + return RuntimeManifest{}, err + } + return manifest, nil +} + // LoadRuntimeManifest reads and parses a runtime manifest file. func LoadRuntimeManifest(path string) (RuntimeManifest, error) { data, err := os.ReadFile(path) @@ -192,8 +208,58 @@ func (m RuntimeManifest) Validate() error { return nil } -// ValidateForLock verifies runtime identity, lock digest, pinned provenance, -// toolchain versions, and the presence of every required asset. +// ValidateForVersion checks a structurally valid manifest against the only +// compatibility identity used by release consumers: the runtime version. +func (m RuntimeManifest) ValidateForVersion(runtimeVersion string) error { + if err := m.Validate(); err != nil { + return err + } + return m.validateForVersion(runtimeVersion) +} + +func (m RuntimeManifest) validateForVersion(runtimeVersion string) error { + if !runtimeVersionPattern.MatchString(runtimeVersion) { + return fmt.Errorf("release: invalid expected runtime version %q", runtimeVersion) + } + if m.RuntimeVersion != runtimeVersion { + return fmt.Errorf("release: runtime manifest version %q does not match %q", m.RuntimeVersion, runtimeVersion) + } + return nil +} + +// ValidateRequiredAssets checks that the manifest contains exactly the named +// release assets. Content size and SHA-256 validation remains part of Validate. +func (m RuntimeManifest) ValidateRequiredAssets(requiredAssets []string) error { + if err := m.Validate(); err != nil { + return err + } + return m.validateRequiredAssets(requiredAssets) +} + +func (m RuntimeManifest) validateRequiredAssets(requiredAssets []string) error { + required := make(map[string]struct{}, len(requiredAssets)) + for _, name := range requiredAssets { + if err := validateBaseName("required runtime asset", name); err != nil { + return err + } + if _, exists := required[name]; exists { + return fmt.Errorf("release: duplicate required runtime asset %q", name) + } + required[name] = struct{}{} + } + if len(m.Assets) != len(required) { + return fmt.Errorf("release: runtime manifest has %d assets, release requires exactly %d", len(m.Assets), len(required)) + } + for _, asset := range m.Assets { + if _, ok := required[asset.Name]; !ok { + return fmt.Errorf("release: runtime asset %q is not required", asset.Name) + } + } + return nil +} + +// ValidateForLock is producer-side validation for metadata generated from one +// exact build lock. Published consumers use ParseRuntimeManifestForRelease. func (m RuntimeManifest) ValidateForLock(lock RuntimeLock) error { if err := lock.Validate(); err != nil { return err @@ -201,11 +267,14 @@ func (m RuntimeManifest) ValidateForLock(lock RuntimeLock) error { if err := m.Validate(); err != nil { return err } + if err := m.validateForVersion(lock.RuntimeVersion); err != nil { + return err + } lockSHA256, err := lock.SHA256() if err != nil { return err } - if m.RuntimeVersion != lock.RuntimeVersion || m.RuntimeABI != lock.RuntimeABI { + if m.RuntimeABI != lock.RuntimeABI { return fmt.Errorf("release: runtime manifest identity does not match lock") } if m.ReleaseRepository != lock.ReleaseRepository { @@ -221,26 +290,7 @@ func (m RuntimeManifest) ValidateForLock(lock RuntimeLock) error { return errors.New("release: manifest toolchain does not match lock") } - required := make(map[string]struct{}, len(lock.RequiredAssets)) - for _, name := range lock.RequiredAssets { - required[name] = struct{}{} - } - if len(m.Assets) != len(required) { - return fmt.Errorf("release: runtime manifest has %d assets, lock requires exactly %d", len(m.Assets), len(required)) - } - present := make(map[string]struct{}, len(m.Assets)) - for _, asset := range m.Assets { - if _, ok := required[asset.Name]; !ok { - return fmt.Errorf("release: runtime asset %q is not declared by the lock", asset.Name) - } - present[asset.Name] = struct{}{} - } - for _, required := range lock.RequiredAssets { - if _, ok := present[required]; !ok { - return fmt.Errorf("release: required runtime asset %q is missing", required) - } - } - return nil + return m.validateRequiredAssets(lock.RequiredAssets) } // JSON returns the canonical, human-readable representation of a manifest. diff --git a/internal/release/runtime_manifest_pin.go b/internal/release/runtime_manifest_pin.go deleted file mode 100644 index 9d5b9ed25..000000000 --- a/internal/release/runtime_manifest_pin.go +++ /dev/null @@ -1,136 +0,0 @@ -/* - * 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 release - -import ( - "crypto/sha256" - "embed" - "errors" - "fmt" - "io/fs" - "path" - "strings" - - "github.com/goplus/spx/v3/internal/strictjson" -) - -const runtimeManifestPinSchema = 1 - -// ErrRuntimeManifestPinNotFound marks an unpublished runtime version. -var ErrRuntimeManifestPinNotFound = errors.New("release: runtime manifest pin not found") - -// RuntimeManifestPin pins a release manifest independently of RuntimeLock. -type RuntimeManifestPin struct { - Schema int `json:"schema"` - RuntimeVersion string `json:"runtime_version"` - Name string `json:"name"` - Size int64 `json:"size"` - SHA256 string `json:"sha256"` -} - -var ( - //go:embed runtime_manifest_pins/*.json - embeddedRuntimeManifestPins embed.FS - runtimeManifestPins = mustLoadRuntimeManifestPins(embeddedRuntimeManifestPins) -) - -func parseRuntimeManifestPin(data []byte) (RuntimeManifestPin, error) { - var pin RuntimeManifestPin - if err := strictjson.Decode(data, &pin); err != nil { - return RuntimeManifestPin{}, fmt.Errorf("decode runtime manifest pin: %w", err) - } - if err := pin.validate(); err != nil { - return RuntimeManifestPin{}, err - } - return pin, nil -} - -func (p RuntimeManifestPin) validate() error { - if p.Schema != runtimeManifestPinSchema { - return fmt.Errorf("release: runtime manifest pin schema = %d, want %d", p.Schema, runtimeManifestPinSchema) - } - if !runtimeVersionPattern.MatchString(p.RuntimeVersion) { - return fmt.Errorf("release: invalid pinned runtime version %q", p.RuntimeVersion) - } - if err := validateBaseName("pinned runtime manifest", p.Name); err != nil { - return err - } - if p.Size <= 0 { - return fmt.Errorf("release: pinned runtime manifest size must be positive") - } - if !isLowerHexDigest(p.SHA256, sha256.Size*2) { - return fmt.Errorf("release: invalid pinned runtime manifest SHA-256 %q", p.SHA256) - } - return nil -} - -// ValidateForLock checks a pin against one runtime lock. -func (p RuntimeManifestPin) ValidateForLock(lock RuntimeLock) error { - if err := lock.Validate(); err != nil { - return err - } - if err := p.validate(); err != nil { - return err - } - if p.RuntimeVersion != lock.RuntimeVersion || p.Name != lock.Manifest { - return fmt.Errorf("release: runtime manifest pin does not match lock") - } - return nil -} - -// RuntimeManifestPinForLock returns the pinned manifest identity for lock. -// Missing pins fail closed. -func RuntimeManifestPinForLock(lock RuntimeLock) (RuntimeManifestPin, error) { - if err := lock.Validate(); err != nil { - return RuntimeManifestPin{}, err - } - pin, ok := runtimeManifestPins[lock.RuntimeVersion] - if !ok { - return RuntimeManifestPin{}, fmt.Errorf("%w: no runtime manifest pin for version %q", ErrRuntimeManifestPinNotFound, lock.RuntimeVersion) - } - if err := pin.ValidateForLock(lock); err != nil { - return RuntimeManifestPin{}, err - } - return pin, nil -} - -func mustLoadRuntimeManifestPins(fileSystem fs.FS) map[string]RuntimeManifestPin { - files, err := fs.Glob(fileSystem, "runtime_manifest_pins/*.json") - if err != nil { - panic("release: list runtime manifest pins: " + err.Error()) - } - pins := make(map[string]RuntimeManifestPin, len(files)) - for _, file := range files { - data, err := fs.ReadFile(fileSystem, file) - if err != nil { - panic("release: read runtime manifest pin: " + err.Error()) - } - pin, err := parseRuntimeManifestPin(data) - if err != nil { - panic("release: invalid runtime manifest pin: " + err.Error()) - } - version := strings.TrimSuffix(path.Base(file), ".json") - if pin.RuntimeVersion != version { - panic(fmt.Sprintf("release: runtime manifest pin %s declares version %q", file, pin.RuntimeVersion)) - } - if _, exists := pins[version]; exists { - panic("release: duplicate runtime manifest pin for " + version) - } - pins[version] = pin - } - return pins -} diff --git a/internal/release/runtime_manifest_pin_test.go b/internal/release/runtime_manifest_pin_test.go deleted file mode 100644 index 27db87c1a..000000000 --- a/internal/release/runtime_manifest_pin_test.go +++ /dev/null @@ -1,89 +0,0 @@ -/* - * 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 release - -import ( - "errors" - "strings" - "testing" -) - -func TestRuntimeManifestPinsMatchLocks(t *testing.T) { - if len(runtimeManifestPins) == 0 { - t.Fatal("no runtime manifest pins") - } - for version := range runtimeManifestPins { - t.Run(version, func(t *testing.T) { - lock, err := RuntimeLockForVersion(version) - if err != nil { - t.Fatal(err) - } - pin, err := RuntimeManifestPinForLock(lock) - if err != nil { - t.Fatal(err) - } - if pin.RuntimeVersion != lock.RuntimeVersion || pin.Name != lock.Manifest || pin.Size <= 0 || len(pin.SHA256) != 64 { - t.Fatalf("runtime manifest pin = %#v", pin) - } - }) - } -} - -func TestRuntimeManifestPinValidation(t *testing.T) { - lock, err := RuntimeLockForVersion("2.4.1") - if err != nil { - t.Fatal(err) - } - pin, err := RuntimeManifestPinForLock(lock) - if err != nil { - t.Fatal(err) - } - for _, mutate := range []func(*RuntimeManifestPin){ - func(p *RuntimeManifestPin) { p.Schema++ }, - func(p *RuntimeManifestPin) { p.RuntimeVersion = "invalid" }, - func(p *RuntimeManifestPin) { p.Name = "../manifest.json" }, - func(p *RuntimeManifestPin) { p.Size = 0 }, - func(p *RuntimeManifestPin) { p.SHA256 = strings.Repeat("A", 64) }, - } { - candidate := pin - mutate(&candidate) - if err := candidate.validate(); err == nil { - t.Fatalf("invalid runtime manifest pin accepted: %#v", candidate) - } - } -} - -func TestRuntimeManifestPinForLockRejectsUnpinnedRuntime(t *testing.T) { - lock := DefaultRuntimeLock() - lock.RuntimeVersion = "9.9.9" - if _, err := RuntimeManifestPinForLock(lock); err == nil || !errors.Is(err, ErrRuntimeManifestPinNotFound) || !strings.Contains(err.Error(), "no runtime manifest pin") { - t.Fatalf("RuntimeManifestPinForLock error = %v", err) - } -} - -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) - } - if _, ok := runtimeManifestPins[lock.RuntimeVersion]; ok { - t.Fatalf("runtime manifest pin unexpectedly exists for unpublished %s", lock.RuntimeVersion) - } - if _, err := RuntimeManifestPinForLock(lock); err == nil || !strings.Contains(err.Error(), "no runtime manifest pin") { - t.Fatalf("RuntimeManifestPinForLock error = %v, want missing-pin failure", err) - } -} diff --git a/internal/release/runtime_manifest_pins/2.4.1.json b/internal/release/runtime_manifest_pins/2.4.1.json deleted file mode 100644 index 1d66fa644..000000000 --- a/internal/release/runtime_manifest_pins/2.4.1.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "schema": 1, - "runtime_version": "2.4.1", - "name": "runtime-manifest.json", - "size": 3563, - "sha256": "7fe10dfa089b62cffc57ba820deff4de36a5b84ec831803fa07cd5e9336b3be5" -} diff --git a/internal/release/runtime_manifest_pins/2.4.2.json b/internal/release/runtime_manifest_pins/2.4.2.json deleted file mode 100644 index 27a8bfaa4..000000000 --- a/internal/release/runtime_manifest_pins/2.4.2.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "schema": 1, - "runtime_version": "2.4.2", - "name": "runtime-manifest.json", - "size": 3563, - "sha256": "5a2f0621393c42c7ecf83c7fcda3a73fd6aac0149e1c4a375a9b2da602a16bd8" -} diff --git a/internal/release/runtime_manifest_pins/2.4.3.json b/internal/release/runtime_manifest_pins/2.4.3.json deleted file mode 100644 index 6d924aa98..000000000 --- a/internal/release/runtime_manifest_pins/2.4.3.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "schema": 1, - "runtime_version": "2.4.3", - "name": "runtime-manifest.json", - "size": 3563, - "sha256": "74252ff398cfeeb571adb3414b988a61a33ea028a7462b2e0e804c589856027d" -} diff --git a/internal/release/runtime_manifest_test.go b/internal/release/runtime_manifest_test.go index 2e10180d4..64b1644c6 100644 --- a/internal/release/runtime_manifest_test.go +++ b/internal/release/runtime_manifest_test.go @@ -154,6 +154,42 @@ func TestRuntimeManifestValidateForLockRejectsExtraAsset(t *testing.T) { } } +func TestRuntimeManifestValidateForVersionIgnoresBuildMetadata(t *testing.T) { + lock, provenance, inputs, _ := runtimeManifestFixture(t) + manifest, err := GenerateRuntimeManifest(lock, provenance, inputs) + if err != nil { + t.Fatal(err) + } + manifest.RuntimeABI++ + manifest.ReleaseRepository = "example/runtime" + manifest.LockSHA256 = strings.Repeat("0", 64) + manifest.Provenance.SPXCommit = strings.Repeat("1", 40) + manifest.Provenance.GodotCommit = strings.Repeat("2", 40) + manifest.Provenance.ModuleTree = strings.Repeat("3", 40) + manifest.Provenance.RuntimePackSourceSHA256 = strings.Repeat("4", 64) + manifest.Provenance.BuildRecipeSHA256 = strings.Repeat("5", 64) + manifest.Provenance.Toolchain = ToolchainLock{ + Go: "9.9.9", XGo: "9.9.9", SCons: "9.9.9", EMSDK: "9.9.9", AndroidNDK: "r99", JDK: "99", + } + if err := manifest.ValidateForVersion(lock.RuntimeVersion); err != nil { + t.Fatalf("version-compatible manifest rejected stale build metadata: %v", err) + } + if err := manifest.ValidateRequiredAssets(lock.RequiredAssets); err != nil { + t.Fatalf("required asset set rejected: %v", err) + } +} + +func TestRuntimeManifestValidateForVersionRejectsMismatch(t *testing.T) { + lock, provenance, inputs, _ := runtimeManifestFixture(t) + manifest, err := GenerateRuntimeManifest(lock, provenance, inputs) + if err != nil { + t.Fatal(err) + } + if err := manifest.ValidateForVersion("9.9.9"); err == nil || !strings.Contains(err.Error(), "does not match") { + t.Fatalf("version mismatch error = %v", err) + } +} + func TestRuntimeManifestValidation(t *testing.T) { lock, provenance, inputs, _ := runtimeManifestFixture(t) original, err := GenerateRuntimeManifest(lock, provenance, inputs) 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..a6fb2af06 --- /dev/null +++ b/internal/xgodriver/argv_test.go @@ -0,0 +1,431 @@ +/* + * 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"}, + {"incomplete local replacement", func(t *testing.T) []string { + args := removeOptions(validArgs(t, ActionRun), "selected-dir", "selected-gomod") + root := filepath.Dir(optionValue(args, "project-dir")) + return append(args, "--replace-path="+filepath.Join(root, "local"), "--") + }, "complete group"}, + {"replacement with selected 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"), "--") + }, "with replacement forbids"}, + {"run output", func(t *testing.T) []string { return append(validArgs(t, ActionRun), "--output=/tmp/out", "--") }, "cannot contain output paths"}, + {"build missing output", func(t *testing.T) []string { return validArgs(t, ActionBuild) }, "requires output paths"}, + {"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..b249e85cd --- /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, buildInfoDependency, 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..84f197aa4 --- /dev/null +++ b/internal/xgodriver/build_provenance.go @@ -0,0 +1,150 @@ +/* + * 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" +) + +type buildInfoPosition uint8 + +const ( + buildInfoMain buildInfoPosition = iota + buildInfoDependency +) + +func verifyBuiltDriverOrigin(ctx context.Context, name string, want ModuleOrigin, position buildInfoPosition, 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, position) +} + +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, position buildInfoPosition) error { + if info == nil { + return fmt.Errorf("missing Go build info") + } + var got *runtimedebug.Module + switch position { + case buildInfoMain: + if info.Main.Path != want.Selected.Path { + return fmt.Errorf("built artifact main module is %q, want %q", info.Main.Path, want.Selected.Path) + } + got = &info.Main + case buildInfoDependency: + for _, dependency := range info.Deps { + if dependency != nil && dependency.Path == want.Selected.Path { + got = dependency + break + } + } + if got == nil { + return fmt.Errorf("built artifact dependencies do not contain module %q", want.Selected.Path) + } + default: + return fmt.Errorf("invalid build info position %d", position) + } + if want.Main && position == buildInfoDependency && !isLocalBuildVersion(got.Version) { + return fmt.Errorf("built workspace module dependency has version %q", got.Version) + } + if !want.Main && 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 !isLocalBuildVersion(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 isLocalBuildVersion(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..15c794ef2 --- /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, buildInfoMain, 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..bad136441 --- /dev/null +++ b/internal/xgodriver/driver.go @@ -0,0 +1,108 @@ +/* + * 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" + + "github.com/goplus/spx/v3/internal/envutil" + "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 := validateDriverOrigin(cfg.DriverOrigin); err != nil { + return xgolauncher.ProcessStatus{}, err + } + if envutil.HasNonEmpty(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 +} diff --git a/internal/xgodriver/driver_test.go b/internal/xgodriver/driver_test.go new file mode 100644 index 000000000..3dad8d681 --- /dev/null +++ b/internal/xgodriver/driver_test.go @@ -0,0 +1,243 @@ +/* + * 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 + position buildInfoPosition + ok bool + }{ + { + name: "workspace main", + info: &runtimedebug.BuildInfo{Main: runtimedebug.Module{Path: "example.com/driver", Version: "v1.2.4-0.20260822024039-eb9e7bbaacff"}}, + want: ModuleOrigin{Selected: ModuleRef{Path: "example.com/driver"}, Main: true}, position: buildInfoMain, ok: true, + }, + { + name: "workspace main in generated launcher dependencies", + info: &runtimedebug.BuildInfo{ + Main: runtimedebug.Module{Path: "example.com/app", Version: "(devel)"}, + Deps: []*runtimedebug.Module{{Path: "example.com/driver", Version: "(devel)"}}, + }, + want: ModuleOrigin{Selected: ModuleRef{Path: "example.com/driver"}, Main: true}, position: buildInfoDependency, ok: true, + }, + { + name: "workspace launcher rejects versioned dependency", + info: &runtimedebug.BuildInfo{ + Main: runtimedebug.Module{Path: "example.com/app", Version: "(devel)"}, + Deps: []*runtimedebug.Module{{Path: "example.com/driver", Version: "v1.2.3"}}, + }, + want: ModuleOrigin{Selected: ModuleRef{Path: "example.com/driver"}, Main: true}, position: buildInfoDependency, + }, + { + name: "bridge rejects dependency position", + info: &runtimedebug.BuildInfo{ + Main: runtimedebug.Module{Path: "example.com/app", Version: "(devel)"}, + Deps: []*runtimedebug.Module{{Path: "example.com/driver", Version: "(devel)"}}, + }, + want: ModuleOrigin{Selected: ModuleRef{Path: "example.com/driver"}, Main: true}, position: buildInfoMain, + }, + { + name: "launcher rejects main position", + info: &runtimedebug.BuildInfo{Main: runtimedebug.Module{Path: "example.com/driver", Version: "(devel)"}}, + want: ModuleOrigin{Selected: ModuleRef{Path: "example.com/driver"}, Main: true}, position: buildInfoDependency, + }, + { + 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"}, + }, position: buildInfoDependency, 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", position: buildInfoDependency, 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", position: buildInfoDependency, 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", position: buildInfoDependency, + }, + { + 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"}}, position: buildInfoMain, + }, + { + 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"}, + }, position: buildInfoMain, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := verifyBuildInfoOrigin(test.info, test.want, test.replacementPath, test.position) + 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", buildInfoDependency); 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..e97fb0af2 --- /dev/null +++ b/internal/xgodriver/provenance.go @@ -0,0 +1,110 @@ +/* + * 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" + + "github.com/goplus/spx/v3/internal/envutil" +) + +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 { + return envutil.HostGoEnvironment(base, cfg.GoWork, false, activeEnvironment) +} diff --git a/internal/xgodriver/run.go b/internal/xgodriver/run.go new file mode 100644 index 000000000..b33f8a2bf --- /dev/null +++ b/internal/xgodriver/run.go @@ -0,0 +1,146 @@ +/* + * 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" + + "github.com/goplus/spx/v3/internal/envutil" + "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 { + return envutil.Without(env, activeEnvironment, "GOFLAGS", "GOWORK", "GOOS", "GOARCH", "CGO_ENABLED") +}