diff --git a/cmd/ispxnative/main.go b/cmd/ispxnative/main.go index 66c6929d0..ad42b1978 100644 --- a/cmd/ispxnative/main.go +++ b/cmd/ispxnative/main.go @@ -20,34 +20,169 @@ package main import ( "fmt" + "io" + "io/fs" "os" "path/filepath" + "sync" _ "unsafe" + "github.com/goplus/spx/v3/internal/interpruntime" "github.com/goplus/spx/v3/pkg/ispx" ) +var retainedProjectRoot struct { + sync.Mutex + root *os.Root +} + func main() { - // The project directory is one level up from the current working directory - // because this shared library runs from either: - // - .temp/ directory (spx run interpreted mode) - // - project/ directory (spx runnative/editor mode) - // - // In both cases, the spx source files (.spx) are in the parent directory. - projDir, err := filepath.Abs("..") + exitCode, err := run(os.Environ()) if err != nil { - panic("Failed to get project directory: " + err.Error()) + fmt.Fprintf(os.Stderr, "ispxnative: %v\n", err) + if exitCode == 0 { + exitCode = 1 + } + } + if exitCode != 0 { + os.Exit(exitCode) } +} +func run(env []string) (int, error) { + roots, err := interpruntime.RootsFromEnv(env) + if err != nil { + return 1, err + } + if err := validateAssetIndex(roots.AssetDir); err != nil { + return 1, err + } + if err := ispx.ConfigureLegacyFilesystemRoots(roots.ProjectDir, roots.AssetDir); err != nil { + return 1, fmt.Errorf("configure filesystem roots: %w", err) + } if err := ispx.Init(nil); err != nil { - panic("Failed to initialize: " + err.Error()) + return 1, fmt.Errorf("initialize interpreter: %w", err) + } + + if err := buildPinnedProject(roots.ProjectDir, ispx.BuildFS); err != nil { + return 1, fmt.Errorf("build project: %w", err) + } + + exitCode, err := ispx.Run() + if err != nil { + return exitCode, fmt.Errorf("interpreter exited with code %d: %w", exitCode, err) + } + return exitCode, nil +} + +// buildPinnedProject retains its root because BuildFS may load resources later. +func buildPinnedProject(projectDir string, build func(fs.FS) error) error { + projectRoot, err := openPinnedProjectRoot(projectDir) + if err != nil { + return err + } + if err := build(projectRoot.FS()); err != nil { + projectRoot.Close() + return err + } + retainProjectRoot(projectRoot) + return nil +} + +func retainProjectRoot(root *os.Root) { + retainedProjectRoot.Lock() + previous := retainedProjectRoot.root + retainedProjectRoot.root = root + retainedProjectRoot.Unlock() + if previous != nil { + _ = previous.Close() + } +} + +func releaseProjectRoot() { + retainedProjectRoot.Lock() + root := retainedProjectRoot.root + retainedProjectRoot.root = nil + retainedProjectRoot.Unlock() + if root != nil { + _ = root.Close() } +} - if err := ispx.BuildFS(os.DirFS(projDir)); err != nil { - panic("Failed to build: " + err.Error()) +func validateAssetIndex(assetDir string) error { + found := false + for _, name := range []string{"index_pack.json", "index.json"} { + indexPath := filepath.Join(assetDir, name) + exists, err := validateStableRegularFile(indexPath) + if err != nil { + return fmt.Errorf("validate asset index %q: %w", indexPath, err) + } + found = found || exists + } + if !found { + return fmt.Errorf("validate asset index: neither %q nor %q exists", filepath.Join(assetDir, "index_pack.json"), filepath.Join(assetDir, "index.json")) } + return nil +} - if exitCode, err := ispx.Run(); err != nil { - panic(fmt.Sprintf("interpreter exited with code %d: %v", exitCode, err)) +func validateStableRegularFile(name string) (bool, error) { + before, err := os.Lstat(name) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, err + } + if before.Mode()&os.ModeSymlink != 0 || !before.Mode().IsRegular() { + return false, fmt.Errorf("must be a regular non-symlink file") + } + file, err := os.Open(name) + if err != nil { + return false, err + } + opened, err := file.Stat() + if err != nil { + file.Close() + return false, err + } + read, readErr := io.Copy(io.Discard, file) + afterOpened, statErr := file.Stat() + closeErr := file.Close() + afterPath, lstatErr := os.Lstat(name) + if readErr != nil { + return false, readErr + } + if statErr != nil { + return false, statErr + } + if closeErr != nil { + return false, closeErr + } + if lstatErr != nil || afterPath.Mode()&os.ModeSymlink != 0 || !opened.Mode().IsRegular() || + !os.SameFile(before, opened) || !os.SameFile(opened, afterOpened) || !os.SameFile(afterOpened, afterPath) || + read != opened.Size() || opened.Mode() != afterOpened.Mode() || opened.Size() != afterOpened.Size() || opened.ModTime() != afterOpened.ModTime() { + return false, fmt.Errorf("changed while it was read") + } + return true, nil +} + +func openPinnedProjectRoot(projectDir string) (*os.Root, error) { + before, err := os.Lstat(projectDir) + if err != nil { + return nil, fmt.Errorf("inspect project root %q: %w", projectDir, err) + } + if before.Mode()&os.ModeSymlink != 0 || !before.IsDir() { + return nil, fmt.Errorf("project root %q must be a real directory", projectDir) + } + root, err := os.OpenRoot(projectDir) + if err != nil { + return nil, fmt.Errorf("open project root %q: %w", projectDir, err) + } + opened, statErr := root.Stat(".") + after, lstatErr := os.Lstat(projectDir) + if statErr != nil || lstatErr != nil || !opened.IsDir() || !os.SameFile(before, opened) || !os.SameFile(opened, after) { + root.Close() + return nil, fmt.Errorf("project root %q changed while opening", projectDir) } + return root, nil } diff --git a/cmd/ispxnative/main_test.go b/cmd/ispxnative/main_test.go new file mode 100644 index 000000000..0f71dc033 --- /dev/null +++ b/cmd/ispxnative/main_test.go @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package main + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestValidateAssetIndex(t *testing.T) { + assetDir := t.TempDir() + indexPath := filepath.Join(assetDir, "index.json") + if err := os.WriteFile(indexPath, []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := validateAssetIndex(assetDir); err != nil { + t.Fatalf("validateAssetIndex() error = %v", err) + } +} + +func TestValidateAssetIndexRejectsMissingAndSymlink(t *testing.T) { + if err := validateAssetIndex(t.TempDir()); err == nil { + t.Fatal("validateAssetIndex accepted a missing index") + } + + assetDir := t.TempDir() + target := filepath.Join(t.TempDir(), "index.json") + if err := os.WriteFile(target, []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(assetDir, "index.json")); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + if err := validateAssetIndex(assetDir); err == nil { + t.Fatal("validateAssetIndex accepted a symlink index") + } +} + +func TestValidateAssetIndexAcceptsPackedOnly(t *testing.T) { + assetDir := t.TempDir() + if err := os.WriteFile(filepath.Join(assetDir, "index_pack.json"), []byte(`{"zorder":[]}`), 0o600); err != nil { + t.Fatal(err) + } + if err := validateAssetIndex(assetDir); err != nil { + t.Fatalf("validateAssetIndex() packed-only error = %v", err) + } +} + +func TestPinnedProjectRootRejectsEscapingSymlink(t *testing.T) { + projectDir := t.TempDir() + externalDir := t.TempDir() + if err := os.WriteFile(filepath.Join(externalDir, "index.json"), []byte(`{}`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(projectDir, "assets"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(externalDir, filepath.Join(projectDir, "assets", "linked")); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + + root, err := openPinnedProjectRoot(projectDir) + if err != nil { + t.Fatal(err) + } + defer root.Close() + if _, err := fs.ReadFile(root.FS(), "assets/linked/index.json"); err == nil { + t.Fatal("project filesystem followed a symlink outside ProjectDir") + } +} + +func TestBuildPinnedProjectKeepsFilesystemAliveForDeferredResourceLoads(t *testing.T) { + t.Cleanup(releaseProjectRoot) + projectDir := t.TempDir() + if err := os.WriteFile(filepath.Join(projectDir, "main.spx"), []byte("onStart => {}\n"), 0o600); err != nil { + t.Fatal(err) + } + + var runtimeFS fs.FS + if err := buildPinnedProject(projectDir, func(fsys fs.FS) error { + runtimeFS = fsys + return nil + }); err != nil { + t.Fatal(err) + } + runtime.GC() + if _, err := fs.ReadFile(runtimeFS, "main.spx"); err != nil { + t.Fatalf("deferred project read failed after build returned: %v", err) + } +} + +func TestBuildPinnedProjectClosesFilesystemOnBuildFailure(t *testing.T) { + t.Cleanup(releaseProjectRoot) + projectDir := t.TempDir() + if err := os.WriteFile(filepath.Join(projectDir, "main.spx"), []byte("onStart => {}\n"), 0o600); err != nil { + t.Fatal(err) + } + + wantErr := errors.New("build failed") + var failedFS fs.FS + err := buildPinnedProject(projectDir, func(fsys fs.FS) error { + failedFS = fsys + return wantErr + }) + if !errors.Is(err, wantErr) { + t.Fatalf("buildPinnedProject() error = %v, want %v", err, wantErr) + } + if _, err := fs.ReadFile(failedFS, "main.spx"); err == nil { + t.Fatal("failed build left the project root open") + } +} diff --git a/cmd/spx/internal/command/cmd.go b/cmd/spx/internal/command/cmd.go index 4947cb17f..165afc870 100644 --- a/cmd/spx/internal/command/cmd.go +++ b/cmd/spx/internal/command/cmd.go @@ -103,6 +103,18 @@ func (cmd *CmdTool) RunCmd(projectName, fileSuffix, version string, fs embed.FS, } return err } + if isInterpretedRunCommand(cmd.Args.CmdName) { + err = cmd.setupInterpretedPaths(dstRelDir) + if err != nil { + logErrorf("Setting up interpreted paths: %v", err) + return err + } + err = cmd.handleInterpretedRunCommand() + if err != nil { + logErrorf("Executing interpreted run command: %v", err) + } + return err + } err = cmd.setupPaths(dstRelDir) if err != nil { @@ -113,15 +125,6 @@ func (cmd *CmdTool) RunCmd(projectName, fileSuffix, version string, fs embed.FS, if cmd.handleSpecialCommands() { return nil } - - if isInterpretedRunCommand(cmd.Args.CmdName) { - err = cmd.handleInterpretedRunCommand() - if err != nil { - logErrorf("Executing interpreted run command: %v", err) - } - return err - } - if isRuntimeModeCommand(cmd.Args.CmdName) { cmd.RuntimeMode = true } @@ -146,6 +149,21 @@ func (cmd *CmdTool) RunCmd(projectName, fileSuffix, version string, fs embed.FS, return cmd.executeCommand() } +// setupInterpretedPaths resolves the source and session roots without changing +// the process working directory. Other legacy commands continue to use +// setupPaths until their generated-project assumptions are migrated. +func (cmd *CmdTool) setupInterpretedPaths(dstRelDir string) error { + targetDir, err := filepath.Abs(*cmd.Args.Path) + if err != nil { + return fmt.Errorf("failed to resolve target directory: %w", err) + } + cmd.TargetAbsDir = filepath.Clean(targetDir) + cmd.TargetDir = cmd.TargetAbsDir + cmd.Args.Path = &cmd.TargetDir + cmd.ProjectDir = filepath.Join(cmd.TargetAbsDir, dstRelDir) + return nil +} + // handleSpecialCommands handles commands without setup. func (cmd *CmdTool) handleSpecialCommands() bool { switch cmd.Args.CmdName { diff --git a/cmd/spx/internal/command/run.go b/cmd/spx/internal/command/run.go index 4c2ff8112..2e688f875 100644 --- a/cmd/spx/internal/command/run.go +++ b/cmd/spx/internal/command/run.go @@ -18,6 +18,7 @@ package command import ( "bytes" + "context" "errors" "fmt" "os" @@ -31,6 +32,7 @@ import ( "github.com/goplus/spx/v3/cmd/spx/internal/runtimeasset" "github.com/goplus/spx/v3/cmd/spx/internal/util" + "github.com/goplus/spx/v3/internal/interpruntime" "github.com/goplus/spx/v3/internal/scaffold" ) @@ -137,12 +139,30 @@ func (cmd *CmdTool) RunInterpreted(pargs ...string) error { } cmd.RuntimeCmdPath = runtimePath - if err := cmd.prepareInterpretedRuntimeDir(libPath); err != nil { + roots, err := cmd.interpretedRoots() + if err != nil { return err } - - args := cmd.buildRuntimeArgs(pargs, cmd.RuntimeTempDir) - return util.RunCommandInDir(cmd.RuntimeTempDir, runtimePath, args...) + if err := interpruntime.PrepareSession(interpruntime.SessionConfig{Roots: roots, BridgePath: libPath}); err != nil { + return err + } + engineCmd, err := interpruntime.PrepareCommand(context.Background(), interpruntime.CommandConfig{ + Roots: roots, + Executable: runtimePath, + Args: pargs, + Env: os.Environ(), + Stdin: os.Stdin, + Stdout: os.Stdout, + Stderr: os.Stderr, + PathPolicy: interpruntime.ReplacePath, + }) + if err != nil { + return err + } + if err := engineCmd.Run(); err != nil { + return fmt.Errorf("interpreted Engine failed: %w", err) + } + return nil } // buildRuntimeArgs builds gdspxrt args. @@ -255,23 +275,35 @@ func runtimePackPath(runtimePath string) string { return filepath.Join(filepath.Dir(runtimePath), runtimePackFileName(filepath.Base(runtimePath))) } -func (cmd *CmdTool) prepareInterpretedRuntimeDir(libPath string) error { - if err := os.MkdirAll(cmd.RuntimeTempDir, 0o755); err != nil { - return fmt.Errorf("failed to create runtime temp dir %s: %w", cmd.RuntimeTempDir, err) +func (cmd *CmdTool) interpretedRoots() (interpruntime.Roots, error) { + projectDir := cmd.TargetAbsDir + if projectDir == "" && cmd.TargetDir != "" { + var err error + projectDir, err = filepath.Abs(cmd.TargetDir) + if err != nil { + return interpruntime.Roots{}, fmt.Errorf("resolve interpreted project directory: %w", err) + } } - - // Place the shared library next to runtime.gdextension so Godot can resolve it - // without depending on a pre-installed runtime.gdextension file. - dstLibPath := filepath.Join(cmd.RuntimeTempDir, filepath.Base(libPath)) - if err := util.CopyFile(libPath, dstLibPath); err != nil { - return fmt.Errorf("failed to copy shared library %s to %s: %w", libPath, dstLibPath, err) + if projectDir == "" { + return interpruntime.Roots{}, fmt.Errorf("interpreted project directory is not configured") } - - extensionPath := filepath.Join(cmd.RuntimeTempDir, "runtime.gdextension") - if err := os.WriteFile(extensionPath, []byte(scaffold.RuntimeGDExtension()), 0o644); err != nil { - return fmt.Errorf("failed to write runtime.gdextension: %w", err) + projectDir = filepath.Clean(projectDir) + sessionDir := cmd.RuntimeTempDir + if sessionDir == "" { + sessionDir = filepath.Join(projectDir, ".temp") + } + if !filepath.IsAbs(sessionDir) { + var err error + sessionDir, err = filepath.Abs(sessionDir) + if err != nil { + return interpruntime.Roots{}, fmt.Errorf("resolve interpreted session directory: %w", err) + } } - return prepareRuntimeExtensionList(cmd.RuntimeTempDir) + return interpruntime.Roots{ + ProjectDir: projectDir, + AssetDir: filepath.Join(projectDir, "assets"), + SessionDir: filepath.Clean(sessionDir), + }, nil } func prepareRuntimeExtensionList(runtimeDir string) error { diff --git a/cmd/spx/internal/command/run_test.go b/cmd/spx/internal/command/run_test.go index 2af42f438..e9b362876 100644 --- a/cmd/spx/internal/command/run_test.go +++ b/cmd/spx/internal/command/run_test.go @@ -303,7 +303,34 @@ func TestBrowserOpenCommandsWindowsUsesRundll32(t *testing.T) { } } -func TestRunInterpretedCreatesRuntimeExtensionAndCopiesSharedLibrary(t *testing.T) { +func TestSetupInterpretedPathsKeepsWorkingDirectory(t *testing.T) { + before, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + projectDir := t.TempDir() + pathArg := projectDir + cmd := CmdTool{Args: ExtraArgs{Path: &pathArg}} + + if err := cmd.setupInterpretedPaths("project"); err != nil { + t.Fatal(err) + } + after, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if after != before { + t.Fatalf("working directory changed from %q to %q", before, after) + } + if cmd.TargetAbsDir != projectDir || cmd.TargetDir != projectDir { + t.Fatalf("project root = (%q, %q), want %q", cmd.TargetAbsDir, cmd.TargetDir, projectDir) + } + if cmd.ProjectDir != filepath.Join(projectDir, "project") { + t.Fatalf("Engine project dir = %q", cmd.ProjectDir) + } +} + +func TestRunInterpretedCreatesIsolatedSessionAndCopiesSharedLibrary(t *testing.T) { oldPrepareEmbeddedRuntimeAssets := prepareEmbeddedRuntimeAssets prepareEmbeddedRuntimeAssets = func(string, ...string) (string, bool, error) { return "", false, nil @@ -313,7 +340,9 @@ func TestRunInterpretedCreatesRuntimeExtensionAndCopiesSharedLibrary(t *testing. }) goBinPath := t.TempDir() - runtimeTempDir := t.TempDir() + projectDir := t.TempDir() + mustWriteAssetIndex(t, projectDir) + runtimeTempDir := filepath.Join(projectDir, ".temp") logPath := filepath.Join(t.TempDir(), "runtime.log") version := "9.9.9-test" @@ -331,6 +360,7 @@ func TestRunInterpretedCreatesRuntimeExtensionAndCopiesSharedLibrary(t *testing. cmd := CmdTool{ GoBinPath: goBinPath, RuntimeTempDir: runtimeTempDir, + TargetAbsDir: projectDir, Version: version, } cmd.BinPostfix = executableSuffix(runtime.GOOS) @@ -347,19 +377,27 @@ func TestRunInterpretedCreatesRuntimeExtensionAndCopiesSharedLibrary(t *testing. if err != nil { t.Fatalf("read runtime.gdextension: %v", err) } - if string(gotExtension) != scaffold.RuntimeGDExtension() { + if string(gotExtension) != scaffold.SessionRuntimeGDExtension() { t.Fatalf("runtime.gdextension contents mismatch") } + projectExtensionPath := filepath.Join(runtimeTempDir, "gdspx.gdextension") + gotProjectExtension, err := os.ReadFile(projectExtensionPath) + if err != nil { + t.Fatalf("read gdspx.gdextension: %v", err) + } + if string(gotProjectExtension) != scaffold.ProjectGDExtension() { + t.Fatalf("gdspx.gdextension contents mismatch") + } extensionListPath := filepath.Join(runtimeTempDir, ".godot", "extension_list.cfg") gotExtensionList, err := os.ReadFile(extensionListPath) if err != nil { t.Fatalf("read extension_list.cfg: %v", err) } - if string(gotExtensionList) != scaffold.RuntimeExtensionList() { + if string(gotExtensionList) != scaffold.SessionExtensionList() { t.Fatalf("extension_list.cfg contents mismatch") } - copiedLibPath := filepath.Join(runtimeTempDir, libName) + copiedLibPath := filepath.Join(runtimeTempDir, "lib", libName) if !fileExists(copiedLibPath) { t.Fatalf("shared library not copied to %s", copiedLibPath) } @@ -375,6 +413,15 @@ func TestRunInterpretedCreatesRuntimeExtensionAndCopiesSharedLibrary(t *testing. if strings.Contains(logContent, "--gdextpath") { t.Fatalf("runtime log = %q, custom --gdextpath should not be used", logContent) } + for _, want := range []string{ + "SPX_PROJECT_DIR=" + projectDir, + "SPX_ASSET_DIR=" + filepath.Join(projectDir, "assets"), + "SPX_SESSION_DIR=" + runtimeTempDir, + } { + if !strings.Contains(logContent, want+"\n") { + t.Fatalf("runtime log = %q, want %q", logContent, want) + } + } } func TestResolveInterpretedRuntimeAssetsPrefersEmbedded(t *testing.T) { @@ -466,6 +513,17 @@ func TestResolveInterpretedRuntimeAssetsFallsBackToExternalWhenEmbeddedUnavailab } } +func mustWriteAssetIndex(t *testing.T, projectDir string) { + t.Helper() + assetDir := filepath.Join(projectDir, "assets") + if err := os.MkdirAll(assetDir, 0o755); err != nil { + t.Fatalf("mkdir assets: %v", err) + } + if err := os.WriteFile(filepath.Join(assetDir, "index.json"), []byte("{}\n"), 0o644); err != nil { + t.Fatalf("write assets/index.json: %v", err) + } +} + func TestGetWasmPathsPrefersProjectBuild(t *testing.T) { projectDir := t.TempDir() cmd := CmdTool{ @@ -602,9 +660,9 @@ func writeTestRuntimeExecutable(t *testing.T, path string, logPath string) { var script string if runtime.GOOS == "windows" { - script = fmt.Sprintf("@echo off\r\ncd > %q\r\nfor %%%%a in (%%*) do @echo %%%%a>>%q\r\n", logPath, logPath) + script = fmt.Sprintf("@echo off\r\ncd > %q\r\nfor %%%%a in (%%*) do @echo %%%%a>>%q\r\necho SPX_PROJECT_DIR=%%SPX_PROJECT_DIR%%>>%q\r\necho SPX_ASSET_DIR=%%SPX_ASSET_DIR%%>>%q\r\necho SPX_SESSION_DIR=%%SPX_SESSION_DIR%%>>%q\r\n", logPath, logPath, logPath, logPath, logPath) } else { - script = fmt.Sprintf("#!/bin/sh\npwd > %q\nprintf '%%s\\n' \"$@\" >> %q\n", logPath, logPath) + script = fmt.Sprintf("#!/bin/sh\npwd > %q\nprintf '%%s\\n' \"$@\" >> %q\nprintf 'SPX_PROJECT_DIR=%%s\\nSPX_ASSET_DIR=%%s\\nSPX_SESSION_DIR=%%s\\n' \"$SPX_PROJECT_DIR\" \"$SPX_ASSET_DIR\" \"$SPX_SESSION_DIR\" >> %q\n", logPath, logPath, logPath) } if err := os.WriteFile(path, []byte(script), 0o755); err != nil { t.Fatalf("write runtime executable: %v", err) diff --git a/internal/engine/path.go b/internal/engine/path.go index 4d5052d8f..2902b164c 100644 --- a/internal/engine/path.go +++ b/internal/engine/path.go @@ -17,10 +17,14 @@ package engine import ( + "encoding/json" + "fmt" + "os" + pathpkg "path" "path/filepath" - "slices" "strings" + spxfs "github.com/goplus/spx/v3/fs" spxlog "github.com/goplus/spx/v3/internal/log" ) @@ -33,33 +37,101 @@ const ( ) type assetPathState struct { - prefix string - root string - extAssetDir string + root string + projectRoot string + compatibilityRoot string + canonicalProjectRoot string + canonicalCompatibilityRoot string + extAssetDir string + explicitFSRoots bool + enforceCanonical bool + legacyCompatibility bool } var ( assetPaths = assetPathState{ - prefix: defaultAssetPathPrefix, - root: defaultAssetPathPrefix + defaultAssetDirName + "/", + root: defaultAssetPathPrefix + defaultAssetDirName + "/", + projectRoot: cleanFilesystemPath(defaultAssetPathPrefix), } ) -type assetProjectConfig struct { - ExtAsset string `json:"extasset"` +// SetFilesystemRoots configures the physical project and asset roots used by +// interpreted desktop sessions. It only updates path state; the Engine resource +// manager is configured later, after the GDExtension has linked its callbacks. +func SetFilesystemRoots(projectDir, assetDir string) error { + return setFilesystemRoots(projectDir, assetDir, false) } -func projectConfigPath(prefix string) string { - return normalizeSlashes(prefix + projectConfigFile) +// SetLegacyFilesystemRoots enables bounded external-resource compatibility for +// existing SPX commands. +func SetLegacyFilesystemRoots(projectDir, assetDir string) error { + return setFilesystemRoots(projectDir, assetDir, true) } -func setAssetRoot(prefix, dir string) { - assetPaths.prefix = prefix - assetPaths.root = joinAssetRoot(prefix, defaultAssetDir(dir)) +func setFilesystemRoots(projectDir, assetDir string, legacy bool) error { + projectRoot, err := validateFilesystemRoot("project", projectDir) + if err != nil { + return err + } + assetRoot, err := validateFilesystemRoot("asset", assetDir) + if err != nil { + return err + } + canonicalProjectRoot, err := filepath.EvalSymlinks(filepath.FromSlash(projectRoot)) + if err != nil { + return fmt.Errorf("engine: canonicalize project root %q: %w", projectDir, err) + } + canonicalAssetRoot, err := filepath.EvalSymlinks(filepath.FromSlash(assetRoot)) + if err != nil { + return fmt.Errorf("engine: canonicalize asset root %q: %w", assetDir, err) + } + if !isWithinRoot(cleanFilesystemPath(canonicalAssetRoot), cleanFilesystemPath(canonicalProjectRoot)) { + return fmt.Errorf("engine: asset root %q must be within project root %q", assetDir, projectDir) + } + compatibilityRoot := "" + canonicalCompatibilityRoot := "" + if legacy { + compatibilityRoot = cleanFilesystemPath(filepath.Join(filepath.FromSlash(assetRoot), "..", "..")) + canonical, err := filepath.EvalSymlinks(filepath.FromSlash(compatibilityRoot)) + if err != nil { + return fmt.Errorf("engine: canonicalize legacy compatibility root %q: %w", compatibilityRoot, err) + } + canonicalCompatibilityRoot = cleanFilesystemPath(canonical) + } + assetPaths = assetPathState{ + root: joinAssetRoot("", assetRoot), + projectRoot: projectRoot, + compatibilityRoot: compatibilityRoot, + canonicalProjectRoot: cleanFilesystemPath(canonicalProjectRoot), + canonicalCompatibilityRoot: canonicalCompatibilityRoot, + extAssetDir: readExtAssetDirFromFilesystem(projectRoot, legacy), + explicitFSRoots: true, + enforceCanonical: true, + legacyCompatibility: legacy, + } + return nil +} + +func validateFilesystemRoot(name, root string) (string, error) { + if root == "" { + return "", fmt.Errorf("engine: %s root is empty", name) + } + if !filepath.IsAbs(root) { + return "", fmt.Errorf("engine: %s root %q is not absolute", name, root) + } + if clean := filepath.Clean(root); clean != root { + return "", fmt.Errorf("engine: %s root %q is not clean (want %q)", name, root, clean) + } + return cleanFilesystemPath(root), nil } -func setExtAssetDir(dir string) { - assetPaths.extAssetDir = dir +func setAssetRoot(prefix, dir string) { + assetPaths.root = joinAssetRoot(prefix, defaultAssetDir(dir)) + if prefix == packmodeAssetPrefix { + assetPaths.projectRoot = packmodeAssetPrefix + } else { + assetPaths.projectRoot = cleanFilesystemPath(prefix) + } } func defaultAssetDir(dir string) string { @@ -82,59 +154,68 @@ func cleanFilesystemPath(path string) string { } func buildFilesystemAssetPath(relPath string) string { - if replacedPath := rewriteExtAssetPath(relPath); replacedPath != "" { - return replacedPath - } - - root := cleanFilesystemPath(assetPaths.root) - path := cleanFilesystemPath(filepath.Join(root, relPath)) - if isWithinRoot(path, root) { - return path - } - // Preserve legacy projects that referenced a shared sibling resource directory - // with "../../...". The compatibility root is still bounded to two parent levels - // above the configured asset root, so paths outside that legacy scope stay rejected. - if leadingParentCount(relPath) >= 2 && isWithinCompatibilityRoot(path, root) { - return path + base, name, allowCompatibility, ok := filesystemAssetReference(relPath) + if !ok { + return "" } - return "" -} -func rewriteExtAssetPath(relPath string) string { - if assetPaths.extAssetDir == "" { + projectRoot := cleanFilesystemPath(assetPaths.projectRoot) + base = cleanFilesystemPath(base) + if projectRoot == "" || projectRoot == "." && filepath.IsAbs(filepath.FromSlash(base)) { return "" } - - path := cleanFilesystemPath(relPath) - segments := strings.Split(path, "/") - leadingParents := 0 - for i, segment := range segments { - if segment == "" { - continue + resolvedPath := cleanFilesystemPath(filepath.Join(filepath.FromSlash(base), filepath.FromSlash(name))) + canonicalRoot := assetPaths.canonicalProjectRoot + if !isWithinRoot(resolvedPath, projectRoot) { + if !assetPaths.legacyCompatibility || !allowCompatibility || leadingParentCount(name) < 2 || + assetPaths.compatibilityRoot == "" || !isWithinRoot(resolvedPath, assetPaths.compatibilityRoot) { + return "" } - if segment == ".." { - leadingParents++ - continue + canonicalRoot = assetPaths.canonicalCompatibilityRoot + } + if assetPaths.enforceCanonical { + if canonicalRoot == "" { + return "" } - if segment != assetPaths.extAssetDir { - if containsPathSegment(segments[i+1:], assetPaths.extAssetDir) { - spxlog.Warn("ToAssetPath: extassetDir must be in the root directory: %s", relPath) - } - if leadingParents == 0 { - return "" - } + info, err := os.Lstat(filepath.FromSlash(resolvedPath)) + if err != nil || info.Mode()&os.ModeSymlink != 0 { return "" } - if leadingParents == 0 { + canonicalPath, err := filepath.EvalSymlinks(filepath.FromSlash(resolvedPath)) + if err != nil || !isWithinRoot(cleanFilesystemPath(canonicalPath), canonicalRoot) { return "" } + } + return resolvedPath +} - suffix := filepath.Join(segments[i+1:]...) - newPath := assetPaths.prefix + filepath.Join(engineExtAssetPath, suffix) - return normalizeSlashes(newPath) +func filesystemAssetReference(reference string) (base, name string, allowCompatibility, ok bool) { + if reference == "" { + return "", "", false, false } + schema, file := spxfs.SplitSchema(reference) + switch schema { + case "": + base, name, allowCompatibility = assetPaths.root, file, true + case "res": + if !strings.HasPrefix(reference, packmodeAssetPrefix) { + return "", "", false, false + } + base, name = assetPaths.projectRoot, file + default: + return "", "", false, false + } + if !isPortableRelativeResourcePath(name) { + return "", "", false, false + } + return base, name, allowCompatibility, true +} - return "" +func isPortableRelativeResourcePath(name string) bool { + if name == "" || strings.ContainsAny(name, "\\:\x00") { + return false + } + return !pathpkg.IsAbs(name) && !filepath.IsAbs(filepath.FromSlash(name)) } func isWithinRoot(path, root string) bool { @@ -146,15 +227,9 @@ func isWithinRoot(path, root string) bool { return rel == "." || (rel != ".." && !strings.HasPrefix(rel, "../")) } -func isWithinCompatibilityRoot(path, assetRoot string) bool { - root := cleanFilesystemPath(filepath.Join(assetRoot, "../..")) - return isWithinRoot(path, root) -} - -func leadingParentCount(relPath string) int { - normalized := cleanFilesystemPath(relPath) +func leadingParentCount(name string) int { count := 0 - for segment := range strings.SplitSeq(normalized, "/") { + for _, segment := range strings.Split(cleanFilesystemPath(name), "/") { if segment != ".." { break } @@ -163,6 +238,65 @@ func leadingParentCount(relPath string) int { return count } -func containsPathSegment(segments []string, target string) bool { - return slices.Contains(segments, target) +type assetProjectConfig struct { + ExtAsset string `json:"extasset"` +} + +func projectConfigPath(prefix string) string { + return normalizeSlashes(prefix + projectConfigFile) +} + +func readExtAssetDirFromFilesystem(projectRoot string, enabled bool) string { + if !enabled { + return "" + } + configPath := filepath.Join(filepath.FromSlash(projectRoot), projectConfigFile) + data, err := os.ReadFile(configPath) + if os.IsNotExist(err) { + return "" + } + if err != nil { + spxlog.Warn("SetAssetDir: failed to read %s: %v", configPath, err) + return "" + } + return parseExtAssetDir(configPath, data) +} + +func readExtAssetDirFromProjectConfig(prefix string) string { + configPath := projectConfigPath(prefix) + if !resMgr.HasFile(configPath) { + return "" + } + return parseExtAssetDir(configPath, []byte(resMgr.ReadAllText(configPath))) +} + +func parseExtAssetDir(configPath string, data []byte) string { + var config assetProjectConfig + if err := json.Unmarshal(data, &config); err != nil { + spxlog.Warn("SetAssetDir: failed to parse %s: %v", configPath, err) + return "" + } + return config.ExtAsset +} + +func extAssetSuffix(relPath string) (string, bool) { + if assetPaths.extAssetDir == "" { + return "", false + } + segments := strings.Split(cleanFilesystemPath(relPath), "/") + leadingParents := 0 + for i, segment := range segments { + if segment == "" { + continue + } + if segment == ".." { + leadingParents++ + continue + } + if leadingParents == 0 || segment != assetPaths.extAssetDir { + return "", false + } + return normalizeSlashes(filepath.Join(segments[i+1:]...)), true + } + return "", false } diff --git a/internal/engine/path_filesystem.go b/internal/engine/path_filesystem.go index 86519c8a7..544e4ca48 100644 --- a/internal/engine/path_filesystem.go +++ b/internal/engine/path_filesystem.go @@ -20,39 +20,59 @@ package engine import ( - "encoding/json" + "path/filepath" "github.com/goplus/spx/v3/internal/engine/platform" - spxlog "github.com/goplus/spx/v3/internal/log" ) func SetAssetDir(dir string) { resMgr.SetLoadMode(true) + if assetPaths.explicitFSRoots { + if assetPaths.legacyCompatibility { + assetPaths.extAssetDir = readExtAssetDirFromFilesystem(assetPaths.projectRoot, true) + } + return + } + setLegacyFilesystemAssetDir(dir) + // Reading the project config uses the Engine resource manager. Keep that + // call at the public Engine boundary so path-state tests can configure the + // legacy filesystem roots without initializing enginewrap. + prefix := defaultAssetPathPrefix + if platform.IsWeb() { + prefix = "" + } + assetPaths.extAssetDir = readExtAssetDirFromProjectConfig(prefix) +} +func setLegacyFilesystemAssetDir(dir string) { prefix := defaultAssetPathPrefix if platform.IsWeb() { prefix = "" } - setExtAssetDir(readExtAssetDirFromProjectConfig(prefix)) setAssetRoot(prefix, dir) + assetPaths.explicitFSRoots = false + assetPaths.legacyCompatibility = true + assetPaths.compatibilityRoot = cleanFilesystemPath(filepath.Join(filepath.FromSlash(assetPaths.root), "..", "..")) + assetPaths.canonicalCompatibilityRoot = "" + assetPaths.extAssetDir = "" + assetPaths.enforceCanonical = !platform.IsWeb() + assetPaths.canonicalProjectRoot = "" + if assetPaths.enforceCanonical { + projectRoot, err := filepath.Abs(filepath.FromSlash(assetPaths.projectRoot)) + if err == nil { + projectRoot, err = filepath.EvalSymlinks(projectRoot) + } + if err == nil { + assetPaths.canonicalProjectRoot = cleanFilesystemPath(projectRoot) + } + compatibilityRoot, compatErr := filepath.EvalSymlinks(filepath.FromSlash(assetPaths.compatibilityRoot)) + if compatErr == nil { + assetPaths.canonicalCompatibilityRoot = cleanFilesystemPath(compatibilityRoot) + } + } } func ToAssetPath(relPath string) string { return buildFilesystemAssetPath(relPath) } - -func readExtAssetDirFromProjectConfig(prefix string) string { - configPath := projectConfigPath(prefix) - if !resMgr.HasFile(configPath) { - return "" - } - - configJSON := resMgr.ReadAllText(configPath) - var config assetProjectConfig - if err := json.Unmarshal([]byte(configJSON), &config); err != nil { - spxlog.Warn("SetAssetDir: failed to parse %s: %v", configPath, err) - return "" - } - return config.ExtAsset -} diff --git a/internal/engine/path_filesystem_test.go b/internal/engine/path_filesystem_test.go new file mode 100644 index 000000000..deb05707c --- /dev/null +++ b/internal/engine/path_filesystem_test.go @@ -0,0 +1,54 @@ +//go:build !packmode + +/* + * 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 engine + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLegacyFilesystemRootsRejectSymlinkEscape(t *testing.T) { + original := assetPaths + t.Cleanup(func() { + assetPaths = original + }) + + projectDir := t.TempDir() + assetDir := filepath.Join(projectDir, "assets") + sessionDir := filepath.Join(projectDir, "project") + if err := os.Mkdir(assetDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(sessionDir, 0o755); err != nil { + t.Fatal(err) + } + externalDir := t.TempDir() + if err := os.WriteFile(filepath.Join(externalDir, "outside.png"), []byte("outside"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(externalDir, filepath.Join(assetDir, "linked")); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + t.Chdir(sessionDir) + setLegacyFilesystemAssetDir("assets") + if got := buildFilesystemAssetPath("linked/outside.png"); got != "" { + t.Fatalf("buildFilesystemAssetPath() followed legacy symlink escape: %q", got) + } +} diff --git a/internal/engine/path_packmode.go b/internal/engine/path_packmode.go index b4177a7b3..33acc8408 100644 --- a/internal/engine/path_packmode.go +++ b/internal/engine/path_packmode.go @@ -19,12 +19,60 @@ package engine +import ( + "path" + "strings" +) + func SetAssetDir(dir string) { resMgr.SetLoadMode(false) - setExtAssetDir("") + // Packmode keeps the legacy archive and export policy. setAssetRoot(packmodeAssetPrefix, dir) + assetPaths.explicitFSRoots = false + assetPaths.legacyCompatibility = true + assetPaths.extAssetDir = readExtAssetDirFromProjectConfig(packmodeAssetPrefix) } func ToAssetPath(relPath string) string { - return normalizeSlashes(assetPaths.root + relPath) + if strings.Contains(relPath, "\\") { + return "" + } + relPath = normalizeSlashes(relPath) + if relPath == "" || strings.HasPrefix(relPath, "/") { + return "" + } + if strings.HasPrefix(relPath, packmodeAssetPrefix) { + return projectResourcePath(strings.TrimPrefix(relPath, packmodeAssetPrefix)) + } + if strings.Contains(relPath, ":") { + return "" + } + if suffix, ok := extAssetSuffix(relPath); ok { + return projectResourcePath(path.Join(engineExtAssetPath, suffix)) + } + if suffix, ok := packmodeCompatibilitySuffix(relPath); ok { + return projectResourcePath(suffix) + } + root := strings.TrimPrefix(assetPaths.root, packmodeAssetPrefix) + return projectResourcePath(path.Join(root, relPath)) +} + +func packmodeCompatibilitySuffix(relPath string) (string, bool) { + clean := path.Clean(relPath) + if !strings.HasPrefix(clean, "../../") { + return "", false + } + suffix := strings.TrimPrefix(clean, "../../") + if suffix == "" || suffix == clean { + return "", false + } + return suffix, true +} + +func projectResourcePath(name string) string { + name = path.Clean(name) + if name == "." || name == ".." || strings.HasPrefix(name, "../") || path.IsAbs(name) || strings.Contains(name, ":") { + return "" + } + return packmodeAssetPrefix + name } diff --git a/internal/engine/path_packmode_test.go b/internal/engine/path_packmode_test.go new file mode 100644 index 000000000..b26a42e67 --- /dev/null +++ b/internal/engine/path_packmode_test.go @@ -0,0 +1,59 @@ +//go:build packmode + +/* + * Copyright (c) 2021 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 engine + +import "testing" + +func TestPackmodeAssetPathStaysWithinProject(t *testing.T) { + original := assetPaths + t.Cleanup(func() { + assetPaths = original + }) + assetPaths = assetPathState{ + root: joinAssetRoot(packmodeAssetPrefix, "assets"), + projectRoot: packmodeAssetPrefix, + extAssetDir: "custom_asset", + legacyCompatibility: true, + } + + for _, test := range []struct { + name string + path string + want string + }{ + {name: "asset", path: "sprites/cat.svg", want: "res://assets/sprites/cat.svg"}, + {name: "project resource", path: "../res/image.png", want: "res://res/image.png"}, + {name: "project URI", path: "res://media/image.png", want: "res://media/image.png"}, + {name: "shared legacy asset", path: "../../shared/image.png", want: "res://shared/image.png"}, + {name: "extasset legacy asset", path: "../../custom_asset/image.png", want: "res://extasset/image.png"}, + {name: "escaping project URI", path: "res://../outside/image.png", want: ""}, + {name: "Windows absolute project URI", path: "res://C:/outside/image.png", want: ""}, + {name: "triple slash project URI", path: "res:///etc/passwd", want: ""}, + {name: "malformed project URI", path: "res:media/image.png", want: ""}, + {name: "backslash project URI", path: `res:\media\image.png`, want: ""}, + {name: "UNC path", path: `\\server\share\image.png`, want: ""}, + {name: "absolute", path: "/tmp/image.png", want: ""}, + } { + t.Run(test.name, func(t *testing.T) { + if got := ToAssetPath(test.path); got != test.want { + t.Fatalf("ToAssetPath(%q) = %q, want %q", test.path, got, test.want) + } + }) + } +} diff --git a/internal/engine/path_test.go b/internal/engine/path_test.go index 2d7f24eed..0d266d453 100644 --- a/internal/engine/path_test.go +++ b/internal/engine/path_test.go @@ -16,34 +16,11 @@ package engine -import "testing" - -func TestProjectConfigPath(t *testing.T) { - tests := []struct { - name string - prefix string - want string - }{ - { - name: "desktop prefix", - prefix: defaultAssetPathPrefix, - want: "../.config", - }, - { - name: "web prefix", - prefix: "", - want: ".config", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := projectConfigPath(tt.prefix); got != tt.want { - t.Fatalf("projectConfigPath(%q) = %q, want %q", tt.prefix, got, tt.want) - } - }) - } -} +import ( + "os" + "path/filepath" + "testing" +) func TestBuildFilesystemAssetPath(t *testing.T) { original := assetPaths @@ -51,7 +28,7 @@ func TestBuildFilesystemAssetPath(t *testing.T) { assetPaths = original }) - assetPaths.root = "../assets/" + assetPaths = assetPathState{root: "../assets/", projectRoot: ".."} tests := []struct { name string @@ -69,20 +46,29 @@ func TestBuildFilesystemAssetPath(t *testing.T) { want: "../assets/image.png", }, { - name: "allow shared external resource", - path: "../../res/image.png", - want: "../../res/image.png", + name: "allow resource elsewhere in project", + path: "../res/image.png", + want: "../res/image.png", }, { - name: "reject parent traversal", - path: "../../../../etc/passwd", + name: "allow canonical project resource URI", + path: "res://res/image.png", + want: "../res/image.png", + }, + { + name: "reject historical shared asset outside project", + path: "../../shared-assets/image.png", want: "", }, { - name: "reject sibling directory with same prefix", - path: "../assets_backup/image.png", + name: "reject parent traversal", + path: "../../../../etc/passwd", want: "", }, + {name: "reject file URI", path: "file:///tmp/image.png", want: ""}, + {name: "reject malformed res URI", path: "res:res/image.png", want: ""}, + {name: "reject Windows path", path: `C:\outside\image.png`, want: ""}, + {name: "reject UNC path", path: `\\server\share\image.png`, want: ""}, } for _, tt := range tests { @@ -94,71 +80,158 @@ func TestBuildFilesystemAssetPath(t *testing.T) { } } -func TestRewriteExtAssetPath(t *testing.T) { +func TestExplicitFilesystemRoots(t *testing.T) { original := assetPaths t.Cleanup(func() { assetPaths = original }) - assetPaths.prefix = defaultAssetPathPrefix - assetPaths.extAssetDir = "custom_asset" - + projectDir := t.TempDir() + assetDir := filepath.Join(projectDir, "assets") + if err := os.Mkdir(assetDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(assetDir, "sprites"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(assetDir, "sprites", "cat.svg"), []byte("svg"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(projectDir, "res"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(projectDir, "res", "image.png"), []byte("png"), 0o644); err != nil { + t.Fatal(err) + } + if err := SetFilesystemRoots(projectDir, assetDir); err != nil { + t.Fatal(err) + } + externalFile := filepath.Join(t.TempDir(), "outside.png") + if err := os.WriteFile(externalFile, []byte("outside"), 0o644); err != nil { + t.Fatal(err) + } + symlinkPath := filepath.Join(assetDir, "escape.png") + symlinkAvailable := os.Symlink(externalFile, symlinkPath) == nil + symlinkDir := filepath.Join(assetDir, "linked") + symlinkDirAvailable := os.Symlink(filepath.Dir(externalFile), symlinkDir) == nil tests := []struct { name string path string want string }{ { - name: "rewrite root extasset path", - path: "../custom_asset/image.png", - want: "../extasset/image.png", + name: "asset", + path: "sprites/cat.svg", + want: filepath.Join(assetDir, "sprites", "cat.svg"), + }, + { + name: "resource elsewhere in project", + path: "../res/image.png", + want: filepath.Join(projectDir, "res", "image.png"), }, { - name: "rewrite nested parent traversal", + name: "project resource URI", + path: "res://res/image.png", + want: filepath.Join(projectDir, "res", "image.png"), + }, + { + name: "reject extasset outside project", path: "../../custom_asset/image.png", - want: "../extasset/image.png", + want: "", + }, + { + name: "reject legacy shared compatibility root", + path: "../../shared/image.png", + want: "", }, { - name: "skip extasset path without parent traversal", - path: "custom_asset/image.png", + name: "reject traversal", + path: "../../../etc/passwd", want: "", }, { - name: "skip normal asset path", - path: "../assets/image.png", + name: "reject absolute", + path: filepath.Join(projectDir, "secret"), want: "", }, { - name: "skip substring match", - path: "../custom_asset_backup/image.png", + name: "reject symlink outside project", + path: "escape.png", want: "", }, { - name: "skip nested extasset directory", - path: "../subdir/custom_asset/image.png", + name: "reject intermediate symlink outside project", + path: "linked/outside.png", want: "", }, } - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := rewriteExtAssetPath(tt.path); got != tt.want { - t.Fatalf("rewriteExtAssetPath(%q) = %q, want %q", tt.path, got, tt.want) + if tt.path == "escape.png" && !symlinkAvailable { + t.Skip("symlink unavailable") + } + if tt.path == "linked/outside.png" && !symlinkDirAvailable { + t.Skip("symlink unavailable") + } + if got := buildFilesystemAssetPath(tt.path); got != normalizeSlashes(filepath.Clean(tt.want)) && !(got == "" && tt.want == "") { + t.Fatalf("buildFilesystemAssetPath(%q) = %q, want %q", tt.path, got, tt.want) } }) } } -func TestRewriteExtAssetPathWithoutExtAssetDir(t *testing.T) { +func TestLegacyFilesystemRootsRetainBoundedExternalAssets(t *testing.T) { original := assetPaths t.Cleanup(func() { assetPaths = original }) - assetPaths.prefix = defaultAssetPathPrefix - assetPaths.extAssetDir = "" + root := t.TempDir() + projectDir := filepath.Join(root, "project") + assetDir := filepath.Join(projectDir, "assets") + for _, dir := range []string{assetDir, filepath.Join(root, "custom_asset"), filepath.Join(root, "shared")} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(projectDir, ".config"), []byte(`{"extasset":"custom_asset"}`), 0o600); err != nil { + t.Fatal(err) + } + for _, name := range []string{"custom_asset/image.png", "shared/image.png"} { + if err := os.WriteFile(filepath.Join(root, name), []byte(name), 0o600); err != nil { + t.Fatal(err) + } + } + if err := SetLegacyFilesystemRoots(projectDir, assetDir); err != nil { + t.Fatal(err) + } + for _, test := range []struct { + name string + path string + want string + }{ + {name: "extasset", path: "../../custom_asset/image.png", want: filepath.Join(root, "custom_asset", "image.png")}, + {name: "shared", path: "../../shared/image.png", want: filepath.Join(root, "shared", "image.png")}, + {name: "escape", path: "../../../outside/image.png", want: ""}, + } { + t.Run(test.name, func(t *testing.T) { + if got := buildFilesystemAssetPath(test.path); got != normalizeSlashes(filepath.Clean(test.want)) && !(got == "" && test.want == "") { + t.Fatalf("buildFilesystemAssetPath(%q) = %q, want %q", test.path, got, test.want) + } + }) + } +} + +func TestSetFilesystemRootsRejectsImplicitPaths(t *testing.T) { + if err := SetFilesystemRoots(".", "assets"); err == nil { + t.Fatal("SetFilesystemRoots accepted relative paths") + } +} - if got := rewriteExtAssetPath("../anything/image.png"); got != "" { - t.Fatalf("rewriteExtAssetPath with empty extAssetDir = %q, want empty string", got) +func TestSetFilesystemRootsRejectsAssetOutsideProject(t *testing.T) { + projectDir := t.TempDir() + assetDir := t.TempDir() + if err := SetFilesystemRoots(projectDir, assetDir); err == nil { + t.Fatal("SetFilesystemRoots accepted AssetDir outside ProjectDir") } } diff --git a/internal/interpruntime/prepare.go b/internal/interpruntime/prepare.go new file mode 100644 index 000000000..074299caa --- /dev/null +++ b/internal/interpruntime/prepare.go @@ -0,0 +1,421 @@ +/* + * Copyright (c) 2021 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 interpruntime + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync/atomic" + + "github.com/goplus/spx/v3/internal/scaffold" +) + +const ( + runtimeExtensionFile = "runtime.gdextension" + projectExtensionFile = "gdspx.gdextension" + extensionListFile = "extension_list.cfg" + bridgeDirectory = "lib" +) + +var scaffoldTempSequence atomic.Uint64 + +// SessionConfig describes the files needed to load the interpreter bridge in +// an Engine session. BridgePath is copied by base name into Roots.SessionDir. +type SessionConfig struct { + Roots Roots + BridgePath string +} + +// PrepareSession creates the session scaffold without changing global cwd or +// environment. +func PrepareSession(cfg SessionConfig) error { + if err := validateAbsoluteCleanPath("ProjectDir", cfg.Roots.ProjectDir); err != nil { + return err + } + if err := validateAbsoluteCleanPath("AssetDir", cfg.Roots.AssetDir); err != nil { + return err + } + if err := validateAbsoluteCleanPath("SessionDir", cfg.Roots.SessionDir); err != nil { + return err + } + for _, item := range []struct { + name string + path string + rejectSymlink bool + }{ + {name: "ProjectDir", path: cfg.Roots.ProjectDir}, + {name: "AssetDir", path: cfg.Roots.AssetDir, rejectSymlink: true}, + } { + if err := validateDirectory(item.name, item.path, item.rejectSymlink); err != nil { + return err + } + } + bridge, bridgeInfo, err := openPinnedRegularFile("BridgePath", cfg.BridgePath) + if err != nil { + return err + } + defer bridge.Close() + + if err := ensureDirectoryNoSymlink("SessionDir", cfg.Roots.SessionDir, 0o700); err != nil { + return err + } + if err := cfg.Roots.Validate(); err != nil { + return err + } + + session, err := openPinnedRoot("SessionDir", cfg.Roots.SessionDir) + if err != nil { + return err + } + defer session.Close() + + bridgeName := filepath.Base(cfg.BridgePath) + if bridgeName == "." || bridgeName == string(filepath.Separator) || bridgeName == runtimeExtensionFile || bridgeName == ".godot" { + return fmt.Errorf("interpruntime: unsafe bridge base name %q", bridgeName) + } + if relative, relErr := filepath.Rel(cfg.Roots.SessionDir, cfg.BridgePath); relErr == nil && + (relative == "." || (relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)))) { + return fmt.Errorf("interpruntime: BridgePath must be outside SessionDir") + } + bridgeRoot, err := ensurePinnedSubdirectory(session, bridgeDirectory, 0o700) + if err != nil { + return err + } + defer bridgeRoot.Close() + if err := replaceRootFile(bridgeRoot, bridgeName, bridge, 0o700); err != nil { + return fmt.Errorf("interpruntime: copy bridge %q: %w", cfg.BridgePath, err) + } + if copied, err := bridge.Seek(0, io.SeekCurrent); err != nil { + return fmt.Errorf("interpruntime: inspect copied BridgePath %q: %w", cfg.BridgePath, err) + } else if copied != bridgeInfo.Size() { + return fmt.Errorf("interpruntime: BridgePath %q changed size while it was copied", cfg.BridgePath) + } + if after, err := bridge.Stat(); err != nil { + return fmt.Errorf("interpruntime: re-stat BridgePath %q: %w", cfg.BridgePath, err) + } else if !os.SameFile(bridgeInfo, after) || bridgeInfo.Mode() != after.Mode() || bridgeInfo.Size() != after.Size() || !bridgeInfo.ModTime().Equal(after.ModTime()) { + return fmt.Errorf("interpruntime: BridgePath %q changed while it was copied", cfg.BridgePath) + } + if err := replaceRootFile(session, runtimeExtensionFile, bytes.NewReader([]byte(scaffold.SessionRuntimeGDExtension())), 0o600); err != nil { + return fmt.Errorf("interpruntime: write %s: %w", runtimeExtensionFile, err) + } + if err := replaceRootFile(session, projectExtensionFile, bytes.NewReader([]byte(scaffold.ProjectGDExtension())), 0o600); err != nil { + return fmt.Errorf("interpruntime: write %s: %w", projectExtensionFile, err) + } + if err := verifyPinnedSubdirectory(session, bridgeDirectory, bridgeRoot); err != nil { + return err + } + projectData, err := ensurePinnedSubdirectory(session, ".godot", 0o700) + if err != nil { + return err + } + defer projectData.Close() + if err := replaceRootFile(projectData, extensionListFile, bytes.NewReader([]byte(scaffold.SessionExtensionList())), 0o600); err != nil { + return fmt.Errorf("interpruntime: write %s: %w", extensionListFile, err) + } + if err := verifyPinnedSubdirectory(session, ".godot", projectData); err != nil { + return err + } + if err := verifyPinnedRootPath("SessionDir", cfg.Roots.SessionDir, session); err != nil { + return err + } + return nil +} + +func ensureDirectoryNoSymlink(name, path string, mode os.FileMode) error { + info, err := os.Lstat(path) + if os.IsNotExist(err) { + if err := os.MkdirAll(path, mode); err != nil { + return fmt.Errorf("interpruntime: create %s %q: %w", name, path, err) + } + info, err = os.Lstat(path) + } + if err != nil { + return fmt.Errorf("interpruntime: stat %s %q: %w", name, path, err) + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("interpruntime: %s %q must not be a symlink", name, path) + } + if !info.IsDir() { + return fmt.Errorf("interpruntime: %s %q is not a directory", name, path) + } + return nil +} + +func openPinnedRoot(name, path string) (*os.Root, error) { + before, err := os.Lstat(path) + if err != nil { + return nil, fmt.Errorf("interpruntime: lstat %s %q: %w", name, path, err) + } + if before.Mode()&os.ModeSymlink != 0 || !before.IsDir() { + return nil, fmt.Errorf("interpruntime: %s %q is not a real directory", name, path) + } + root, err := os.OpenRoot(path) + if err != nil { + return nil, fmt.Errorf("interpruntime: open %s root %q: %w", name, path, err) + } + after, err := root.Stat(".") + if err != nil { + root.Close() + return nil, fmt.Errorf("interpruntime: stat opened %s root %q: %w", name, path, err) + } + if !after.IsDir() || !os.SameFile(before, after) { + root.Close() + return nil, fmt.Errorf("interpruntime: %s %q changed while it was opened", name, path) + } + if err := verifyPinnedRootPath(name, path, root); err != nil { + root.Close() + return nil, err + } + return root, nil +} + +func verifyPinnedRootPath(name, path string, root *os.Root) error { + opened, err := root.Stat(".") + if err != nil { + return fmt.Errorf("interpruntime: stat pinned %s %q: %w", name, path, err) + } + current, err := os.Lstat(path) + if err != nil || current.Mode()&os.ModeSymlink != 0 || !os.SameFile(opened, current) { + return fmt.Errorf("interpruntime: %s %q changed after it was opened", name, path) + } + return nil +} + +func ensurePinnedSubdirectory(parent *os.Root, name string, mode os.FileMode) (*os.Root, error) { + before, err := parent.Lstat(name) + if os.IsNotExist(err) { + if err := parent.Mkdir(name, mode); err != nil && !os.IsExist(err) { + return nil, fmt.Errorf("interpruntime: create session directory %q: %w", name, err) + } + before, err = parent.Lstat(name) + } + if err != nil { + return nil, fmt.Errorf("interpruntime: inspect session directory %q: %w", name, err) + } + if before.Mode()&os.ModeSymlink != 0 || !before.IsDir() { + return nil, fmt.Errorf("interpruntime: session directory %q is not a real directory", name) + } + root, err := parent.OpenRoot(name) + if err != nil { + return nil, fmt.Errorf("interpruntime: open session directory %q: %w", name, err) + } + after, err := root.Stat(".") + if err != nil { + root.Close() + return nil, err + } + if !after.IsDir() || !os.SameFile(before, after) { + root.Close() + return nil, fmt.Errorf("interpruntime: session directory %q changed while it was opened", name) + } + if err := verifyPinnedSubdirectory(parent, name, root); err != nil { + root.Close() + return nil, err + } + return root, nil +} + +func verifyPinnedSubdirectory(parent *os.Root, name string, root *os.Root) error { + opened, err := root.Stat(".") + if err != nil { + return fmt.Errorf("interpruntime: stat pinned session directory %q: %w", name, err) + } + current, err := parent.Lstat(name) + if err != nil || current.Mode()&os.ModeSymlink != 0 || !os.SameFile(opened, current) { + return fmt.Errorf("interpruntime: session directory %q changed after it was opened", name) + } + return nil +} + +func replaceRootFile(root *os.Root, name string, input io.Reader, mode os.FileMode) (err error) { + if name == "" || name == "." || filepath.Base(name) != name { + return fmt.Errorf("interpruntime: invalid scaffold file name %q", name) + } + if info, statErr := root.Lstat(name); statErr == nil { + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return fmt.Errorf("interpruntime: scaffold target %q is not a regular non-symlink file", name) + } + } else if !os.IsNotExist(statErr) { + return fmt.Errorf("interpruntime: inspect scaffold target %q: %w", name, statErr) + } + + var tempName string + var output *os.File + for attempt := 0; attempt < 100; attempt++ { + tempName = fmt.Sprintf(".%s.tmp-%d-%d", name, os.Getpid(), scaffoldTempSequence.Add(1)) + output, err = root.OpenFile(tempName, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) + if err == nil { + break + } + if !os.IsExist(err) { + return fmt.Errorf("interpruntime: create temporary scaffold %q: %w", name, err) + } + } + if output == nil { + return fmt.Errorf("interpruntime: could not allocate temporary scaffold for %q", name) + } + defer func() { + _ = root.Remove(tempName) + }() + if _, err := io.Copy(output, input); err != nil { + _ = output.Close() + return fmt.Errorf("interpruntime: write temporary scaffold %q: %w", name, err) + } + if err := output.Chmod(mode); err != nil { + _ = output.Close() + return fmt.Errorf("interpruntime: chmod temporary scaffold %q: %w", name, err) + } + if err := output.Sync(); err != nil { + _ = output.Close() + return fmt.Errorf("interpruntime: sync temporary scaffold %q: %w", name, err) + } + if err := output.Close(); err != nil { + return fmt.Errorf("interpruntime: close temporary scaffold %q: %w", name, err) + } + if runtime.GOOS == "windows" { + // Windows rename does not consistently replace an existing regular file. + // Root.Remove removes the directory entry itself and never follows it. + if err := root.Remove(name); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("interpruntime: replace existing scaffold %q: %w", name, err) + } + } + if err := root.Rename(tempName, name); err != nil { + return fmt.Errorf("interpruntime: publish scaffold %q: %w", name, err) + } + return nil +} + +// CommandConfig describes a prepared Engine child. Env is the complete base +// environment, rather than an overlay; root variables are replaced before the +// command is returned. +type CommandConfig struct { + Roots Roots + Executable string + Args []string + Env []string + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer + PathPolicy PathPolicy +} + +// PathPolicy controls how pre-existing Engine --path options are handled. +// RejectPath is the safe default. ReplacePath supports the legacy spx adapter. +type PathPolicy uint8 + +const ( + RejectPath PathPolicy = iota + ReplacePath +) + +// PrepareCommand returns an Engine command rooted in SessionDir without global +// side effects. Executable validation is fail-fast; os/exec reopens it in Start. +func PrepareCommand(ctx context.Context, cfg CommandConfig) (*exec.Cmd, error) { + if ctx == nil { + return nil, fmt.Errorf("interpruntime: nil context") + } + if err := cfg.Roots.Validate(); err != nil { + return nil, err + } + if err := validateRegularFile("Executable", cfg.Executable); err != nil { + return nil, err + } + env, err := cfg.Roots.Environment(cfg.Env) + if err != nil { + return nil, err + } + + args, err := engineArgs(cfg.Args, cfg.Roots.SessionDir, cfg.PathPolicy) + if err != nil { + return nil, err + } + cmd := exec.CommandContext(ctx, cfg.Executable, args...) + cmd.Dir = cfg.Roots.SessionDir + cmd.Env = env + cmd.Stdin = cfg.Stdin + cmd.Stdout = cfg.Stdout + cmd.Stderr = cfg.Stderr + return cmd, nil +} + +func validateRegularFile(name, path string) error { + file, _, err := openPinnedRegularFile(name, path) + if err != nil { + return err + } + return file.Close() +} + +func openPinnedRegularFile(name, path string) (*os.File, os.FileInfo, error) { + if err := validateAbsoluteCleanPath(name, path); err != nil { + return nil, nil, err + } + before, err := os.Lstat(path) + if err != nil { + return nil, nil, fmt.Errorf("interpruntime: lstat %s %q: %w", name, path, err) + } + if before.Mode()&os.ModeSymlink != 0 || !before.Mode().IsRegular() { + return nil, nil, fmt.Errorf("interpruntime: %s %q is not a regular non-symlink file", name, path) + } + file, err := os.Open(path) + if err != nil { + return nil, nil, fmt.Errorf("interpruntime: open %s %q: %w", name, path, err) + } + opened, err := file.Stat() + if err != nil { + file.Close() + return nil, nil, fmt.Errorf("interpruntime: stat opened %s %q: %w", name, path, err) + } + after, err := os.Lstat(path) + if err != nil || !opened.Mode().IsRegular() || !os.SameFile(before, opened) || !os.SameFile(opened, after) { + file.Close() + return nil, nil, fmt.Errorf("interpruntime: %s %q changed while it was opened", name, path) + } + return file, opened, nil +} + +// engineArgs makes SessionDir authoritative. RejectPath fails closed; the +// explicit ReplacePath compatibility policy removes both accepted spellings. +func engineArgs(input []string, sessionDir string, policy PathPolicy) ([]string, error) { + if policy != RejectPath && policy != ReplacePath { + return nil, fmt.Errorf("interpruntime: unknown Engine path policy %d", policy) + } + args := make([]string, 0, len(input)+3) + for i := 0; i < len(input); i++ { + if input[i] == "--path" || strings.HasPrefix(input[i], "--path=") { + if policy == RejectPath { + return nil, fmt.Errorf("interpruntime: Engine --path is reserved for SessionDir") + } + if input[i] == "--path" { + if i+1 < len(input) { + i++ + } + } + continue + } + args = append(args, input[i]) + } + args = append(args, "--path", sessionDir, "--no-header") + return args, nil +} diff --git a/internal/interpruntime/prepare_test.go b/internal/interpruntime/prepare_test.go new file mode 100644 index 000000000..6d7cac017 --- /dev/null +++ b/internal/interpruntime/prepare_test.go @@ -0,0 +1,295 @@ +/* + * Copyright (c) 2021 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 interpruntime + +import ( + "context" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/goplus/spx/v3/internal/scaffold" +) + +func mkdirAll(path string) error { + return os.MkdirAll(path, 0o755) +} + +func TestPrepareSession(t *testing.T) { + projectDir := t.TempDir() + assetDir := filepath.Join(projectDir, "assets") + if err := mkdirAll(assetDir); err != nil { + t.Fatal(err) + } + sessionDir := filepath.Join(t.TempDir(), "session") + bridgePath := filepath.Join(t.TempDir(), "gdspx-test.bridge") + if err := os.WriteFile(bridgePath, []byte("bridge"), 0o755); err != nil { + t.Fatal(err) + } + roots := Roots{ProjectDir: projectDir, AssetDir: assetDir, SessionDir: sessionDir} + + before, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := PrepareSession(SessionConfig{Roots: roots, BridgePath: bridgePath}); err != nil { + t.Fatal(err) + } + after, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if after != before { + t.Fatalf("working directory changed from %q to %q", before, after) + } + + files := map[string]string{ + filepath.Join(sessionDir, bridgeDirectory, filepath.Base(bridgePath)): "bridge", + filepath.Join(sessionDir, runtimeExtensionFile): scaffold.SessionRuntimeGDExtension(), + filepath.Join(sessionDir, projectExtensionFile): scaffold.ProjectGDExtension(), + filepath.Join(sessionDir, ".godot", extensionListFile): scaffold.SessionExtensionList(), + } + for path, want := range files { + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + if string(got) != want { + t.Fatalf("%s = %q, want %q", path, got, want) + } + } +} + +func TestPrepareCommandUsesExplicitRootsWithoutGlobalMutation(t *testing.T) { + roots := testRoots(t) + executable := filepath.Join(t.TempDir(), "engine") + if err := os.WriteFile(executable, []byte("engine"), 0o755); err != nil { + t.Fatal(err) + } + + before, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + cmd, err := PrepareCommand(context.Background(), CommandConfig{ + Roots: roots, + Executable: executable, + Args: []string{"--headless", "--path=/ignored", "--path", "/also-ignored"}, + Env: []string{"KEEP=value", ProjectDirEnv + "=/ignored"}, + PathPolicy: ReplacePath, + }) + if err != nil { + t.Fatal(err) + } + after, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if after != before { + t.Fatalf("working directory changed from %q to %q", before, after) + } + if cmd.Dir != roots.SessionDir { + t.Fatalf("command Dir = %q, want %q", cmd.Dir, roots.SessionDir) + } + wantArgs := []string{executable, "--headless", "--path", roots.SessionDir, "--no-header"} + if !reflect.DeepEqual(cmd.Args, wantArgs) { + t.Fatalf("command Args = %#v, want %#v", cmd.Args, wantArgs) + } + wantEnv := []string{ + "KEEP=value", + ProjectDirEnv + "=" + roots.ProjectDir, + AssetDirEnv + "=" + roots.AssetDir, + SessionDirEnv + "=" + roots.SessionDir, + } + if !reflect.DeepEqual(cmd.Env, wantEnv) { + t.Fatalf("command Env = %#v, want %#v", cmd.Env, wantEnv) + } +} + +func TestPrepareCommandRejectsEnginePathByDefault(t *testing.T) { + roots := testRoots(t) + executable := filepath.Join(t.TempDir(), "engine") + if err := os.WriteFile(executable, []byte("engine"), 0o755); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{{"--path", "/ignored"}, {"--path=/ignored"}} { + if _, err := PrepareCommand(context.Background(), CommandConfig{ + Roots: roots, + Executable: executable, + Args: args, + }); err == nil { + t.Fatalf("PrepareCommand(%q) accepted reserved --path", args) + } + } +} + +func TestPrepareSessionRejectsSymlinkScaffoldTarget(t *testing.T) { + projectDir := t.TempDir() + assetDir := filepath.Join(projectDir, "assets") + if err := mkdirAll(assetDir); err != nil { + t.Fatal(err) + } + sessionDir := t.TempDir() + bridgePath := filepath.Join(t.TempDir(), "gdspx-test.bridge") + if err := os.WriteFile(bridgePath, []byte("bridge"), 0o755); err != nil { + t.Fatal(err) + } + sentinel := filepath.Join(t.TempDir(), "sentinel") + if err := os.WriteFile(sentinel, []byte("unchanged"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(sentinel, filepath.Join(sessionDir, runtimeExtensionFile)); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + err := PrepareSession(SessionConfig{ + Roots: Roots{ + ProjectDir: projectDir, + AssetDir: assetDir, + SessionDir: sessionDir, + }, + BridgePath: bridgePath, + }) + if err == nil { + t.Fatal("PrepareSession accepted symlink scaffold target") + } + got, err := os.ReadFile(sentinel) + if err != nil { + t.Fatal(err) + } + if string(got) != "unchanged" { + t.Fatalf("symlink target was overwritten: %q", got) + } +} + +func TestPrepareSessionReplacesExistingRegularScaffold(t *testing.T) { + projectDir := t.TempDir() + assetDir := filepath.Join(projectDir, "assets") + if err := mkdirAll(assetDir); err != nil { + t.Fatal(err) + } + sessionDir := t.TempDir() + bridgePath := filepath.Join(t.TempDir(), "gdspx-test.bridge") + if err := os.WriteFile(bridgePath, []byte("bridge-v2"), 0o700); err != nil { + t.Fatal(err) + } + for _, name := range []string{runtimeExtensionFile, projectExtensionFile} { + if err := os.WriteFile(filepath.Join(sessionDir, name), []byte("stale"), 0o600); err != nil { + t.Fatal(err) + } + } + if err := os.Mkdir(filepath.Join(sessionDir, bridgeDirectory), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sessionDir, bridgeDirectory, filepath.Base(bridgePath)), []byte("stale"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(sessionDir, ".godot"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sessionDir, ".godot", extensionListFile), []byte("stale"), 0o600); err != nil { + t.Fatal(err) + } + + err := PrepareSession(SessionConfig{ + Roots: Roots{ProjectDir: projectDir, AssetDir: assetDir, SessionDir: sessionDir}, + BridgePath: bridgePath, + }) + if err != nil { + t.Fatal(err) + } + if got, err := os.ReadFile(filepath.Join(sessionDir, bridgeDirectory, filepath.Base(bridgePath))); err != nil || string(got) != "bridge-v2" { + t.Fatalf("replaced bridge = %q, err=%v", got, err) + } +} + +func TestPrepareSessionRejectsSymlinkProjectDataDirectory(t *testing.T) { + projectDir := t.TempDir() + assetDir := filepath.Join(projectDir, "assets") + if err := mkdirAll(assetDir); err != nil { + t.Fatal(err) + } + sessionDir := t.TempDir() + outside := t.TempDir() + if err := os.Symlink(outside, filepath.Join(sessionDir, ".godot")); err != nil { + t.Skipf("symlink not supported: %v", err) + } + bridgePath := filepath.Join(t.TempDir(), "gdspx-test.bridge") + if err := os.WriteFile(bridgePath, []byte("bridge"), 0o700); err != nil { + t.Fatal(err) + } + + err := PrepareSession(SessionConfig{ + Roots: Roots{ProjectDir: projectDir, AssetDir: assetDir, SessionDir: sessionDir}, + BridgePath: bridgePath, + }) + if err == nil { + t.Fatal("PrepareSession accepted a symlinked .godot directory") + } + if entries, readErr := os.ReadDir(outside); readErr != nil { + t.Fatal(readErr) + } else if len(entries) != 0 { + t.Fatalf("symlink target was modified: %#v", entries) + } +} + +func TestPrepareSessionRejectsSymlinkBridgeSource(t *testing.T) { + projectDir := t.TempDir() + assetDir := filepath.Join(projectDir, "assets") + if err := mkdirAll(assetDir); err != nil { + t.Fatal(err) + } + realBridge := filepath.Join(t.TempDir(), "bridge-real") + if err := os.WriteFile(realBridge, []byte("bridge"), 0o700); err != nil { + t.Fatal(err) + } + bridgeLink := filepath.Join(t.TempDir(), "bridge-link") + if err := os.Symlink(realBridge, bridgeLink); err != nil { + t.Skipf("symlink not supported: %v", err) + } + err := PrepareSession(SessionConfig{ + Roots: Roots{ProjectDir: projectDir, AssetDir: assetDir, SessionDir: filepath.Join(t.TempDir(), "session")}, + BridgePath: bridgeLink, + }) + if err == nil { + t.Fatal("PrepareSession accepted a symlink bridge source") + } +} + +func TestPrepareSessionRejectsBridgeSourceAsDestination(t *testing.T) { + projectDir := t.TempDir() + assetDir := filepath.Join(projectDir, "assets") + if err := mkdirAll(assetDir); err != nil { + t.Fatal(err) + } + sessionDir := t.TempDir() + bridgePath := filepath.Join(sessionDir, "bridge") + if err := os.WriteFile(bridgePath, []byte("unchanged"), 0o700); err != nil { + t.Fatal(err) + } + err := PrepareSession(SessionConfig{ + Roots: Roots{ProjectDir: projectDir, AssetDir: assetDir, SessionDir: sessionDir}, + BridgePath: bridgePath, + }) + if err == nil { + t.Fatal("PrepareSession accepted BridgePath as its own destination") + } + if got, readErr := os.ReadFile(bridgePath); readErr != nil || string(got) != "unchanged" { + t.Fatalf("bridge source changed: %q, err=%v", got, readErr) + } +} diff --git a/internal/interpruntime/roots.go b/internal/interpruntime/roots.go new file mode 100644 index 000000000..2268c0a8f --- /dev/null +++ b/internal/interpruntime/roots.go @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2021 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 interpruntime prepares isolated native interpreter sessions without +// changing the process working directory or environment. +package interpruntime + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" +) + +const ( + ProjectDirEnv = "SPX_PROJECT_DIR" + AssetDirEnv = "SPX_ASSET_DIR" + SessionDirEnv = "SPX_SESSION_DIR" +) + +// Roots identifies the independent source, asset, and Engine session roots. +// Every path must be absolute, clean, and name an existing directory. +type Roots struct { + ProjectDir string + AssetDir string + SessionDir string +} + +// Validate checks the interpreted runtime path contract. +func (r Roots) Validate() error { + paths := []struct { + name string + path string + rejectSymlink bool + }{ + {name: "ProjectDir", path: r.ProjectDir}, + {name: "AssetDir", path: r.AssetDir, rejectSymlink: true}, + {name: "SessionDir", path: r.SessionDir, rejectSymlink: true}, + } + for _, item := range paths { + if err := validateAbsoluteCleanPath(item.name, item.path); err != nil { + return err + } + if err := validateDirectory(item.name, item.path, item.rejectSymlink); err != nil { + return err + } + } + if err := validateDirectoryWithin("AssetDir", r.ProjectDir, r.AssetDir); err != nil { + return err + } + return nil +} + +func validateDirectoryWithin(name, root, directory string) error { + canonicalRoot, err := filepath.EvalSymlinks(root) + if err != nil { + return fmt.Errorf("interpruntime: canonicalize ProjectDir %q: %w", root, err) + } + canonicalDirectory, err := filepath.EvalSymlinks(directory) + if err != nil { + return fmt.Errorf("interpruntime: canonicalize %s %q: %w", name, directory, err) + } + rel, err := filepath.Rel(canonicalRoot, canonicalDirectory) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return fmt.Errorf("interpruntime: %s %q must be within ProjectDir %q", name, directory, root) + } + return nil +} + +func validateDirectory(name, path string, rejectSymlink bool) error { + var ( + info os.FileInfo + err error + ) + if rejectSymlink { + info, err = os.Lstat(path) + } else { + info, err = os.Stat(path) + } + if err != nil { + return fmt.Errorf("interpruntime: stat %s %q: %w", name, path, err) + } + if rejectSymlink && info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("interpruntime: %s %q must not be a symlink", name, path) + } + if !info.IsDir() { + return fmt.Errorf("interpruntime: %s %q is not a directory", name, path) + } + return nil +} + +func validateAbsoluteCleanPath(name, path string) error { + if path == "" { + return fmt.Errorf("interpruntime: %s is empty", name) + } + if !filepath.IsAbs(path) { + return fmt.Errorf("interpruntime: %s %q is not absolute", name, path) + } + if clean := filepath.Clean(path); clean != path { + return fmt.Errorf("interpruntime: %s %q is not clean (want %q)", name, path, clean) + } + return nil +} + +// RootsFromEnv parses and validates roots from a complete process environment. +// Duplicate root variables are rejected instead of relying on platform-specific +// duplicate-key precedence. +func RootsFromEnv(env []string) (Roots, error) { + values := make(map[string]string, 3) + for _, entry := range env { + key, value, ok := strings.Cut(entry, "=") + if !ok { + continue + } + canonical, ok := rootEnvKey(key) + if !ok { + continue + } + if _, duplicate := values[canonical]; duplicate { + return Roots{}, fmt.Errorf("interpruntime: duplicate environment variable %s", canonical) + } + values[canonical] = value + } + + for _, key := range []string{ProjectDirEnv, AssetDirEnv, SessionDirEnv} { + if _, ok := values[key]; !ok { + return Roots{}, fmt.Errorf("interpruntime: required environment variable %s is not set", key) + } + } + r := Roots{ + ProjectDir: values[ProjectDirEnv], + AssetDir: values[AssetDirEnv], + SessionDir: values[SessionDirEnv], + } + if err := r.Validate(); err != nil { + return Roots{}, err + } + return r, nil +} + +// Environment returns base with all ambient root variables removed and one +// validated value for each root appended. +func (r Roots) Environment(base []string) ([]string, error) { + if err := r.Validate(); err != nil { + return nil, err + } + env := make([]string, 0, len(base)+3) + for _, entry := range base { + key, _, ok := strings.Cut(entry, "=") + if ok { + if _, root := rootEnvKey(key); root { + continue + } + } + env = append(env, entry) + } + return append(env, + ProjectDirEnv+"="+r.ProjectDir, + AssetDirEnv+"="+r.AssetDir, + SessionDirEnv+"="+r.SessionDir, + ), nil +} + +func rootEnvKey(key string) (string, bool) { + for _, canonical := range []string{ProjectDirEnv, AssetDirEnv, SessionDirEnv} { + if key == canonical || (runtime.GOOS == "windows" && strings.EqualFold(key, canonical)) { + return canonical, true + } + } + return "", false +} diff --git a/internal/interpruntime/roots_test.go b/internal/interpruntime/roots_test.go new file mode 100644 index 000000000..4513b838d --- /dev/null +++ b/internal/interpruntime/roots_test.go @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2021 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 interpruntime + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func testRoots(t *testing.T) Roots { + t.Helper() + projectDir := t.TempDir() + assetDir := filepath.Join(projectDir, "assets") + sessionDir := filepath.Join(t.TempDir(), "session") + for _, dir := range []string{assetDir, sessionDir} { + if err := mkdirAll(dir); err != nil { + t.Fatal(err) + } + } + return Roots{ProjectDir: projectDir, AssetDir: assetDir, SessionDir: sessionDir} +} + +func TestRootsFromEnv(t *testing.T) { + roots := testRoots(t) + env := []string{ + "PATH=/bin", + ProjectDirEnv + "=" + roots.ProjectDir, + AssetDirEnv + "=" + roots.AssetDir, + SessionDirEnv + "=" + roots.SessionDir, + } + got, err := RootsFromEnv(env) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, roots) { + t.Fatalf("RootsFromEnv() = %#v, want %#v", got, roots) + } +} + +func TestRootsFromEnvRejectsInvalidContract(t *testing.T) { + roots := testRoots(t) + valid := []string{ + ProjectDirEnv + "=" + roots.ProjectDir, + AssetDirEnv + "=" + roots.AssetDir, + SessionDirEnv + "=" + roots.SessionDir, + } + tests := []struct { + name string + env []string + want string + }{ + {name: "missing", env: valid[:2], want: SessionDirEnv}, + {name: "duplicate", env: append(append([]string{}, valid...), ProjectDirEnv+"="+roots.ProjectDir), want: "duplicate"}, + {name: "relative", env: []string{ProjectDirEnv + "=.", valid[1], valid[2]}, want: "not absolute"}, + {name: "unclean", env: []string{ProjectDirEnv + "=" + roots.ProjectDir + string(filepath.Separator) + ".", valid[1], valid[2]}, want: "not clean"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := RootsFromEnv(tt.env) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("RootsFromEnv() error = %v, want containing %q", err, tt.want) + } + }) + } +} + +func TestEnvironmentReplacesAmbientRoots(t *testing.T) { + roots := testRoots(t) + base := []string{ + "PATH=/bin", + ProjectDirEnv + "=/attacker/project", + AssetDirEnv + "=/attacker/assets", + SessionDirEnv + "=/attacker/session", + "UNCHANGED=value", + } + got, err := roots.Environment(base) + if err != nil { + t.Fatal(err) + } + want := []string{ + "PATH=/bin", + "UNCHANGED=value", + ProjectDirEnv + "=" + roots.ProjectDir, + AssetDirEnv + "=" + roots.AssetDir, + SessionDirEnv + "=" + roots.SessionDir, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("Environment() = %#v, want %#v", got, want) + } +} + +func TestRootsRejectSymlinkAssetAndSessionRoots(t *testing.T) { + projectDir := t.TempDir() + realAssetDir := t.TempDir() + realSessionDir := t.TempDir() + + for _, tt := range []struct { + name string + assetDir string + sessionDir string + linkTarget string + linkPath string + }{ + { + name: "asset", + assetDir: filepath.Join(projectDir, "assets-link"), + sessionDir: realSessionDir, + linkTarget: realAssetDir, + linkPath: filepath.Join(projectDir, "assets-link"), + }, + { + name: "session", + assetDir: realAssetDir, + sessionDir: filepath.Join(projectDir, "session-link"), + linkTarget: realSessionDir, + linkPath: filepath.Join(projectDir, "session-link"), + }, + } { + t.Run(tt.name, func(t *testing.T) { + if err := os.Symlink(tt.linkTarget, tt.linkPath); err != nil { + t.Skipf("symlink not supported: %v", err) + } + t.Cleanup(func() { _ = os.Remove(tt.linkPath) }) + err := (Roots{ProjectDir: projectDir, AssetDir: tt.assetDir, SessionDir: tt.sessionDir}).Validate() + if err == nil || !strings.Contains(err.Error(), "symlink") { + t.Fatalf("Roots.Validate() error = %v, want symlink rejection", err) + } + }) + } +} + +func TestRootsRejectMissingAssetDirectory(t *testing.T) { + projectDir := t.TempDir() + missingAssetDir := filepath.Join(projectDir, "assets") + sessionDir := t.TempDir() + roots := Roots{ProjectDir: projectDir, AssetDir: missingAssetDir, SessionDir: sessionDir} + if err := roots.Validate(); err == nil || !errors.Is(err, os.ErrNotExist) { + t.Fatalf("Roots.Validate() error = %v, want missing asset directory", err) + } +} + +func TestRootsRejectAssetDirectoryOutsideProject(t *testing.T) { + projectDir := t.TempDir() + assetDir := t.TempDir() + sessionDir := t.TempDir() + roots := Roots{ProjectDir: projectDir, AssetDir: assetDir, SessionDir: sessionDir} + if err := roots.Validate(); err == nil || !strings.Contains(err.Error(), "within ProjectDir") { + t.Fatalf("Roots.Validate() error = %v, want ProjectDir containment rejection", err) + } +} + +func TestRootsRejectAssetDirectoryThroughSymlinkOutsideProject(t *testing.T) { + projectDir := t.TempDir() + externalParent := t.TempDir() + assetDir := filepath.Join(externalParent, "assets") + if err := os.Mkdir(assetDir, 0o755); err != nil { + t.Fatal(err) + } + link := filepath.Join(projectDir, "linked") + if err := os.Symlink(externalParent, link); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + roots := Roots{ + ProjectDir: projectDir, + AssetDir: filepath.Join(link, "assets"), + SessionDir: t.TempDir(), + } + if err := roots.Validate(); err == nil || !strings.Contains(err.Error(), "within ProjectDir") { + t.Fatalf("Roots.Validate() error = %v, want canonical ProjectDir containment rejection", err) + } +} diff --git a/internal/release/current_spx_version.go b/internal/release/current_spx_version.go index 87f30e308..deb92c062 100644 --- a/internal/release/current_spx_version.go +++ b/internal/release/current_spx_version.go @@ -18,4 +18,4 @@ package release // currentSPXVersion is the single source of truth for the SPX release declared // by this source tree. Runtime identity remains independent in runtime.lock.json. -const currentSPXVersion = "v3.2.3" +const currentSPXVersion = "v3.2.4" diff --git a/internal/release/release_meta.go b/internal/release/release_meta.go index 2db8b2f77..105971e7b 100644 --- a/internal/release/release_meta.go +++ b/internal/release/release_meta.go @@ -89,6 +89,7 @@ var historicalSPXRuntimeMappings = []spxRuntimeMapping{ {spxVersion: "v3.2.0", runtimeVersion: "2.4.0"}, {spxVersion: "v3.2.1", runtimeVersion: "2.4.1"}, {spxVersion: "v3.2.2", runtimeVersion: "2.4.2"}, + {spxVersion: "v3.2.3", runtimeVersion: "2.4.3"}, } func allRuntimeReleaseDefinitions() []RuntimeRelease { diff --git a/internal/release/runtime.lock.json b/internal/release/runtime.lock.json index ac175f90b..351736695 100644 --- a/internal/release/runtime.lock.json +++ b/internal/release/runtime.lock.json @@ -1,6 +1,6 @@ { "schema": 1, - "runtime_version": "2.4.3", + "runtime_version": "2.4.4", "runtime_abi": 2, "release_repository": "goplus/spx", "manifest": "runtime-manifest.json", diff --git a/internal/release/runtime_locks/2.4.4.json b/internal/release/runtime_locks/2.4.4.json new file mode 100644 index 000000000..351736695 --- /dev/null +++ b/internal/release/runtime_locks/2.4.4.json @@ -0,0 +1,44 @@ +{ + "schema": 1, + "runtime_version": "2.4.4", + "runtime_abi": 2, + "release_repository": "goplus/spx", + "manifest": "runtime-manifest.json", + "required_assets": [ + "android.zip", + "editor-linux-x86_64.zip", + "editor-macos-arm64.zip", + "editor-macos-x86_64.zip", + "editor-web.zip", + "editor-windows-x86_64.zip", + "ios.zip", + "linux-x86_64.zip", + "macos-arm64.zip", + "macos-x86_64.zip", + "macos.zip", + "spx-runtime-assets.zip", + "web-minigame.zip", + "web-miniprogram.zip", + "web-threads.zip", + "web-worker.zip", + "web.zip", + "windows-x86_64.zip" + ], + "godot": { + "repository": "https://github.com/goplus/godot.git", + "ref": "spx4.4.1", + "commit": "32e5a3f324e766a4ec6722ea6a046e48400e88be", + "version": "4.4.1.stable" + }, + "module": { + "path": "godot_modules/spx" + }, + "toolchain": { + "go": "1.25.8", + "xgo": "1.7.5", + "scons": "4.8.1", + "emsdk": "3.1.62", + "android_ndk": "23.2.8568313", + "jdk": "17" + } +} diff --git a/internal/scaffold/gdextension.go b/internal/scaffold/gdextension.go index 6f8b2ef70..f06ab874f 100644 --- a/internal/scaffold/gdextension.go +++ b/internal/scaffold/gdextension.go @@ -51,23 +51,35 @@ var projectOnlyGDExtensionLibraries = []gdExtensionLibrary{ } var ( - runtimeGDExtension = renderGDExtension("", desktopGDExtensionLibraries) - projectGDExtension = renderGDExtension("res://lib/", joinGDExtensionLibraries(desktopGDExtensionLibraries, projectOnlyGDExtensionLibraries)) + runtimeGDExtension = renderGDExtension("", desktopGDExtensionLibraries) + sessionRuntimeGDExtension = renderGDExtension("res://", desktopGDExtensionLibraries) + projectGDExtension = renderGDExtension("res://lib/", joinGDExtensionLibraries(desktopGDExtensionLibraries, projectOnlyGDExtensionLibraries)) ) -const runtimeExtensionList = "res://runtime.gdextension\n" +const ( + runtimeExtensionList = "res://runtime.gdextension\n" + sessionExtensionList = "res://gdspx.gdextension\n" +) // RuntimeGDExtension returns the default runtime.gdextension template used by desktop runtime. func RuntimeGDExtension() string { return runtimeGDExtension } +// SessionRuntimeGDExtension pins bridge libraries to the session root. +func SessionRuntimeGDExtension() string { + return sessionRuntimeGDExtension +} + // RuntimeExtensionList returns the standard Godot extension list used by the // temporary desktop runtime project. func RuntimeExtensionList() string { return runtimeExtensionList } +// SessionExtensionList selects the session-local extension descriptor. +func SessionExtensionList() string { return sessionExtensionList } + // ProjectGDExtension returns the project gdspx.gdextension template copied by project creation flows. func ProjectGDExtension() string { return projectGDExtension diff --git a/internal/scaffold/gdextension_test.go b/internal/scaffold/gdextension_test.go index ca3915267..c0320e0bd 100644 --- a/internal/scaffold/gdextension_test.go +++ b/internal/scaffold/gdextension_test.go @@ -19,6 +19,7 @@ package scaffold import ( "os" "path/filepath" + "strings" "testing" ) @@ -34,6 +35,22 @@ func TestRuntimeExtensionListUsesStandardProjectEntry(t *testing.T) { } } +func TestSessionExtensionListUsesPackedProjectEntry(t *testing.T) { + if got, want := SessionExtensionList(), "res://gdspx.gdextension\n"; got != want { + t.Fatalf("SessionExtensionList() = %q, want %q", got, want) + } +} + +func TestSessionRuntimeGDExtensionPinsLibrariesToSession(t *testing.T) { + got := SessionRuntimeGDExtension() + if !strings.Contains(got, `"res://gdspx-darwin-amd64.dylib"`) { + t.Fatalf("SessionRuntimeGDExtension() does not use res:// libraries:\n%s", got) + } + if strings.Contains(RuntimeGDExtension(), `"res://gdspx-darwin-amd64.dylib"`) { + t.Fatal("legacy RuntimeGDExtension unexpectedly changed") + } +} + func assertTemplateFile(t *testing.T, path, want string) { t.Helper() got, err := os.ReadFile(path) diff --git a/pkg/ispx/filesystem_roots_test.go b/pkg/ispx/filesystem_roots_test.go new file mode 100644 index 000000000..76fe868a6 --- /dev/null +++ b/pkg/ispx/filesystem_roots_test.go @@ -0,0 +1,41 @@ +/* + * 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 ispx + +import ( + "strings" + "testing" + + "github.com/goplus/ixgo" +) + +func TestConfigureFilesystemRootsRejectsAfterInit(t *testing.T) { + mu.Lock() + previous := ixgoCtx + ixgoCtx = &ixgo.Context{} + mu.Unlock() + t.Cleanup(func() { + mu.Lock() + ixgoCtx = previous + mu.Unlock() + }) + + err := ConfigureFilesystemRoots("/unused/project", "/unused/assets") + if err == nil || !strings.Contains(err.Error(), "before Init") { + t.Fatalf("ConfigureFilesystemRoots() error = %v, want lifecycle rejection", err) + } +} diff --git a/pkg/ispx/ispx.go b/pkg/ispx/ispx.go index 39c085489..901798a9d 100644 --- a/pkg/ispx/ispx.go +++ b/pkg/ispx/ispx.go @@ -146,7 +146,31 @@ func Build(files map[string][]byte) error { return BuildFS(memfs.New(files)) } -// BuildFS builds the spx code from the provided file system into the interpreter. +// ConfigureFilesystemRoots sets strict project and asset roots before Init. +func ConfigureFilesystemRoots(projectDir, assetDir string) error { + return configureFilesystemRoots(projectDir, assetDir, false) +} + +// ConfigureLegacyFilesystemRoots retains bounded external asset references +// used by existing interpreted and native commands. +func ConfigureLegacyFilesystemRoots(projectDir, assetDir string) error { + return configureFilesystemRoots(projectDir, assetDir, true) +} + +func configureFilesystemRoots(projectDir, assetDir string, legacy bool) error { + mu.Lock() + defer mu.Unlock() + if ixgoCtx != nil { + return fmt.Errorf("ispx: filesystem roots must be configured before Init") + } + if legacy { + return engine.SetLegacyFilesystemRoots(projectDir, assetDir) + } + return engine.SetFilesystemRoots(projectDir, assetDir) +} + +// BuildFS builds from a borrowed file system that must remain usable while the +// Engine can load project resources. func BuildFS(fsys fs.FS) error { // Stop the game if running. if err := Shutdown(); err != nil { @@ -166,10 +190,6 @@ func BuildFS(fsys fs.FS) error { ixgoInterp = nil } - spxfs.RegisterSchema("", func(path string) (spxfs.Dir, error) { - return newSpxDir(fsys, path), nil - }) - source, err := xgobuild.BuildFSDir(ixgoCtx, newXGoParserFS(fsys), ".") if err != nil { return fmt.Errorf("failed to build XGo source: %w", err) @@ -190,6 +210,12 @@ func BuildFS(fsys fs.FS) error { return fmt.Errorf("failed to create interp: %w", err) } + // Project resources are loaded lazily from Engine callbacks, so publish + // the new schema only after every fallible build step has succeeded. A + // failed rebuild must not replace a previously working resource source. + spxfs.RegisterSchema("", func(path string) (spxfs.Dir, error) { + return newSpxDir(fsys, path), nil + }) ixgoInterp = interp return nil }