Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 148 additions & 13 deletions cmd/ispxnative/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
129 changes: 129 additions & 0 deletions cmd/ispxnative/main_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
36 changes: 27 additions & 9 deletions cmd/spx/internal/command/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Expand All @@ -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 {
Expand Down
Loading
Loading