From 4e2f26402c0b02d37804aa71c7c3f377d94ec45d Mon Sep 17 00:00:00 2001 From: joeykchen <466719968@qq.com> Date: Fri, 21 Aug 2026 15:13:25 +0800 Subject: [PATCH 1/2] feat(launchpack): add driver-independent packaging service --- internal/launchpack/launcher.go | 203 +++++++++++++ internal/launchpack/launcher_test.go | 56 ++++ internal/launchpack/payload.go | 282 ++++++++++++++++++ internal/launchpack/payload_files.go | 182 +++++++++++ internal/launchpack/project.go | 116 +++++++ internal/launchpack/project_test.go | 62 ++++ internal/launchpack/runtime_assets.go | 239 +++++++++++++++ internal/launchpack/runtime_bundle.go | 188 ++++++++++++ internal/launchpack/runtime_fetch.go | 183 ++++++++++++ internal/launchpack/runtime_local_test.go | 118 ++++++++ internal/launchpack/runtime_materialize.go | 220 ++++++++++++++ internal/launchpack/service.go | 137 +++++++++ .../launchpack/service_integration_test.go | 119 ++++++++ internal/launchpack/types.go | 105 +++++++ internal/launchpack/validation.go | 188 ++++++++++++ internal/launchpack/validation_test.go | 67 +++++ 16 files changed, 2465 insertions(+) create mode 100644 internal/launchpack/launcher.go create mode 100644 internal/launchpack/launcher_test.go create mode 100644 internal/launchpack/payload.go create mode 100644 internal/launchpack/payload_files.go create mode 100644 internal/launchpack/project.go create mode 100644 internal/launchpack/project_test.go create mode 100644 internal/launchpack/runtime_assets.go create mode 100644 internal/launchpack/runtime_bundle.go create mode 100644 internal/launchpack/runtime_fetch.go create mode 100644 internal/launchpack/runtime_local_test.go create mode 100644 internal/launchpack/runtime_materialize.go create mode 100644 internal/launchpack/service.go create mode 100644 internal/launchpack/service_integration_test.go create mode 100644 internal/launchpack/types.go create mode 100644 internal/launchpack/validation.go create mode 100644 internal/launchpack/validation_test.go diff --git a/internal/launchpack/launcher.go b/internal/launchpack/launcher.go new file mode 100644 index 000000000..481b40aa8 --- /dev/null +++ b/internal/launchpack/launcher.go @@ -0,0 +1,203 @@ +/* + * 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" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" +) + +const generatedLauncherTemplate = `package main + +import ( + "context" + _ "embed" + "fmt" + "os" + + "github.com/goplus/spx/v3/x/xgolauncher" +) + +//go:embed payload.spxpkg +var payload []byte + +const payloadSHA256 = %s +const manifestSHA256 = %s + +func main() { + status, err := xgolauncher.RunCommand(context.Background(), func(ctx context.Context) (xgolauncher.ProcessStatus, error) { + return xgolauncher.RunContext(ctx, xgolauncher.Config{ + Payload: payload, PayloadSHA256: payloadSHA256, ManifestSHA256: manifestSHA256, + Args: os.Args[1:], Stdin: os.Stdin, Stdout: os.Stdout, Stderr: os.Stderr, + }) + }) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "xgolauncher: %%v\n", err) + status = xgolauncher.ProcessStatus{Code: 1} + } + xgolauncher.Exit(status) +} +` + +func renderGeneratedLauncher(payloadDigest, manifestDigest string) []byte { + return []byte(fmt.Sprintf(generatedLauncherTemplate, strconv.Quote(payloadDigest), strconv.Quote(manifestDigest))) +} + +type payloadBuilder func(workDir string, dst io.Writer) (payloadDigest, manifestDigest string, err error) + +func compileLauncher(ctx context.Context, cfg Config, streams IO, buildPayload payloadBuilder) error { + if err := cfg.validateGraphInputs(); err != nil { + return err + } + if info, err := os.Lstat(cfg.Output); err == nil { + return fmt.Errorf("launchpack: staging output %q already exists with mode %s", cfg.Output, info.Mode()) + } else if !os.IsNotExist(err) { + return fmt.Errorf("launchpack: inspect staging output %q: %w", cfg.Output, err) + } + workDir, err := os.MkdirTemp("", "spx-launchpack-build-") + if err != nil { + return fmt.Errorf("launchpack: create launcher work directory: %w", err) + } + keepWork := hasBuildFlag(cfg.BuildFlags, "work") + if keepWork { + if streams.Stderr != nil { + _, _ = fmt.Fprintf(streams.Stderr, "SPXWORK=%s\n", workDir) + } + } else { + defer os.RemoveAll(workDir) + } + payloadPath := filepath.Join(workDir, "payload.spxpkg") + mainPath := filepath.Join(workDir, "main.go") + if buildPayload == nil { + return fmt.Errorf("launchpack: nil payload builder") + } + payloadFile, err := os.OpenFile(payloadPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return fmt.Errorf("launchpack: create generated payload: %w", err) + } + payloadDigest, manifestDigest, buildErr := buildPayload(workDir, payloadFile) + if buildErr == nil { + buildErr = payloadFile.Sync() + } + if closeErr := payloadFile.Close(); buildErr == nil { + buildErr = closeErr + } + if buildErr != nil { + return fmt.Errorf("launchpack: write generated payload: %w", buildErr) + } + if err := os.WriteFile(mainPath, renderGeneratedLauncher(payloadDigest, manifestDigest), 0o600); err != nil { + return fmt.Errorf("launchpack: write generated launcher: %w", err) + } + + args := append([]string{"build"}, cfg.GraphFlags...) + args = append(args, normalizedGoBuildFlags(cfg.BuildFlags)...) + args = append(args, "-buildmode=exe", "-o", cfg.Output, mainPath) + command := exec.CommandContext(ctx, cfg.GoCommand, args...) + command.Dir = cfg.WorkDir + command.Env = hostGoEnv(cfg, streams.Env) + command.Stdin = streams.Stdin + command.Stdout = streams.Stdout + command.Stderr = streams.Stderr + if err := cfg.verifyGraph(ctx, "before launcher build"); err != nil { + return err + } + if err := command.Run(); err != nil { + return fmt.Errorf("launchpack: build generated launcher: %w", err) + } + if err := cfg.verifyGraph(ctx, "after launcher build"); err != nil { + return err + } + if err := validateHostExecutable(cfg.Output); err != nil { + return err + } + if runtime.GOOS == "darwin" { + if err := signDarwinLauncher(ctx, cfg.Output, streams); err != nil { + return err + } + } + return validateHostExecutable(cfg.Output) +} + +func normalizedGoBuildFlags(flags []string) []string { + result := make([]string, 0, len(flags)) + for _, flag := range flags { + name, value, hasValue := strings.Cut(strings.TrimPrefix(flag, "-"), "=") + switch name { + case "v", "x", "work", "trimpath": + if !hasValue || value == "true" { + result = append(result, "-"+name) + } + case "buildvcs": + result = append(result, "-buildvcs="+value) + } + } + return result +} + +func validateHostExecutable(name string) error { + info, err := os.Lstat(name) + if err != nil { + return fmt.Errorf("launchpack: inspect launcher output %q: %w", name, err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Size() == 0 { + return fmt.Errorf("launchpack: launcher output %q is not a non-empty regular non-symlink file", name) + } + file, err := os.Open(name) + if err != nil { + return fmt.Errorf("launchpack: open launcher output: %w", err) + } + defer file.Close() + var magic [4]byte + if _, err := file.Read(magic[:]); err != nil { + return fmt.Errorf("launchpack: read launcher output header: %w", err) + } + valid := false + switch runtime.GOOS { + case "darwin": + valid = bytes.Equal(magic[:], []byte{0xcf, 0xfa, 0xed, 0xfe}) || bytes.Equal(magic[:], []byte{0xfe, 0xed, 0xfa, 0xcf}) || bytes.Equal(magic[:], []byte{0xca, 0xfe, 0xba, 0xbe}) + case "linux": + valid = bytes.Equal(magic[:], []byte{0x7f, 'E', 'L', 'F'}) + case "windows": + valid = magic[0] == 'M' && magic[1] == 'Z' + } + if !valid { + return fmt.Errorf("launchpack: launcher output %q is not a host %s executable", name, runtime.GOOS) + } + return nil +} + +func signDarwinLauncher(ctx context.Context, output string, streams IO) error { + sign := exec.CommandContext(ctx, "/usr/bin/codesign", "--force", "--sign", "-", output) + sign.Stdout, sign.Stderr = streams.Stdout, streams.Stderr + if err := sign.Run(); err != nil { + return fmt.Errorf("launchpack: ad-hoc sign launcher: %w", err) + } + verify := exec.CommandContext(ctx, "/usr/bin/codesign", "--verify", "--strict", output) + verify.Stdout, verify.Stderr = streams.Stdout, streams.Stderr + if err := verify.Run(); err != nil { + return fmt.Errorf("launchpack: verify launcher signature: %w", err) + } + return nil +} diff --git a/internal/launchpack/launcher_test.go b/internal/launchpack/launcher_test.go new file mode 100644 index 000000000..dfef14db5 --- /dev/null +++ b/internal/launchpack/launcher_test.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. + */ + +package launchpack + +import "testing" + +func TestGeneratedLauncherGolden(t *testing.T) { + const want = `package main + +import ( + "context" + _ "embed" + "fmt" + "os" + + "github.com/goplus/spx/v3/x/xgolauncher" +) + +//go:embed payload.spxpkg +var payload []byte + +const payloadSHA256 = "payload-digest" +const manifestSHA256 = "manifest-digest" + +func main() { + status, err := xgolauncher.RunCommand(context.Background(), func(ctx context.Context) (xgolauncher.ProcessStatus, error) { + return xgolauncher.RunContext(ctx, xgolauncher.Config{ + Payload: payload, PayloadSHA256: payloadSHA256, ManifestSHA256: manifestSHA256, + Args: os.Args[1:], Stdin: os.Stdin, Stdout: os.Stdout, Stderr: os.Stderr, + }) + }) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "xgolauncher: %v\n", err) + status = xgolauncher.ProcessStatus{Code: 1} + } + xgolauncher.Exit(status) +} +` + if got := string(renderGeneratedLauncher("payload-digest", "manifest-digest")); got != want { + t.Fatalf("generated launcher changed:\n%s", got) + } +} diff --git a/internal/launchpack/payload.go b/internal/launchpack/payload.go new file mode 100644 index 000000000..879a1eed9 --- /dev/null +++ b/internal/launchpack/payload.go @@ -0,0 +1,282 @@ +/* + * 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" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + + "github.com/goplus/spx/v3/internal/projectbundle" + "github.com/goplus/spx/v3/internal/projectpolicy" + "github.com/goplus/spx/v3/internal/runtimebundle" + "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 { + return "", "", err + } + var payloadDigest, manifestDigest string + err = compileLauncher(ctx, cfg, streams, func(workDir string, dst io.Writer) (string, string, error) { + var buildErr error + payloadDigest, manifestDigest, buildErr = writeLauncherPayload(workDir, dst, cfg, assets, projectConfig, streams) + return payloadDigest, manifestDigest, buildErr + }) + return payloadDigest, manifestDigest, err +} + +func writeLauncherPayload(workDir string, dst io.Writer, cfg Config, assets Assets, projectConfig projectbundle.Config, streams IO) (payloadDigest, manifestDigest string, err error) { + projectPath := filepath.Join(workDir, "project.zip") + projectFile, err := os.OpenFile(projectPath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return "", "", fmt.Errorf("launchpack: create project archive: %w", err) + } + defer func() { + if closeErr := projectFile.Close(); err == nil && closeErr != nil { + payloadDigest, manifestDigest, err = "", "", fmt.Errorf("launchpack: close project archive: %w", closeErr) + } + }() + projectDigest, err := projectbundle.WriteArchive(projectFile, projectConfig) + if err != nil { + return "", "", fmt.Errorf("launchpack: collect project: %w", err) + } + if err := projectFile.Sync(); err != nil { + return "", "", fmt.Errorf("launchpack: sync project archive: %w", err) + } + projectInfo, err := projectFile.Stat() + if err != nil { + return "", "", fmt.Errorf("launchpack: stat project archive: %w", err) + } + projectBundle, err := runtimepayload.ComponentBundleReaderAt(projectFile, projectInfo.Size(), runtimebundle.NamespaceProject) + if err != nil { + return "", "", fmt.Errorf("launchpack: verify generated project bundle: %w", err) + } + + engine, err := openPinnedFile("Engine", assets.EnginePath) + if err != nil { + return "", "", err + } + defer func() { + if closeErr := engine.file.Close(); err == nil && closeErr != nil { + payloadDigest, manifestDigest, err = "", "", fmt.Errorf("launchpack: close Engine: %w", closeErr) + } + }() + pack, err := openPinnedFile("runtime PCK", assets.PackPath) + if err != nil { + return "", "", err + } + defer func() { + if closeErr := pack.file.Close(); err == nil && closeErr != nil { + payloadDigest, manifestDigest, err = "", "", fmt.Errorf("launchpack: close runtime PCK: %w", closeErr) + } + }() + bridge, err := openPinnedFile("interpreter bridge", assets.BridgePath) + if err != nil { + return "", "", err + } + defer func() { + if closeErr := bridge.file.Close(); err == nil && closeErr != nil { + payloadDigest, manifestDigest, err = "", "", fmt.Errorf("launchpack: close interpreter bridge: %w", closeErr) + } + }() + + interfaceDigest, engineDigest, packDigest, err := localEngineSourceDigests(engine.source(""), pack.source("")) + if err != nil { + return "", "", err + } + bridgeDigest, err := digestFileSource(bridge.source("")) + 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, + }) + if err != nil { + return "", "", err + } + + engineName := filepath.Base(assets.EnginePath) + packName := filepath.Base(assets.PackPath) + bridgeName := filepath.Base(assets.BridgePath) + engineSources := []runtimepayload.FileSource{ + byteSource("runtime-manifest.json", 0o644, engineManifest), + engine.source(engineName), + pack.source(packName), + } + engineBundle, err := runtimepayload.ComponentBundleSources(engineSources, runtimebundle.NamespaceEngine) + if err != nil { + return "", "", fmt.Errorf("launchpack: identify Engine bundle: %w", err) + } + if !bundleEntryHasDigest(engineBundle, engineName, engineDigest) || !bundleEntryHasDigest(engineBundle, packName, packDigest) { + return "", "", errors.New("launchpack: Engine or runtime PCK changed while identifying payload") + } + bridgeSources := []runtimepayload.FileSource{ + byteSource("bridge-manifest.json", 0o644, bridgeManifest), + bridge.source(bridgeName), + } + bridgeBundle, err := runtimepayload.ComponentBundleSources(bridgeSources, runtimebundle.NamespaceBridge) + if err != nil { + return "", "", fmt.Errorf("launchpack: identify bridge bundle: %w", err) + } + if !bundleEntryHasDigest(bridgeBundle, bridgeName, bridgeDigest) { + return "", "", errors.New("launchpack: interpreter bridge changed while identifying payload") + } + + payloadSources := []runtimepayload.FileSource{ + byteSource("engine/runtime-manifest.json", 0o644, engineManifest), + engine.source("engine/" + engineName), + pack.source("engine/" + packName), + byteSource("bridge/bridge-manifest.json", 0o644, bridgeManifest), + bridge.source("bridge/" + bridgeName), + {Name: runtimepayload.ProjectZipPath, Mode: 0o644, ReaderAt: projectFile, Size: projectInfo.Size()}, + } + payloadDigest, manifestDigest, err = runtimepayload.BuildTo(dst, runtimepayload.BuildConfig{ + SPX: runtimepayload.SourceIdentity{ + SelectedPath: cfg.Source.SelectedPath, SelectedVersion: cfg.Source.SelectedVersion, + EffectivePath: cfg.Source.EffectivePath, EffectiveVersion: cfg.Source.EffectiveVersion, + Main: cfg.Source.Main, SourceMode: cfg.Source.SourceMode, + }, + Target: runtimepayload.Target{GOOS: runtime.GOOS, GOARCH: runtime.GOARCH}, + Engine: runtimepayload.Engine{ + RuntimeVersion: assets.Lock.RuntimeVersion, RuntimeABI: assets.Lock.RuntimeABI, + EngineInterfaceDigest: interfaceDigest, Executable: engineName, Pack: packName, + BundleDigest: engineBundle.Digest, + }, + Bridge: runtimepayload.Bridge{File: bridgeName, BundleDigest: bridgeBundle.Digest}, + Project: runtimepayload.Project{ + PackDirectory: cfg.PackDir, BundleDigest: projectBundle.Digest, + ArchiveSHA256: projectDigest.String(), + }, + }, payloadSources) + if err != nil { + return "", "", fmt.Errorf("launchpack: build embedded payload: %w", err) + } + for _, source := range []*pinnedFile{engine, pack, bridge} { + if err := source.verify(); err != nil { + return "", "", err + } + } + if traceEnabled(cfg.BuildFlags) && streams.Stderr != nil { + _, _ = fmt.Fprintf(streams.Stderr, "launchpack: project=%s payload=%s engine=%s bridge=%s\n", projectDigest, payloadDigest, engineBundle.Digest, bridgeBundle.Digest) + } + return payloadDigest, manifestDigest, nil +} diff --git a/internal/launchpack/payload_files.go b/internal/launchpack/payload_files.go new file mode 100644 index 000000000..fa97d29e4 --- /dev/null +++ b/internal/launchpack/payload_files.go @@ -0,0 +1,182 @@ +/* + * 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" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "runtime" + "strings" + + "github.com/goplus/spx/v3/internal/runtimebundle" + "github.com/goplus/spx/v3/internal/runtimepayload" +) + +type pinnedFile struct { + name string + path string + file *os.File + info os.FileInfo +} + +func openPinnedFile(name, filePath string) (*pinnedFile, error) { + before, err := os.Lstat(filePath) + if err != nil { + return nil, fmt.Errorf("lstat %s %q: %w", name, filePath, err) + } + if before.Mode()&os.ModeSymlink != 0 || !before.Mode().IsRegular() { + return nil, fmt.Errorf("%s %q is not a regular non-symlink file", name, filePath) + } + file, err := os.Open(filePath) + if err != nil { + return nil, fmt.Errorf("open %s %q: %w", name, filePath, err) + } + opened, statErr := file.Stat() + if statErr != nil { + _ = file.Close() + return nil, fmt.Errorf("stat %s %q: %w", name, filePath, statErr) + } + after, err := os.Lstat(filePath) + if err != nil || after.Mode()&os.ModeSymlink != 0 || !after.Mode().IsRegular() || !os.SameFile(before, opened) || !os.SameFile(opened, after) || before.Size() != opened.Size() || opened.Size() != after.Size() { + _ = file.Close() + return nil, fmt.Errorf("%s %q changed while opening", name, filePath) + } + return &pinnedFile{name: name, path: filePath, file: file, info: opened}, nil +} + +func validatePinnedFile(name, filePath string) error { + file, err := openPinnedFile(name, filePath) + if err != nil { + return err + } + if err := file.file.Close(); err != nil { + return fmt.Errorf("close %s %q: %w", name, filePath, err) + } + return nil +} + +func (f *pinnedFile) source(name string) runtimepayload.FileSource { + return runtimepayload.FileSource{Name: name, Mode: f.info.Mode().Perm(), ReaderAt: f.file, Size: f.info.Size()} +} + +func (f *pinnedFile) verify() error { + opened, err := f.file.Stat() + if err != nil { + return fmt.Errorf("stat %s %q: %w", f.name, f.path, err) + } + after, err := os.Lstat(f.path) + if err != nil || after.Mode()&os.ModeSymlink != 0 || !after.Mode().IsRegular() || !os.SameFile(f.info, opened) || !os.SameFile(opened, after) || opened.Size() != f.info.Size() || after.Size() != f.info.Size() { + return fmt.Errorf("%s %q changed while reading", f.name, f.path) + } + return nil +} + +func byteSource(name string, mode os.FileMode, data []byte) runtimepayload.FileSource { + return runtimepayload.FileSource{Name: name, Mode: mode, ReaderAt: bytes.NewReader(data), Size: int64(len(data))} +} + +func digestFileSource(source runtimepayload.FileSource) (string, error) { + hasher := sha256.New() + if err := copyFileSource(hasher, source); err != nil { + return "", err + } + 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 { + return "", "", "", fmt.Errorf("launchpack: hash Engine: %w", err) + } + _, _ = interfaceHasher.Write([]byte{0}) + if err := copyFileSource(io.MultiWriter(interfaceHasher, packHasher), pack); 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 +} + +func copyFileSource(dst io.Writer, source runtimepayload.FileSource) error { + count, err := io.Copy(dst, io.NewSectionReader(source.ReaderAt, 0, source.Size)) + if err != nil { + return err + } + if count != source.Size { + return fmt.Errorf("short read: read %d bytes, want %d: %w", count, source.Size, io.ErrUnexpectedEOF) + } + return nil +} + +func bundleEntryHasDigest(bundle runtimebundle.Bundle, name, digest string) bool { + for _, entry := range bundle.Entries { + if entry.Name == name { + return entry.SHA256 == digest + } + } + return false +} + +func hasBuildFlag(flags []string, name string) bool { + bare, enabled := "-"+name, "-"+name+"=true" + for _, flag := range flags { + if flag == bare || flag == enabled { + return true + } + } + return false +} + +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") +} + +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") +} diff --git a/internal/launchpack/project.go b/internal/launchpack/project.go new file mode 100644 index 000000000..7a75a69bc --- /dev/null +++ b/internal/launchpack/project.go @@ -0,0 +1,116 @@ +/* + * 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" + "sort" + "strings" + + "github.com/goplus/spx/v3/internal/projectassets" + "github.com/goplus/spx/v3/internal/projectbundle" + "github.com/goplus/spx/v3/internal/projectpolicy" +) + +func collectProjectAllowlist(cfg Config) ([]string, error) { + entries, err := os.ReadDir(cfg.ProjectDir) + if err != nil { + return nil, fmt.Errorf("launchpack: read project directory: %w", err) + } + extension := cfg.ProjectExt + if extension != "" && !strings.HasPrefix(extension, ".") { + extension = "." + extension + } + var projectFiles []string + for _, entry := range entries { + if filepath.Ext(entry.Name()) != extension { + continue + } + info, err := entry.Info() + if err != nil { + return nil, fmt.Errorf("launchpack: inspect project source %q: %w", entry.Name(), err) + } + if entry.Type()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return nil, fmt.Errorf("launchpack: project source %q is not a regular non-symlink file", entry.Name()) + } + projectFiles = append(projectFiles, entry.Name()) + } + if len(projectFiles) == 0 { + return nil, fmt.Errorf("launchpack: project has no top-level %s source files", extension) + } + projectBase := filepath.Base(cfg.ProjectFile) + foundProject := false + for _, name := range projectFiles { + if name == projectBase { + foundProject = true + break + } + } + if !foundProject { + return nil, fmt.Errorf("launchpack: project file %q is not in the source allowlist", projectBase) + } + + external, err := collectReferencedProjectFiles(cfg) + if err != nil { + return nil, err + } + projectFiles = append(projectFiles, external...) + sort.Strings(projectFiles) + projectFiles = compactStrings(projectFiles) + return projectFiles, nil +} + +func prepareProjectBundleConfig(cfg Config, snapshot projectpolicy.PortableConfigSnapshot) (projectbundle.Config, error) { + files, err := collectProjectAllowlist(cfg) + if err != nil { + return projectbundle.Config{}, err + } + if err := snapshot.Verify(cfg.ProjectDir); err != nil { + return projectbundle.Config{}, fmt.Errorf("launchpack: %w", err) + } + return projectbundle.Config{ProjectDir: cfg.ProjectDir, ProjectFiles: files, + IncludeConfig: snapshot.Present(), ConfigBytes: snapshot.Bytes(), PackDir: cfg.PackDir, + Output: cfg.Output}, nil +} + +// collectReferencedProjectFiles resolves explicit resources outside PackDir. +func collectReferencedProjectFiles(cfg Config) ([]string, error) { + referenced, err := projectassets.Collect(projectassets.Config{ + ProjectDir: cfg.ProjectDir, + PackDir: cfg.PackDir, + PackIndex: cfg.PackIndex, + }) + if err != nil { + return nil, fmt.Errorf("launchpack: collect typed project resources: %w", err) + } + return referenced, nil +} + +func compactStrings(values []string) []string { + if len(values) == 0 { + return values + } + output := values[:1] + for _, value := range values[1:] { + if value != output[len(output)-1] { + output = append(output, value) + } + } + return output +} diff --git a/internal/launchpack/project_test.go b/internal/launchpack/project_test.go new file mode 100644 index 000000000..6cb246e14 --- /dev/null +++ b/internal/launchpack/project_test.go @@ -0,0 +1,62 @@ +/* + * 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 ( + "os" + "path/filepath" + "testing" + + "github.com/goplus/spx/v3/internal/projectpolicy" +) + +func TestProjectBundleIncludesAllTopLevelSourcesAndPack(t *testing.T) { + projectDir := t.TempDir() + writeProjectTestFile(t, filepath.Join(projectDir, "main.spx"), "main") + writeProjectTestFile(t, filepath.Join(projectDir, "Hero.spx"), "hero") + writeProjectTestFile(t, filepath.Join(projectDir, "assets", "index.json"), "{}") + writeProjectTestFile(t, filepath.Join(projectDir, "assets", "hero.png"), "asset") + snapshot, err := projectpolicy.SnapshotPortableConfig(projectDir) + if err != nil { + t.Fatal(err) + } + cfg := Config{ProjectDir: projectDir, ProjectFile: filepath.Join(projectDir, "main.spx"), ProjectExt: ".spx", PackDir: "assets", PackIndex: "index.json"} + bundle, err := prepareProjectBundleConfig(cfg, snapshot) + if err != nil { + t.Fatal(err) + } + if bundle.PackDir != "assets" { + t.Fatalf("PackDir = %q", bundle.PackDir) + } + got := map[string]bool{} + for _, name := range bundle.ProjectFiles { + got[name] = true + } + if !got["main.spx"] || !got["Hero.spx"] { + t.Fatalf("project files = %#v", bundle.ProjectFiles) + } +} + +func writeProjectTestFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/internal/launchpack/runtime_assets.go b/internal/launchpack/runtime_assets.go new file mode 100644 index 000000000..ddab689d3 --- /dev/null +++ b/internal/launchpack/runtime_assets.go @@ -0,0 +1,239 @@ +/* + * 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" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/goplus/spx/v3/internal/release" + "github.com/goplus/spx/v3/internal/runtimebundle" +) + +const ( + runtimeOfflineEnv = "SPX_RUNTIME_OFFLINE" + runtimeLocalManifestEnv = "SPX_RUNTIME_LOCAL_MANIFEST" + runtimeAssetDirEnv = "SPX_RUNTIME_ASSET_DIR" + runtimeCacheEnv = "SPX_RUNTIME_CACHE" + maxRuntimeManifestSize = 16 << 20 +) + +type runtimeAssetDependencies struct { + fetch runtimebundle.FetchFunc + cacheRoot func() string + manifestPin func(release.RuntimeLock) (release.RuntimeManifestPin, error) +} + +func defaultRuntimeAssetDependencies() runtimeAssetDependencies { + return runtimeAssetDependencies{ + fetch: fetchRuntimeURL, + cacheRoot: runtimebundle.DefaultCacheRoot, + manifestPin: release.RuntimeManifestPinForLock, + } +} + +type runtimeAssetSource struct { + manifest release.RuntimeManifest + manifestSHA256 string + manifestDir string + fetch runtimebundle.FetchFunc +} + +type localRuntimeSource struct { + manifest release.LocalRuntimeManifest + directory string + bytes []byte +} + +// acquireRuntimeAssets obtains one verified Engine/PCK pair. +func acquireRuntimeAssets(ctx context.Context, cfg Config, streams IO, lock release.RuntimeLock) (Assets, error) { + return acquireRuntimeAssetsWith(ctx, cfg, streams, lock, defaultRuntimeAssetDependencies()) +} + +func acquireRuntimeAssetsWith(ctx context.Context, cfg Config, streams IO, lock release.RuntimeLock, dependencies runtimeAssetDependencies) (Assets, error) { + if ctx == nil { + return Assets{}, errors.New("launchpack: nil context") + } + if dependencies.fetch == nil || dependencies.cacheRoot == nil || dependencies.manifestPin == nil { + return Assets{}, errors.New("launchpack: incomplete runtime acquisition dependencies") + } + env := runtimeEnvironment(cfg, streams.Env) + spec, err := release.HostRuntimeSpecFor(lock, runtime.GOOS, runtime.GOARCH) + if err != nil { + return Assets{}, err + } + cacheDefault := dependencies.cacheRoot + if cfg.RuntimeCacheRoot != "" { + cacheDefault = func() string { return cfg.RuntimeCacheRoot } + } + cacheRoot, err := resolveRuntimeCacheRoot(env, cacheDefault) + if err != nil { + return Assets{}, err + } + offline, err := runtimeOffline(env) + if err != nil { + return Assets{}, err + } + offline = offline || cfg.RuntimeOffline + + if local, found, err := findLocalRuntimeManifest(cfg, env, lock, spec); err != nil { + return Assets{}, err + } else if found { + return materializeLocalRuntime(ctx, cacheRoot, lock, spec, local) + } + + source, err := resolvePublishedRuntime(ctx, cacheRoot, lock, spec, env, offline, dependencies) + if err != nil { + return Assets{}, err + } + return materializePublishedRuntime(ctx, cacheRoot, lock, spec, source, offline) +} + +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 findLocalRuntimeManifest(cfg Config, env []string, lock release.RuntimeLock, spec release.HostRuntimeSpec) (localRuntimeSource, bool, error) { + path, explicit, duplicate := environmentValue(env, runtimeLocalManifestEnv) + if duplicate { + return localRuntimeSource{}, false, fmt.Errorf("launchpack: duplicate %s", runtimeLocalManifestEnv) + } + if !explicit { + if _, assetDirSet, duplicate := environmentValue(env, runtimeAssetDirEnv); duplicate { + return localRuntimeSource{}, false, fmt.Errorf("launchpack: duplicate %s", runtimeAssetDirEnv) + } else if assetDirSet { + return localRuntimeSource{}, false, nil + } + if cfg.RuntimeSourceRoot == "" { + return localRuntimeSource{}, false, nil + } + candidate, pathErr := release.LocalRuntimeManifestPath(cfg.RuntimeSourceRoot, lock, spec.GOOS, spec.GOARCH) + if pathErr != nil { + return localRuntimeSource{}, false, pathErr + } + if info, err := os.Lstat(candidate); err == nil { + if !isRegularNonSymlink(info) { + return localRuntimeSource{}, false, fmt.Errorf("launchpack: discovered local runtime manifest is not a regular non-symlink file: %s", candidate) + } + path = candidate + explicit = true + } else if !os.IsNotExist(err) { + return localRuntimeSource{}, false, fmt.Errorf("launchpack: inspect local runtime manifest: %w", err) + } + } + if !explicit { + return localRuntimeSource{}, false, nil + } + 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 + } + if err := manifest.ValidateForLock(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, directory: directory, bytes: data}, true, nil +} diff --git a/internal/launchpack/runtime_bundle.go b/internal/launchpack/runtime_bundle.go new file mode 100644 index 000000000..dbe6ee597 --- /dev/null +++ b/internal/launchpack/runtime_bundle.go @@ -0,0 +1,188 @@ +/* + * 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" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + + "github.com/goplus/spx/v3/internal/release" + "github.com/goplus/spx/v3/internal/runtimebundle" +) + +func expectedEngineBundle(origin []byte, spec release.HostRuntimeSpec, engineSize int64, engineSHA string, packSize int64, packSHA string) (runtimebundle.Bundle, error) { + bundle := runtimebundle.Bundle{ + Schema: runtimebundle.SchemaV1, Namespace: runtimebundle.NamespaceEngine, + Entries: []runtimebundle.Entry{ + {Name: "runtime-manifest.json", Mode: 0o600, Size: int64(len(origin)), SHA256: digestBytes(origin)}, + {Name: spec.RuntimeName, Mode: 0o700, Size: engineSize, SHA256: engineSHA}, + {Name: spec.PackName, Mode: 0o600, Size: packSize, SHA256: packSHA}, + }, + } + return bundle.WithDigest() +} + +func validateRuntimeEntry(path, name string, bundle runtimebundle.Bundle) error { + if err := validateRuntimeFile(path, name); err != nil { + return err + } + for _, entry := range bundle.Entries { + if entry.Name == filepath.ToSlash(name) { + return nil + } + } + return fmt.Errorf("launchpack: runtime archive is missing %s", name) +} + +func isRegularNonSymlink(info os.FileInfo) bool { + return info != nil && info.Mode()&os.ModeSymlink == 0 && info.Mode().IsRegular() +} + +func validateRuntimeFile(path, label string) error { + info, err := os.Lstat(path) + if err != nil { + return fmt.Errorf("launchpack: %s unavailable at %s: %w", label, path, err) + } + if !isRegularNonSymlink(info) { + return fmt.Errorf("launchpack: %s %q is not a regular non-symlink file", label, path) + } + return nil +} + +func withPinnedFile(name, path string, fn func(*pinnedFile) error) error { + file, err := openPinnedFile(name, path) + if err != nil { + return err + } + operationErr := fn(file) + if operationErr == nil { + operationErr = file.verify() + } + closeErr := file.file.Close() + if operationErr != nil { + return operationErr + } + return closeErr +} + +func hashRuntimeFile(path string) (int64, string, error) { + var size int64 + var digest string + err := withPinnedFile("runtime file", path, func(file *pinnedFile) error { + hasher := sha256.New() + var err error + size, err = io.Copy(hasher, file.file) + if err != nil { + return err + } + if size != file.info.Size() { + return fmt.Errorf("file changed while reading") + } + digest = hex.EncodeToString(hasher.Sum(nil)) + return nil + }) + if err != nil { + return 0, "", err + } + return size, digest, nil +} + +func writeEngineBundle(path string, origin []byte, engineName, packName, enginePath, packPath string) (err error) { + tmp, err := os.CreateTemp(filepath.Dir(path), ".spx-launchpack-engine-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer func() { _ = os.Remove(tmpPath) }() + zw := zip.NewWriter(tmp) + addBytes := func(name string, mode os.FileMode, data []byte) error { + header := &zip.FileHeader{Name: name, Method: zip.Store} + header.SetMode(mode) + writer, err := zw.CreateHeader(header) + if err != nil { + return err + } + _, err = writer.Write(data) + return err + } + if err := addBytes("runtime-manifest.json", 0o600, origin); err != nil { + _ = zw.Close() + _ = tmp.Close() + return err + } + if err := addFileToZip(zw, engineName, enginePath, 0o700); err != nil { + _ = zw.Close() + _ = tmp.Close() + return fmt.Errorf("add Engine to bundle: %w", err) + } + if err := addFileToZip(zw, packName, packPath, 0o600); err != nil { + _ = zw.Close() + _ = tmp.Close() + return fmt.Errorf("add runtime PCK to bundle: %w", err) + } + if err := zw.Close(); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return replaceRuntimeFile(tmpPath, path, 0o600) +} + +func addFileToZip(zw *zip.Writer, name, path string, mode os.FileMode) error { + header := &zip.FileHeader{Name: name, Method: zip.Store} + header.SetMode(mode) + writer, err := zw.CreateHeader(header) + if err != nil { + return err + } + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + _, err = io.Copy(writer, file) + return err +} + +func replaceRuntimeFile(src, dst string, mode os.FileMode) error { + if info, err := os.Lstat(dst); err == nil { + if !isRegularNonSymlink(info) { + return fmt.Errorf("destination %q is not a regular non-symlink file", dst) + } + } else if !os.IsNotExist(err) { + return err + } + if err := os.Rename(src, dst); err != nil { + return err + } + if runtime.GOOS != "windows" { + return os.Chmod(dst, mode) + } + return nil +} diff --git a/internal/launchpack/runtime_fetch.go b/internal/launchpack/runtime_fetch.go new file mode 100644 index 000000000..09a69fad8 --- /dev/null +++ b/internal/launchpack/runtime_fetch.go @@ -0,0 +1,183 @@ +/* + * 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" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "path/filepath" + "time" + + "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, env []string, offline bool, dependencies runtimeAssetDependencies) (runtimeAssetSource, error) { + pin, err := dependencies.manifestPin(lock) + if err != nil { + return runtimeAssetSource{}, fmt.Errorf("launchpack: resolve runtime manifest pin: %w", err) + } + if err := pin.ValidateForLock(lock); err != nil { + return runtimeAssetSource{}, err + } + assetDir, assetDirSet, duplicate := environmentValue(env, runtimeAssetDirEnv) + if duplicate { + return runtimeAssetSource{}, fmt.Errorf("launchpack: duplicate %s", runtimeAssetDirEnv) + } + if assetDirSet { + if assetDir == "" { + return runtimeAssetSource{}, fmt.Errorf("launchpack: %s must not be empty", runtimeAssetDirEnv) + } + 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, + }) + 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 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 + } + 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 +} + +func acquireReleaseAsset(ctx context.Context, root string, asset release.RuntimeAsset, url, localDir string, offline bool, fetch runtimebundle.FetchFunc) (*runtimebundle.AcquiredFile, error) { + name := asset.SHA256 + "-" + asset.Name + if localDir != "" { + if !filepath.IsAbs(localDir) || filepath.Clean(localDir) != localDir { + return nil, fmt.Errorf("launchpack: %s must be an absolute clean path", runtimeAssetDirEnv) + } + src := filepath.Join(localDir, asset.Name) + return runtimebundle.AcquireFile(ctx, root, runtimebundle.FetchSpec{ + Name: name, URL: src, Size: asset.Size, SHA256: asset.SHA256, + Fetch: func(ctx context.Context, _ string, dst io.Writer) error { return copyLocalRuntimeAsset(ctx, src, dst) }, + }) + } + return runtimebundle.AcquireFile(ctx, root, runtimebundle.FetchSpec{ + Name: name, URL: url, Size: asset.Size, SHA256: asset.SHA256, + Offline: offline, Fetch: fetch, + }) +} + +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 { + return err + } + defer file.file.Close() + if err := ctx.Err(); err != nil { + return err + } + if _, err := io.Copy(dst, file.file); err != nil { + return err + } + return file.verify() +} + +func readRegularFile(path string) ([]byte, error) { + var data []byte + err := withPinnedFile("runtime metadata", path, func(file *pinnedFile) error { + var err error + data, err = readRuntimeMetadata(file.file, path) + return err + }) + return data, err +} + +func readRuntimeMetadata(reader io.Reader, path string) ([]byte, error) { + data, err := io.ReadAll(io.LimitReader(reader, maxRuntimeManifestSize+1)) + if err != nil { + return nil, err + } + if len(data) > maxRuntimeManifestSize { + return nil, fmt.Errorf("runtime metadata %q exceeds %d bytes", path, maxRuntimeManifestSize) + } + return data, nil +} + +func digestBytes(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + +func fetchRuntimeURL(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 + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return fmt.Errorf("GET %s returned %s", url, response.Status) + } + _, err = io.Copy(dst, response.Body) + return err +} diff --git a/internal/launchpack/runtime_local_test.go b/internal/launchpack/runtime_local_test.go new file mode 100644 index 000000000..e93634454 --- /dev/null +++ b/internal/launchpack/runtime_local_test.go @@ -0,0 +1,118 @@ +/* + * 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" + "path/filepath" + "runtime" + "testing" + + "github.com/goplus/spx/v3/internal/release" +) + +func TestAcquireRuntimeAssetsPrefersExplicitLocalManifest(t *testing.T) { + lock := release.DefaultRuntimeLock() + spec, err := release.HostRuntimeSpecFor(lock, runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Fatal(err) + } + root := t.TempDir() + sourceRoot := filepath.Join(root, "source") + explicitRoot := filepath.Join(root, "explicit") + if err := os.MkdirAll(sourceRoot, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(explicitRoot, 0o755); err != nil { + t.Fatal(err) + } + sourceManifest, err := release.LocalRuntimeManifestPath(sourceRoot, lock, runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Fatal(err) + } + publishLocalRuntimeTest(t, sourceRoot, sourceManifest, spec, "source-engine", "source-pack") + explicitManifest := publishLocalRuntimeTest(t, explicitRoot, filepath.Join(explicitRoot, "engine-manifest.json"), spec, "explicit-engine", "explicit-pack") + + cfg := Config{ + RuntimeSourceRoot: sourceRoot, RuntimeManifestPath: explicitManifest, + RuntimeCacheRoot: filepath.Join(root, "cache"), + } + 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, + }) + if err != nil { + t.Fatal(err) + } + defer assets.Cleanup() + engine, err := os.ReadFile(assets.EnginePath) + if err != nil { + t.Fatal(err) + } + if string(engine) != "explicit-engine" { + t.Fatalf("engine = %q, want explicit manifest asset", engine) + } + + cfg.RuntimeManifestPath = "" + 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, + }) + if err != nil { + t.Fatal(err) + } + defer assets.Cleanup() + engine, err = os.ReadFile(assets.EnginePath) + if err != nil { + t.Fatal(err) + } + if string(engine) != "source-engine" { + t.Fatalf("engine = %q, want source-root manifest asset", engine) + } +} + +func TestRuntimeEnvironmentConfigOverridesProcess(t *testing.T) { + t.Setenv(runtimeCacheEnv, "/process-cache") + env := runtimeEnvironment(Config{RuntimeCacheRoot: "/config-cache"}, nil) + value, found, duplicate := environmentValue(env, runtimeCacheEnv) + if !found || duplicate || value != "/config-cache" { + t.Fatalf("%s = %q, found %v, duplicate %v", runtimeCacheEnv, value, found, duplicate) + } +} + +func publishLocalRuntimeTest(t *testing.T, root, manifestPath string, spec release.HostRuntimeSpec, engine, pack string) string { + t.Helper() + enginePath := filepath.Join(root, spec.RuntimeName) + packPath := filepath.Join(root, spec.PackName) + if err := os.WriteFile(enginePath, []byte(engine), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(packPath, []byte(pack), 0o644); err != nil { + t.Fatal(err) + } + manifest, err := release.NewLocalRuntimeManifest(release.DefaultRuntimeLock(), runtime.GOOS, runtime.GOARCH, enginePath, packPath) + if err != nil { + t.Fatal(err) + } + if err := release.PublishLocalRuntimeManifest(manifestPath, manifest, enginePath, packPath); err != nil { + t.Fatal(err) + } + return manifestPath +} diff --git a/internal/launchpack/runtime_materialize.go b/internal/launchpack/runtime_materialize.go new file mode 100644 index 000000000..a9d8bbd35 --- /dev/null +++ b/internal/launchpack/runtime_materialize.go @@ -0,0 +1,220 @@ +/* + * 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" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/goplus/spx/v3/internal/release" + "github.com/goplus/spx/v3/internal/runtimebundle" +) + +type publishedRuntimeArchives struct { + engine *runtimebundle.AcquiredFile + pack *runtimebundle.AcquiredFile +} + +type runtimeMaterializationInput struct { + manifestSHA256 string + enginePath string + packPath string + archives *publishedRuntimeArchives + engineSize int64 + engineSHA256 string + packSize int64 + packSHA256 string + releaseManifest release.RuntimeManifest +} + +type runtimeBundleOrigin struct { + Schema string `json:"schema"` + Mode string `json:"mode"` + RuntimeVersion string `json:"runtime_version"` + RuntimeABI int `json:"runtime_abi"` + LockSHA256 string `json:"lock_sha256"` + ManifestSHA256 string `json:"manifest_sha256"` + GOOS string `json:"goos"` + GOARCH string `json:"goarch"` + EngineArchive string `json:"engine_archive,omitempty"` + EngineArchiveSHA256 string `json:"engine_archive_sha256,omitempty"` + PackArchive string `json:"pack_archive,omitempty"` + PackArchiveSHA256 string `json:"pack_archive_sha256,omitempty"` + EngineName string `json:"engine_name"` + EngineSize int64 `json:"engine_size"` + EngineSHA256 string `json:"engine_sha256"` + PackName string `json:"pack_name"` + PackSize int64 `json:"pack_size"` + PackSHA256 string `json:"pack_sha256"` +} + +func materializePublishedRuntime(ctx context.Context, cacheRoot string, lock release.RuntimeLock, spec release.HostRuntimeSpec, source runtimeAssetSource, offline bool) (Assets, error) { + engineAsset, ok := source.manifest.Asset(spec.ArchiveName) + if !ok { + return Assets{}, fmt.Errorf("launchpack: runtime manifest has no host asset %q", spec.ArchiveName) + } + packAsset, ok := source.manifest.Asset(release.RuntimeAssetZipName) + 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) + assetDir := source.manifestDir + engineFile, err := acquireReleaseAsset(ctx, assetRoot, engineAsset, source.manifestURL(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) + if err != nil { + return Assets{}, err + } + defer packFile.Close() + return materializeRuntimeBundle(ctx, cacheRoot, lock, spec, runtimeMaterializationInput{ + manifestSHA256: source.manifestSHA256, + archives: &publishedRuntimeArchives{engine: engineFile, pack: packFile}, + releaseManifest: source.manifest, + }) +} + +func materializeLocalRuntime(ctx context.Context, cacheRoot string, lock release.RuntimeLock, spec release.HostRuntimeSpec, source localRuntimeSource) (Assets, error) { + return materializeRuntimeBundle(ctx, cacheRoot, lock, spec, runtimeMaterializationInput{ + manifestSHA256: digestBytes(source.bytes), + enginePath: filepath.Join(source.directory, source.manifest.Engine.Name), + packPath: filepath.Join(source.directory, source.manifest.Pack.Name), + engineSize: source.manifest.Engine.Size, + engineSHA256: source.manifest.Engine.SHA256, + packSize: source.manifest.Pack.Size, + packSHA256: source.manifest.Pack.SHA256, + }) +} + +func materializeRuntimeBundle(ctx context.Context, cacheRoot string, lock release.RuntimeLock, spec release.HostRuntimeSpec, input runtimeMaterializationInput) (Assets, error) { + local := input.archives == nil + if !local { + if input.archives.engine == nil || input.archives.pack == nil { + return Assets{}, errors.New("launchpack: incomplete published runtime archive handles") + } + workDir, err := os.MkdirTemp("", "spx-launchpack-runtime-") + if err != nil { + return Assets{}, fmt.Errorf("launchpack: create runtime extraction directory: %w", err) + } + defer os.RemoveAll(workDir) + hostDir := filepath.Join(workDir, "host") + packDir := filepath.Join(workDir, "pack") + if err := os.MkdirAll(hostDir, 0o700); err != nil { + return Assets{}, err + } + if err := os.MkdirAll(packDir, 0o700); err != nil { + return Assets{}, err + } + engineInfo, err := input.archives.engine.Stat() + if err != nil { + return Assets{}, fmt.Errorf("launchpack: stat acquired Engine archive: %w", err) + } + packInfo, err := input.archives.pack.Stat() + if err != nil { + return Assets{}, fmt.Errorf("launchpack: stat acquired runtime pack archive: %w", err) + } + engineBundle, err := runtimebundle.ExtractZipReader(input.archives.engine, engineInfo.Size(), hostDir) + if err != nil { + return Assets{}, fmt.Errorf("launchpack: verify Engine archive: %w", err) + } + packBundle, err := runtimebundle.ExtractZipReader(input.archives.pack, packInfo.Size(), packDir) + if err != nil { + return Assets{}, fmt.Errorf("launchpack: verify runtime pack archive: %w", err) + } + input.enginePath = filepath.Join(hostDir, filepath.FromSlash(spec.BinaryName)) + input.packPath = filepath.Join(packDir, filepath.FromSlash("gdspxrt.pck")) + if err := validateRuntimeEntry(input.enginePath, spec.BinaryName, engineBundle); err != nil { + return Assets{}, err + } + if err := validateRuntimeEntry(input.packPath, "gdspxrt.pck", packBundle); err != nil { + return Assets{}, err + } + } + + engineSize, engineSHA, err := hashRuntimeFile(input.enginePath) + if err != nil { + return Assets{}, fmt.Errorf("launchpack: hash Engine: %w", err) + } + packSize, packSHA, err := hashRuntimeFile(input.packPath) + if err != nil { + return Assets{}, fmt.Errorf("launchpack: hash runtime PCK: %w", err) + } + if local && (engineSize != input.engineSize || engineSHA != input.engineSHA256 || packSize != input.packSize || packSHA != input.packSHA256) { + return Assets{}, errors.New("launchpack: local runtime changed after manifest verification") + } + lockSHA, err := lock.SHA256() + if err != nil { + return Assets{}, err + } + origin := runtimeBundleOrigin{ + Schema: "spx-runtime-acquisition/v1", Mode: "published", RuntimeVersion: lock.RuntimeVersion, + RuntimeABI: lock.RuntimeABI, LockSHA256: lockSHA, ManifestSHA256: input.manifestSHA256, + GOOS: spec.GOOS, GOARCH: spec.GOARCH, EngineName: spec.RuntimeName, + EngineSize: engineSize, EngineSHA256: engineSHA, PackName: spec.PackName, + PackSize: packSize, PackSHA256: packSHA, + } + if local { + origin.Mode = "local" + } else { + origin.EngineArchive = spec.ArchiveName + if asset, ok := input.releaseManifest.Asset(spec.ArchiveName); ok { + origin.EngineArchiveSHA256 = asset.SHA256 + } + origin.PackArchive = release.RuntimeAssetZipName + if asset, ok := input.releaseManifest.Asset(release.RuntimeAssetZipName); ok { + origin.PackArchiveSHA256 = asset.SHA256 + } + } + originBytes, err := json.Marshal(origin) + if err != nil { + return Assets{}, err + } + bundle, err := expectedEngineBundle(originBytes, spec, engineSize, engineSHA, packSize, packSHA) + if err != nil { + return Assets{}, err + } + bundleWork, err := os.MkdirTemp("", "spx-launchpack-engine-") + if err != nil { + return Assets{}, fmt.Errorf("launchpack: create Engine bundle directory: %w", err) + } + defer os.RemoveAll(bundleWork) + bundleZip := filepath.Join(bundleWork, "engine.bundle.zip") + if err := writeEngineBundle(bundleZip, originBytes, spec.RuntimeName, spec.PackName, input.enginePath, input.packPath); err != nil { + return Assets{}, err + } + materialized, err := runtimebundle.NewCache(cacheRoot).Materialize(ctx, runtimebundle.NamespaceEngine, bundleZip, &bundle) + if err != nil { + return Assets{}, fmt.Errorf("launchpack: materialize verified Engine bundle: %w", err) + } + materializedEnginePath := filepath.Join(materialized.Path, spec.RuntimeName) + materializedPackPath := filepath.Join(materialized.Path, spec.PackName) + if err := validateRuntimeFile(materializedEnginePath, "Engine"); err != nil { + _ = materialized.Close() + return Assets{}, err + } + if err := validateRuntimeFile(materializedPackPath, "runtime PCK"); err != nil { + _ = materialized.Close() + return Assets{}, err + } + return Assets{EnginePath: materializedEnginePath, PackPath: materializedPackPath, Lock: lock, Cleanup: func() { _ = materialized.Close() }}, nil +} diff --git a/internal/launchpack/service.go b/internal/launchpack/service.go new file mode 100644 index 000000000..aff81e6f7 --- /dev/null +++ b/internal/launchpack/service.go @@ -0,0 +1,137 @@ +/* + * 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" + "path/filepath" + "runtime" + + "github.com/goplus/spx/v3/internal/release" +) + +// AcquireRuntimeAssets resolves and materializes one verified Engine/PCK pair. +// Explicit manifest/asset settings are honored before RuntimeSourceRoot and +// the pinned release cache. No GOPATH/bin lookup is performed. +func AcquireRuntimeAssets(ctx context.Context, cfg Config) (Assets, error) { + lock, err := runtimeLock(cfg) + if err != nil { + return Assets{}, err + } + return acquireRuntimeAssets(ctx, cfg, cfg.IO, lock) +} + +// BuildSourceBridge compiles the configured source bridge and returns a path +// plus a cleanup function. Provenance checks belong to the caller. +func BuildSourceBridge(ctx context.Context, cfg Config) (string, func(), error) { + if ctx == nil { + return "", nil, fmt.Errorf("launchpack: nil context") + } + if err := cfg.validateGraphInputs(); err != nil { + return "", nil, err + } + name, err := bridgeFileName(runtime.GOOS, runtime.GOARCH) + if err != nil { + return "", nil, err + } + 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) + } + assets, err := AcquireRuntimeAssets(ctx, cfg) + if err != nil { + return Result{}, err + } + bridge, bridgeCleanup, err := BuildSourceBridge(ctx, cfg) + if err != nil { + if assets.Cleanup != nil { + assets.Cleanup() + } + return Result{}, 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) + } + } + oldCleanup := assets.Cleanup + cleanup := func() { + bridgeCleanup() + if oldCleanup != nil { + oldCleanup() + } + } + defer cleanup() + + payload, manifest, err := buildLauncher(ctx, cfg, assets, cfg.PortableConfig, cfg.IO) + if err != nil { + return Result{}, err + } + return Result{ + Output: cfg.Output, PayloadSHA256: payload, ManifestSHA256: manifest, + }, nil +} + +func (c Config) verifyGraph(ctx context.Context, phase string) error { + if c.VerifyGraph == nil { + return nil + } + if err := c.VerifyGraph(ctx); err != nil { + return fmt.Errorf("launchpack: graph changed %s: %w", phase, err) + } + return nil +} + +func runtimeLock(cfg Config) (release.RuntimeLock, error) { + lock := cfg.RuntimeLock + if lock.RuntimeVersion == "" { + lock = release.DefaultRuntimeLock() + } + if err := lock.Validate(); err != nil { + return release.RuntimeLock{}, err + } + identity := cfg.RuntimeIdentity + if identity.Version != "" && identity.Version != lock.RuntimeVersion { + return release.RuntimeLock{}, fmt.Errorf("launchpack: runtime version %q does not match lock %q", identity.Version, lock.RuntimeVersion) + } + if identity.ABI != 0 && identity.ABI != lock.RuntimeABI { + return release.RuntimeLock{}, fmt.Errorf("launchpack: runtime ABI %d does not match lock %d", identity.ABI, lock.RuntimeABI) + } + if identity.GOOS != "" && identity.GOOS != runtime.GOOS || identity.GOARCH != "" && identity.GOARCH != runtime.GOARCH { + return release.RuntimeLock{}, fmt.Errorf("launchpack: runtime target does not match host %s/%s", runtime.GOOS, runtime.GOARCH) + } + if cfg.RuntimeCacheRoot != "" && (!filepath.IsAbs(cfg.RuntimeCacheRoot) || filepath.Clean(cfg.RuntimeCacheRoot) != cfg.RuntimeCacheRoot) { + return release.RuntimeLock{}, fmt.Errorf("launchpack: runtime cache root must be an absolute clean path") + } + return lock, nil +} diff --git a/internal/launchpack/service_integration_test.go b/internal/launchpack/service_integration_test.go new file mode 100644 index 000000000..a5e2aaa99 --- /dev/null +++ b/internal/launchpack/service_integration_test.go @@ -0,0 +1,119 @@ +/* + * 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" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/goplus/spx/v3/internal/projectpolicy" + "github.com/goplus/spx/v3/internal/release" +) + +func TestBuildLauncherEndToEnd(t *testing.T) { + goCommand, err := exec.LookPath("go") + if err != nil { + t.Skip(err) + } + if output, err := exec.Command(goCommand, "env", "CGO_ENABLED").Output(); err != nil || strings.TrimSpace(string(output)) != "1" { + t.Skip("cgo toolchain unavailable") + } + goCommand, err = filepath.EvalSymlinks(goCommand) + if err != nil { + t.Fatal(err) + } + + repoRoot := launchpackRepoRoot(t) + moduleRoot := t.TempDir() + writeProjectTestFile(t, filepath.Join(moduleRoot, "go.mod"), "module example.com/launcher\n\ngo 1.25.0\n\nrequire github.com/goplus/spx/v3 v3.0.0\nreplace github.com/goplus/spx/v3 => "+filepath.ToSlash(repoRoot)+"\n") + writeProjectTestFile(t, filepath.Join(moduleRoot, "bridge", "main.go"), "package main\n\n// void bridge(void) {}\nimport \"C\"\n\nfunc main() {}\n") + + projectDir := filepath.Join(moduleRoot, "game") + projectFile := filepath.Join(projectDir, "main.spx") + writeProjectTestFile(t, projectFile, "onStart => {}\n") + writeProjectTestFile(t, filepath.Join(projectDir, "assets", "index.json"), "{}\n") + snapshot, err := projectpolicy.SnapshotPortableConfig(projectDir) + if err != nil { + t.Fatal(err) + } + + lock := release.DefaultRuntimeLock() + spec, err := release.HostRuntimeSpecFor(lock, runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Skip(err) + } + runtimeRoot := filepath.Join(moduleRoot, "runtime") + if err := os.MkdirAll(runtimeRoot, 0o755); err != nil { + t.Fatal(err) + } + manifest := publishLocalRuntimeTest(t, runtimeRoot, filepath.Join(runtimeRoot, "manifest.json"), spec, "engine", "pack") + output := filepath.Join(moduleRoot, "launcher"+executableSuffix(runtime.GOOS)) + + var buildLog bytes.Buffer + graphChecks := 0 + result, err := BuildLauncher(context.Background(), Config{ + ProjectDir: projectDir, ProjectFile: projectFile, ProjectExt: ".spx", + PackDir: "assets", PackIndex: "index.json", PortableConfig: snapshot, + RuntimeManifestPath: manifest, RuntimeCacheRoot: filepath.Join(moduleRoot, "cache"), + RuntimeIdentity: RuntimeIdentity{Version: lock.RuntimeVersion, ABI: lock.RuntimeABI, GOOS: runtime.GOOS, GOARCH: runtime.GOARCH}, + RuntimeLock: lock, + Source: SourceIdentity{SelectedPath: "github.com/goplus/spx/v3", SelectedVersion: "v3.0.0", EffectivePath: repoRoot, SourceMode: true}, + GoCommand: goCommand, WorkDir: moduleRoot, GoWork: "off", GraphFlags: []string{"-mod=mod"}, + BuildFlags: []string{"-trimpath=true", "-buildvcs=false"}, + Output: output, BridgePackage: "./bridge", VerifyGraph: func(context.Context) error { + graphChecks++ + return nil + }, + IO: IO{Stdout: &buildLog, Stderr: &buildLog, Env: os.Environ()}, + }) + if err != nil { + t.Fatalf("%v\n%s", err, buildLog.String()) + } + if result.Output != output || len(result.PayloadSHA256) != 64 || len(result.ManifestSHA256) != 64 || graphChecks != 4 { + t.Fatalf("result = %#v", result) + } +} + +func launchpackRepoRoot(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + root, err := filepath.Abs(filepath.Join(dir, "..", "..")) + if err != nil { + t.Fatal(err) + } + root, err = filepath.EvalSymlinks(root) + if err != nil { + t.Fatal(err) + } + return root +} + +func executableSuffix(goos string) string { + if goos == "windows" { + return ".exe" + } + return "" +} diff --git a/internal/launchpack/types.go b/internal/launchpack/types.go new file mode 100644 index 000000000..bb5fa6e1e --- /dev/null +++ b/internal/launchpack/types.go @@ -0,0 +1,105 @@ +/* + * 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 builds self-contained SPX launchers. It has no knowledge +// of XGo driver transport or provenance; callers provide those identities. +package launchpack + +import ( + "context" + "io" + + "github.com/goplus/spx/v3/internal/projectpolicy" + "github.com/goplus/spx/v3/internal/release" +) + +// IO describes command streams and environment. A nil Env inherits the process. +type IO struct { + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer + Env []string +} + +// RuntimeIdentity asserts the runtime consumed by a package operation. +type RuntimeIdentity struct { + Version string + ABI int + GOOS string + GOARCH string +} + +// SourceIdentity is a generic source identity recorded in the payload. It is +// intentionally independent of module graph and driver protocol types. +type SourceIdentity struct { + SelectedPath string + SelectedVersion string + EffectivePath string + EffectiveVersion string + Main bool + SourceMode bool +} + +// Config is the complete input to launcher packaging. +type Config struct { + ProjectDir string + ProjectFile string + ProjectExt string + PackDir string + PackIndex string + + PortableConfig projectpolicy.PortableConfigSnapshot + + RuntimeSourceRoot string + RuntimeManifestPath string + RuntimeAssetDir string + RuntimeCacheRoot string + RuntimeOffline bool + RuntimeIdentity RuntimeIdentity + + RuntimeLock release.RuntimeLock + Source SourceIdentity + + GoCommand string + WorkDir string + GoWork string + GraphFlags []string + BuildFlags []string + Output string + + BridgePackage string + VerifyGraph func(context.Context) error + VerifyBridge func(string) error + IO IO +} + +// 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. +type Assets struct { + EnginePath string + PackPath string + BridgePath string + Lock release.RuntimeLock + Cleanup func() +} + +// Result describes a successfully generated launcher and its embedded +// component identities. +type Result struct { + Output string + PayloadSHA256 string + ManifestSHA256 string +} diff --git a/internal/launchpack/validation.go b/internal/launchpack/validation.go new file mode 100644 index 000000000..a6b10c9ac --- /dev/null +++ b/internal/launchpack/validation.go @@ -0,0 +1,188 @@ +/* + * 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" + "path/filepath" + "runtime" + "strings" +) + +func (c Config) validate() error { + for name, value := range map[string]string{ + "project-dir": c.ProjectDir, "project-file": c.ProjectFile, + "output": c.Output, + } { + if value == "" { + return fmt.Errorf("launchpack: %s is required", name) + } + } + if err := regularPath("project-dir", c.ProjectDir, true); err != nil { + return err + } + if err := regularPath("project-file", c.ProjectFile, false); err != nil { + return err + } + if !pathWithin(c.ProjectDir, c.ProjectFile) { + return fmt.Errorf("launchpack: project-file must be within project-dir") + } + if err := c.validateGraphInputs(); err != nil { + return err + } + if runtime.GOOS != "windows" { + info, err := os.Stat(c.GoCommand) + if err == nil && info.Mode().Perm()&0o111 == 0 { + return fmt.Errorf("launchpack: go-command is not executable: %q", c.GoCommand) + } + } + if c.PackDir == "" || c.PackIndex == "" { + return fmt.Errorf("launchpack: pack directory and index are required") + } + if err := validatePackPath("pack directory", c.PackDir, true); err != nil { + return err + } + if err := validatePackPath("pack index", c.PackIndex, false); err != nil { + return err + } + if !filepath.IsAbs(c.Output) || filepath.Clean(c.Output) != c.Output { + return fmt.Errorf("launchpack: output must be an absolute clean path") + } + packRoot := filepath.Join(c.ProjectDir, filepath.FromSlash(c.PackDir)) + 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.RuntimeSourceRoot != "" { + if err := regularPath("runtime-source-root", c.RuntimeSourceRoot, true); err != nil { + return err + } + } + return nil +} + +// validatePackPath uses the slash-separated paths stored in project metadata; +// filepath semantics would reject nested pack directories on Windows. +func validatePackPath(name, value string, directory bool) error { + if value == "" || value == "." || value == ".." || strings.ContainsAny(value, "\\\x00:") || path.IsAbs(value) || looksLikeWindowsAbsolutePath(value) || path.Clean(value) != value { + return fmt.Errorf("launchpack: %s must be a clean relative slash path: %q", name, value) + } + for _, component := range strings.Split(value, "/") { + if component == "" || component == "." || component == ".." { + return fmt.Errorf("launchpack: %s contains an invalid path component: %q", name, value) + } + } + if !directory && strings.Contains(value, "/") { + return fmt.Errorf("launchpack: %s must be a plain file name: %q", name, value) + } + return nil +} + +func looksLikeWindowsAbsolutePath(value string) bool { + return strings.HasPrefix(value, "//") || len(value) >= 3 && isASCIIAlpha(value[0]) && value[1] == ':' && value[2] == '/' +} + +func isASCIIAlpha(value byte) bool { + return value >= 'a' && value <= 'z' || value >= 'A' && value <= 'Z' +} + +func (c Config) validateGraphInputs() error { + if c.GoCommand == "" || c.WorkDir == "" { + return fmt.Errorf("launchpack: Go command and work directory are required") + } + if err := regularPath("go-command", c.GoCommand, false); err != nil { + return err + } + if err := regularPath("work-dir", c.WorkDir, true); err != nil { + return err + } + if c.GoWork != "" && c.GoWork != "off" { + if err := regularPath("go-work", c.GoWork, false); err != nil { + return err + } + } + if err := validateGraphFlags(c.GraphFlags); err != nil { + return err + } + return validateBuildFlags(c.BuildFlags) +} + +func validateGraphFlags(flags []string) error { + for _, flag := range flags { + if !strings.HasPrefix(flag, "-") || flag == "-" { + return fmt.Errorf("launchpack: invalid graph flag: %q", flag) + } + name, value, hasValue := strings.Cut(strings.TrimLeft(flag, "-"), "=") + switch name { + case "overlay": + return fmt.Errorf("launchpack: graph overlay is not supported") + case "modfile": + if !hasValue || value == "" { + return fmt.Errorf("launchpack: graph modfile has no path") + } + if err := regularPath("graph modfile", value, false); err != nil { + return err + } + } + } + return nil +} + +func validateBuildFlags(flags []string) error { + for _, flag := range flags { + if !strings.HasPrefix(flag, "-") || flag == "-" { + return fmt.Errorf("launchpack: invalid build flag: %q", flag) + } + name, value, hasValue := strings.Cut(strings.TrimPrefix(flag, "-"), "=") + switch name { + case "v", "x", "work", "trimpath": + if hasValue && value != "true" && value != "false" { + return fmt.Errorf("launchpack: build flag -%s requires true or false", name) + } + case "buildvcs": + if !hasValue || value != "auto" && value != "true" && value != "false" { + return fmt.Errorf("launchpack: build flag -buildvcs requires auto, true, or false") + } + default: + return fmt.Errorf("launchpack: unsupported build flag: %q", flag) + } + } + return nil +} + +func pathWithin(root, target string) bool { + rel, err := filepath.Rel(root, target) + return err == nil && (rel == "." || rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) +} + +func regularPath(name, value string, directory bool) error { + info, err := os.Lstat(value) + if err != nil { + return fmt.Errorf("launchpack: %s cannot be inspected: %w", name, err) + } + if filepath.Clean(value) != value || !filepath.IsAbs(value) { + return fmt.Errorf("launchpack: %s must be an absolute clean path: %q", name, value) + } + if info.Mode()&os.ModeSymlink != 0 || (directory && !info.IsDir()) || (!directory && !info.Mode().IsRegular()) { + return fmt.Errorf("launchpack: %s has an invalid file type: %q", name, value) + } + return nil +} diff --git a/internal/launchpack/validation_test.go b/internal/launchpack/validation_test.go new file mode 100644 index 000000000..5c9e35f70 --- /dev/null +++ b/internal/launchpack/validation_test.go @@ -0,0 +1,67 @@ +/* + * 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 "testing" + +func TestValidateGraphFlags(t *testing.T) { + if err := validateGraphFlags([]string{"-trimpath", "-mod=readonly", "-buildvcs=false"}); err != nil { + t.Fatal(err) + } + for _, flags := range [][]string{{"trimpath"}, {"-overlay=/tmp/overlay.json"}, {"-modfile"}} { + if err := validateGraphFlags(flags); err == nil { + t.Fatalf("validateGraphFlags(%q) succeeded", flags) + } + } +} + +func TestValidateBuildFlags(t *testing.T) { + if err := validateBuildFlags([]string{"-v", "-trimpath=true", "-buildvcs=auto"}); err != nil { + t.Fatal(err) + } + for _, flags := range [][]string{{"-tags=custom"}, {"-v=maybe"}, {"-buildvcs"}} { + if err := validateBuildFlags(flags); err == nil { + t.Fatalf("validateBuildFlags(%q) succeeded", flags) + } + } +} + +func TestValidatePackPath(t *testing.T) { + for _, test := range []struct { + name string + value string + directory bool + wantErr bool + }{ + {name: "nested directory", value: "assets/images", directory: true}, + {name: "plain index", value: "index.json"}, + {name: "backslash", value: `assets\images`, directory: true, wantErr: true}, + {name: "absolute", value: "/tmp/assets", directory: true, wantErr: true}, + {name: "windows absolute", value: "C:/assets", directory: true, wantErr: true}, + {name: "parent escape", value: "../assets", directory: true, wantErr: true}, + {name: "dot component", value: "assets/./images", directory: true, wantErr: true}, + {name: "parent component", value: "assets/../images", directory: true, wantErr: true}, + {name: "nested index", value: "indexes/index.json", wantErr: true}, + } { + t.Run(test.name, func(t *testing.T) { + err := validatePackPath("pack path", test.value, test.directory) + if (err != nil) != test.wantErr { + t.Fatalf("validatePackPath(%q, directory=%v) error = %v, wantErr=%v", test.value, test.directory, err, test.wantErr) + } + }) + } +} From e45e86ed11a92b0ae1cbf13aa18eda3448c045a7 Mon Sep 17 00:00:00 2001 From: joeykchen <466719968@qq.com> Date: Fri, 21 Aug 2026 15:22:03 +0800 Subject: [PATCH 2/2] fix(launchpack): require root project entry --- internal/launchpack/project.go | 21 ++++++++++++++++++++- internal/launchpack/project_test.go | 13 +++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/internal/launchpack/project.go b/internal/launchpack/project.go index 7a75a69bc..aed365a8d 100644 --- a/internal/launchpack/project.go +++ b/internal/launchpack/project.go @@ -54,7 +54,10 @@ func collectProjectAllowlist(cfg Config) ([]string, error) { if len(projectFiles) == 0 { return nil, fmt.Errorf("launchpack: project has no top-level %s source files", extension) } - projectBase := filepath.Base(cfg.ProjectFile) + projectBase, err := topLevelProjectName(cfg.ProjectDir, cfg.ProjectFile) + if err != nil { + return nil, err + } foundProject := false for _, name := range projectFiles { if name == projectBase { @@ -76,6 +79,22 @@ func collectProjectAllowlist(cfg Config) ([]string, error) { return projectFiles, nil } +func topLevelProjectName(projectDir, projectFile string) (string, error) { + root, err := filepath.Abs(projectDir) + if err != nil { + return "", fmt.Errorf("launchpack: resolve project directory: %w", err) + } + file, err := filepath.Abs(projectFile) + if err != nil { + return "", fmt.Errorf("launchpack: resolve project file: %w", err) + } + rel, err := filepath.Rel(root, file) + if err != nil || rel == "." || filepath.Dir(rel) != "." { + return "", fmt.Errorf("launchpack: project file %q is not at the project root", projectFile) + } + return rel, nil +} + func prepareProjectBundleConfig(cfg Config, snapshot projectpolicy.PortableConfigSnapshot) (projectbundle.Config, error) { files, err := collectProjectAllowlist(cfg) if err != nil { diff --git a/internal/launchpack/project_test.go b/internal/launchpack/project_test.go index 6cb246e14..f46385679 100644 --- a/internal/launchpack/project_test.go +++ b/internal/launchpack/project_test.go @@ -51,6 +51,19 @@ func TestProjectBundleIncludesAllTopLevelSourcesAndPack(t *testing.T) { } } +func TestProjectBundleRejectsNestedProjectFile(t *testing.T) { + projectDir := t.TempDir() + writeProjectTestFile(t, filepath.Join(projectDir, "main.spx"), "main") + writeProjectTestFile(t, filepath.Join(projectDir, "nested", "main.spx"), "nested") + cfg := Config{ + ProjectDir: projectDir, ProjectFile: filepath.Join(projectDir, "nested", "main.spx"), + ProjectExt: ".spx", PackDir: "assets", PackIndex: "index.json", + } + if _, err := collectProjectAllowlist(cfg); err == nil { + t.Fatal("collectProjectAllowlist accepted a nested project file") + } +} + func writeProjectTestFile(t *testing.T, path, content string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {