From 831eec0b6b4ed316af3249df589aa3d2829a27b7 Mon Sep 17 00:00:00 2001 From: joeykchen <466719968@qq.com> Date: Fri, 21 Aug 2026 18:22:10 +0800 Subject: [PATCH] feat(projectdriver): dispatch class projects through verified drivers --- cmd/internal/base/pass.go | 3 +- cmd/internal/build/build.go | 17 + cmd/internal/install/install.go | 17 + cmd/internal/projectdriver/cli_e2e_test.go | 233 ++++++++++ cmd/internal/projectdriver/command.go | 36 ++ cmd/internal/projectdriver/dispatch.go | 145 ++++++ cmd/internal/projectdriver/dispatch_test.go | 86 ++++ cmd/internal/projectdriver/driver.go | 261 +++++++++++ cmd/internal/projectdriver/driver_test.go | 214 +++++++++ .../projectdriver/executable_darwin.go | 75 ++++ .../projectdriver/executable_linux.go | 33 ++ .../projectdriver/executable_other.go | 23 + .../projectdriver/executable_windows.go | 33 ++ cmd/internal/projectdriver/fixture_test.go | 250 +++++++++++ cmd/internal/projectdriver/flags.go | 239 ++++++++++ cmd/internal/projectdriver/flags_test.go | 170 +++++++ cmd/internal/projectdriver/graph.go | 280 ++++++++++++ cmd/internal/projectdriver/graph_command.go | 168 +++++++ cmd/internal/projectdriver/graph_overlay.go | 105 +++++ .../projectdriver/graph_overlay_listing.go | 140 ++++++ .../projectdriver/graph_overlay_view.go | 161 +++++++ cmd/internal/projectdriver/graph_package.go | 210 +++++++++ cmd/internal/projectdriver/graph_paths.go | 71 +++ cmd/internal/projectdriver/graph_test.go | 414 ++++++++++++++++++ cmd/internal/projectdriver/output.go | 57 +++ cmd/internal/projectdriver/output_commit.go | 156 +++++++ cmd/internal/projectdriver/output_test.go | 350 +++++++++++++++ .../projectdriver/output_transaction.go | 151 +++++++ cmd/internal/projectdriver/process_unix.go | 160 +++++++ .../projectdriver/process_unix_test.go | 282 ++++++++++++ cmd/internal/projectdriver/process_windows.go | 140 ++++++ .../projectdriver/process_windows_test.go | 115 +++++ cmd/internal/projectdriver/protocol.go | 137 ++++++ cmd/internal/projectdriver/protocol_test.go | 138 ++++++ cmd/internal/projectdriver/resolve.go | 70 +++ cmd/internal/projectdriver/resolve_driver.go | 111 +++++ cmd/internal/projectdriver/resolve_graph.go | 78 ++++ .../projectdriver/resolve_metadata.go | 74 ++++ cmd/internal/projectdriver/resolve_overlay.go | 197 +++++++++ .../projectdriver/resolve_overlay_test.go | 184 ++++++++ cmd/internal/projectdriver/resolve_package.go | 104 +++++ .../projectdriver/resolve_policy_test.go | 326 ++++++++++++++ cmd/internal/projectdriver/resolve_project.go | 264 +++++++++++ cmd/internal/projectdriver/resolve_target.go | 101 +++++ cmd/internal/projectdriver/resolve_test.go | 138 ++++++ cmd/internal/projectdriver/resolve_vendor.go | 127 ++++++ .../projectdriver/resolve_vendor_classify.go | 69 +++ .../projectdriver/resolve_vendor_mode.go | 122 ++++++ .../projectdriver/resolve_vendor_mode_test.go | 103 +++++ .../projectdriver/resolve_vendor_test.go | 227 ++++++++++ .../projectdriver/resolve_vendor_workspace.go | 157 +++++++ .../resolve_vendor_workspace_test.go | 207 +++++++++ .../projectdriver/resolve_versioned.go | 289 ++++++++++++ .../projectdriver/resolve_versioned_test.go | 226 ++++++++++ .../projectdriver/signal_boundary_unix.go | 95 ++++ .../signal_boundary_unix_test.go | 47 ++ .../projectdriver/signal_boundary_windows.go | 74 ++++ cmd/internal/projectdriver/status.go | 65 +++ cmd/internal/projectdriver/status_unix.go | 39 ++ cmd/internal/projectdriver/status_windows.go | 23 + cmd/internal/projectdriver/types.go | 128 ++++++ cmd/internal/projectdriver/version.go | 91 ++++ cmd/internal/run/run.go | 29 +- doc/gox.mod.md | 26 +- go.mod | 7 +- 65 files changed, 8860 insertions(+), 8 deletions(-) create mode 100644 cmd/internal/projectdriver/cli_e2e_test.go create mode 100644 cmd/internal/projectdriver/command.go create mode 100644 cmd/internal/projectdriver/dispatch.go create mode 100644 cmd/internal/projectdriver/dispatch_test.go create mode 100644 cmd/internal/projectdriver/driver.go create mode 100644 cmd/internal/projectdriver/driver_test.go create mode 100644 cmd/internal/projectdriver/executable_darwin.go create mode 100644 cmd/internal/projectdriver/executable_linux.go create mode 100644 cmd/internal/projectdriver/executable_other.go create mode 100644 cmd/internal/projectdriver/executable_windows.go create mode 100644 cmd/internal/projectdriver/fixture_test.go create mode 100644 cmd/internal/projectdriver/flags.go create mode 100644 cmd/internal/projectdriver/flags_test.go create mode 100644 cmd/internal/projectdriver/graph.go create mode 100644 cmd/internal/projectdriver/graph_command.go create mode 100644 cmd/internal/projectdriver/graph_overlay.go create mode 100644 cmd/internal/projectdriver/graph_overlay_listing.go create mode 100644 cmd/internal/projectdriver/graph_overlay_view.go create mode 100644 cmd/internal/projectdriver/graph_package.go create mode 100644 cmd/internal/projectdriver/graph_paths.go create mode 100644 cmd/internal/projectdriver/graph_test.go create mode 100644 cmd/internal/projectdriver/output.go create mode 100644 cmd/internal/projectdriver/output_commit.go create mode 100644 cmd/internal/projectdriver/output_test.go create mode 100644 cmd/internal/projectdriver/output_transaction.go create mode 100644 cmd/internal/projectdriver/process_unix.go create mode 100644 cmd/internal/projectdriver/process_unix_test.go create mode 100644 cmd/internal/projectdriver/process_windows.go create mode 100644 cmd/internal/projectdriver/process_windows_test.go create mode 100644 cmd/internal/projectdriver/protocol.go create mode 100644 cmd/internal/projectdriver/protocol_test.go create mode 100644 cmd/internal/projectdriver/resolve.go create mode 100644 cmd/internal/projectdriver/resolve_driver.go create mode 100644 cmd/internal/projectdriver/resolve_graph.go create mode 100644 cmd/internal/projectdriver/resolve_metadata.go create mode 100644 cmd/internal/projectdriver/resolve_overlay.go create mode 100644 cmd/internal/projectdriver/resolve_overlay_test.go create mode 100644 cmd/internal/projectdriver/resolve_package.go create mode 100644 cmd/internal/projectdriver/resolve_policy_test.go create mode 100644 cmd/internal/projectdriver/resolve_project.go create mode 100644 cmd/internal/projectdriver/resolve_target.go create mode 100644 cmd/internal/projectdriver/resolve_test.go create mode 100644 cmd/internal/projectdriver/resolve_vendor.go create mode 100644 cmd/internal/projectdriver/resolve_vendor_classify.go create mode 100644 cmd/internal/projectdriver/resolve_vendor_mode.go create mode 100644 cmd/internal/projectdriver/resolve_vendor_mode_test.go create mode 100644 cmd/internal/projectdriver/resolve_vendor_test.go create mode 100644 cmd/internal/projectdriver/resolve_vendor_workspace.go create mode 100644 cmd/internal/projectdriver/resolve_vendor_workspace_test.go create mode 100644 cmd/internal/projectdriver/resolve_versioned.go create mode 100644 cmd/internal/projectdriver/resolve_versioned_test.go create mode 100644 cmd/internal/projectdriver/signal_boundary_unix.go create mode 100644 cmd/internal/projectdriver/signal_boundary_unix_test.go create mode 100644 cmd/internal/projectdriver/signal_boundary_windows.go create mode 100644 cmd/internal/projectdriver/status.go create mode 100644 cmd/internal/projectdriver/status_unix.go create mode 100644 cmd/internal/projectdriver/status_windows.go create mode 100644 cmd/internal/projectdriver/types.go create mode 100644 cmd/internal/projectdriver/version.go diff --git a/cmd/internal/base/pass.go b/cmd/internal/base/pass.go index e2dd70a48..5c5742fed 100644 --- a/cmd/internal/base/pass.go +++ b/cmd/internal/base/pass.go @@ -78,6 +78,7 @@ func PassBuildFlags(cmd *Command) *PassArgs { "trimpath", "work") p.Var("p", "asmflags", "compiler", "buildmode", "gcflags", "gccgoflags", "installsuffix", - "ldflags", "pkgdir", "tags", "toolexec", "buildvcs") + "ldflags", "pkgdir", "tags", "toolexec", "buildvcs", + "mod", "modfile", "overlay") return p } diff --git a/cmd/internal/build/build.go b/cmd/internal/build/build.go index ba8c72780..fb9088bee 100644 --- a/cmd/internal/build/build.go +++ b/cmd/internal/build/build.go @@ -18,6 +18,7 @@ package build import ( + "context" "fmt" "log" "os" @@ -27,6 +28,7 @@ import ( "github.com/goplus/gogen" "github.com/goplus/xgo/cl" "github.com/goplus/xgo/cmd/internal/base" + "github.com/goplus/xgo/cmd/internal/projectdriver" "github.com/goplus/xgo/tool" "github.com/goplus/xgo/x/gocmd" "github.com/goplus/xgo/x/xgoprojs" @@ -73,6 +75,17 @@ func runCmd(cmd *base.Command, args []string) { if len(args) != 0 { log.Panicln("too many arguments:", args) } + driverResult, driverErr := tryDriver(proj, pass.Args, *flagOutput) + if driverErr != nil { + fmt.Fprintln(os.Stderr, driverErr) + os.Exit(1) + } + if driverResult.Handled { + if driverResult.Status.Signaled || driverResult.Status.Code != 0 { + projectdriver.Exit(driverResult.Status) + } + return + } conf, err := tool.NewDefaultConf(".", tool.ConfFlagNoTestFiles, pass.Tags()) if err != nil { @@ -92,6 +105,10 @@ func runCmd(cmd *base.Command, args []string) { build(proj, conf, confCmd) } +func tryDriver(proj xgoprojs.Proj, flags []string, output string) (projectdriver.DispatchResult, error) { + return projectdriver.TryBuild(context.Background(), "", proj, flags, output, projectdriver.Streams{}) +} + func build(proj xgoprojs.Proj, conf *tool.Config, build *gocmd.BuildConfig) { const flags = tool.GenFlagPrompt var obj string diff --git a/cmd/internal/install/install.go b/cmd/internal/install/install.go index 39b9bf0e9..b6ed4de6d 100644 --- a/cmd/internal/install/install.go +++ b/cmd/internal/install/install.go @@ -18,6 +18,7 @@ package install import ( + "context" "fmt" "log" "os" @@ -27,6 +28,7 @@ import ( "github.com/goplus/mod/modfetch" "github.com/goplus/xgo/cl" "github.com/goplus/xgo/cmd/internal/base" + "github.com/goplus/xgo/cmd/internal/projectdriver" "github.com/goplus/xgo/tool" "github.com/goplus/xgo/x/gocmd" "github.com/goplus/xgo/x/xgoprojs" @@ -70,6 +72,17 @@ func runCmd(cmd *base.Command, args []string) { cl.SetDebug(cl.DbgFlagAll) cl.SetDisableRecover(true) } + driverResult, driverErr := tryDriver(projs, pass.Args) + if driverErr != nil { + fmt.Fprintln(os.Stderr, driverErr) + os.Exit(1) + } + if driverResult.Handled { + if driverResult.Status.Signaled || driverResult.Status.Code != 0 { + projectdriver.Exit(driverResult.Status) + } + return + } conf, err := tool.NewDefaultConf(".", tool.ConfFlagNoTestFiles, pass.Tags()) if err != nil { @@ -84,6 +97,10 @@ func runCmd(cmd *base.Command, args []string) { } } +func tryDriver(projs []xgoprojs.Proj, flags []string) (projectdriver.DispatchResult, error) { + return projectdriver.TryInstall(context.Background(), "", projs, flags, projectdriver.Streams{}) +} + func install(proj xgoprojs.Proj, conf *tool.Config, install *gocmd.InstallConfig) { const flags = tool.GenFlagPrompt var obj string diff --git a/cmd/internal/projectdriver/cli_e2e_test.go b/cmd/internal/projectdriver/cli_e2e_test.go new file mode 100644 index 000000000..3f619a151 --- /dev/null +++ b/cmd/internal/projectdriver/cli_e2e_test.go @@ -0,0 +1,233 @@ +/* + * 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 projectdriver + +import ( + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" +) + +func TestCLIEndToEnd(t *testing.T) { + if testing.Short() { + t.Skip("builds the XGo command and fake driver") + } + buildGoWork := os.Getenv("GOWORK") + modDir := protocolModuleDir(t, buildGoWork) + fixture := newDriverFixture(t) + configureSharedProtocolDriver(t, fixture, modDir) + goxmod := filepath.Join(fixture.framework, "gox.mod") + metadata, err := os.ReadFile(goxmod) + if err != nil { + t.Fatal(err) + } + metadata = []byte(strings.Replace(string(metadata), "pack pack index.data\n", "", 1)) + if err := os.WriteFile(goxmod, metadata, 0644); err != nil { + t.Fatal(err) + } + t.Setenv("FAKE_DRIVER_EXPECT_NO_PACK", "1") + repo, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatal(err) + } + xgo := filepath.Join(t.TempDir(), executableName("xgo")) + build := exec.Command("go", "build", "-o", xgo, "./cmd/xgo") + build.Dir = repo + build.Env = replaceEnv(os.Environ(), "GOWORK", buildGoWork) + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build XGo: %v\n%s", err, output) + } + + run := cliCommand(xgo, repo, fixture.app, "run", "./game", "--", "", "a b", "--") + output, err := run.CombinedOutput() + if err != nil { + t.Fatalf("xgo run: %v\n%s", err, output) + } + if !strings.Contains(string(output), "run-args=|a b|--") { + t.Fatalf("xgo run output = %q", output) + } + assertNoAutogen(t, fixture.project) + + artifact := filepath.Join(fixture.root, executableName("built-game")) + command := cliCommand(xgo, repo, fixture.app, "build", "-o", artifact, "./game") + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("xgo build: %v\n%s", err, output) + } + if info, err := os.Stat(artifact); err != nil || info.Size() == 0 { + t.Fatalf("built artifact = %#v, %v", info, err) + } + assertNoAutogen(t, fixture.project) + + bin := filepath.Join(fixture.root, "install-bin") + install := cliCommand(xgo, repo, fixture.app, "install", "./game") + install.Env = append(install.Env, "GOBIN="+bin) + if output, err := install.CombinedOutput(); err != nil { + t.Fatalf("xgo install: %v\n%s", err, output) + } + installed := filepath.Join(bin, executableName("game")) + if info, err := os.Stat(installed); err != nil || info.Size() == 0 { + t.Fatalf("installed artifact = %#v, %v", info, err) + } + + marker := filepath.Join(fixture.root, "driver-started") + multiBin := filepath.Join(fixture.root, "multi-bin") + multi := cliCommand(xgo, repo, fixture.app, "install", "./game", "./game") + multi.Env = append(multi.Env, "GOBIN="+multiBin, "FAKE_DRIVER_MARKER="+marker) + output, err = multi.CombinedOutput() + if err == nil || !strings.Contains(string(output), "exactly one target") { + t.Fatalf("multi driver install = %v\n%s", err, output) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("driver ran before multi-target rejection: %v", err) + } + if _, err := os.Stat(multiBin); !os.IsNotExist(err) { + t.Fatalf("install directory created before rejection: %v", err) + } + + plain := filepath.Join(fixture.app, "plain") + mustMkdirAll(t, plain) + mustWriteFile(t, filepath.Join(plain, "main.go"), "package main\nfunc main() {}\n") + plainOutput := filepath.Join(fixture.root, executableName("plain")) + legacy := cliCommand(xgo, repo, fixture.app, "build", "-o", plainOutput, "./plain") + if output, err := legacy.CombinedOutput(); err != nil { + t.Fatalf("legacy build: %v\n%s", err, output) + } +} + +func protocolModuleDir(t *testing.T, goWork string) string { + t.Helper() + command := exec.Command("go", "list", "-m", "-f={{.Dir}}", "github.com/goplus/mod") + command.Env = replaceEnv(os.Environ(), "GOWORK", goWork) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("resolve github.com/goplus/mod source: %v\n%s", err, output) + } + dir := strings.TrimSpace(string(output)) + if dir == "" { + t.Fatal("github.com/goplus/mod source directory is empty") + } + return canonicalDir(t, dir) +} + +func configureSharedProtocolDriver(t *testing.T, fixture driverFixture, modDir string) { + t.Helper() + mustWriteFile(t, filepath.Join(fixture.framework, "cmd", "driver", "main.go"), protocolFakeDriverSource) + frameworkGoModPath := filepath.Join(fixture.framework, "go.mod") + frameworkGoMod, err := os.ReadFile(frameworkGoModPath) + if err != nil { + t.Fatal(err) + } + frameworkGoMod = append(frameworkGoMod, []byte("\nrequire github.com/goplus/mod v0.0.0\n")...) + if err := os.WriteFile(frameworkGoModPath, frameworkGoMod, 0644); err != nil { + t.Fatal(err) + } + goModPath := filepath.Join(fixture.app, "go.mod") + goMod, err := os.ReadFile(goModPath) + if err != nil { + t.Fatal(err) + } + goMod = append(goMod, []byte("\nrequire github.com/goplus/mod v0.0.0\n\nreplace github.com/goplus/mod => "+strconv.Quote(modDir)+"\n")...) + if err := os.WriteFile(goModPath, goMod, 0644); err != nil { + t.Fatal(err) + } + download := exec.Command("go", "mod", "download", "all") + download.Dir = fixture.app + download.Env = replaceEnv(os.Environ(), "GOWORK", "off") + if output, err := download.CombinedOutput(); err != nil { + t.Fatalf("prepare shared-protocol driver graph: %v\n%s", err, output) + } + goMod, err = os.ReadFile(goModPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(goMod), "example.test/framework v1.2.3 //xgo:class") { + t.Fatalf("fixture lost the class marker:\n%s", goMod) + } + if _, err := os.Stat(filepath.Join(fixture.app, "driverprotocol_tools.go")); !os.IsNotExist(err) { + t.Fatalf("fixture contains a test-only tools file: %v", err) + } +} + +const protocolFakeDriverSource = `package main + +import ( + "fmt" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + + "github.com/goplus/mod/driverprotocol" +) + +func main() { + if marker := os.Getenv("FAKE_DRIVER_MARKER"); marker != "" { + _ = os.WriteFile(marker, []byte("started"), 0600) + } + if value := os.Getenv("FAKE_DRIVER_EXIT"); value != "" { + code, _ := strconv.Atoi(value) + os.Exit(code) + } + request, err := driverprotocol.Parse(os.Args[1:]) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(90) + } + if os.Getenv("FAKE_DRIVER_EXPECT_NO_PACK") != "" && request.Project.Pack != nil { + os.Exit(94) + } + switch request.Action { + case driverprotocol.ActionRun: + fmt.Printf("run-args=%s\n", strings.Join(request.ApplicationArgs, "|")) + case driverprotocol.ActionBuild: + if request.Output == nil { + os.Exit(91) + } + self, err := os.Executable() + if err != nil { panic(err) } + data, err := os.ReadFile(self) + if err != nil { panic(err) } + if err := os.WriteFile(request.Output.Staging, data, 0755); err != nil { panic(err) } + if runtime.GOOS == "darwin" { + if data, err := exec.Command("/usr/bin/codesign", "--force", "--sign", "-", request.Output.Staging).CombinedOutput(); err != nil { + fmt.Fprintln(os.Stderr, string(data)) + os.Exit(93) + } + } + default: + os.Exit(92) + } +} +` + +func cliCommand(xgo, xgoRoot, dir string, args ...string) *exec.Cmd { + cmd := exec.Command(xgo, args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOWORK=off", "XGOROOT="+xgoRoot) + return cmd +} + +func assertNoAutogen(t *testing.T, dir string) { + t.Helper() + if _, err := os.Stat(filepath.Join(dir, "xgo_autogen.go")); !os.IsNotExist(err) { + t.Fatalf("driver path touched xgo_autogen.go: %v", err) + } +} diff --git a/cmd/internal/projectdriver/command.go b/cmd/internal/projectdriver/command.go new file mode 100644 index 000000000..c99a7441d --- /dev/null +++ b/cmd/internal/projectdriver/command.go @@ -0,0 +1,36 @@ +/* + * 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 projectdriver + +import ( + "bytes" + "context" + "os/exec" +) + +func commandContext(ctx context.Context, name string, args ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, name, args...) + cmd.Stderr = new(bytes.Buffer) + return cmd +} + +func cmdStderr(cmd *exec.Cmd) []byte { + if buffer, ok := cmd.Stderr.(*bytes.Buffer); ok { + return buffer.Bytes() + } + return nil +} diff --git a/cmd/internal/projectdriver/dispatch.go b/cmd/internal/projectdriver/dispatch.go new file mode 100644 index 000000000..cacb36711 --- /dev/null +++ b/cmd/internal/projectdriver/dispatch.go @@ -0,0 +1,145 @@ +/* + * 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 projectdriver + +import ( + "context" + "errors" + "fmt" + "os" + + "github.com/goplus/xgo/x/xgoprojs" +) + +// DispatchResult reports whether a driver-backed target was selected and, if so, +// the driver process status and build output path. +type DispatchResult struct { + Handled bool + Status ProcessStatus + Output string +} + +// TryRun resolves and, when matched, runs one driver-backed target. It never exits +// the process; command entry points own status-to-exit translation. +func TryRun(ctx context.Context, cwd string, target xgoprojs.Proj, flags, appArgs []string, streams Streams) (DispatchResult, error) { + if len(appArgs) != 0 && appArgs[0] == "--" { + appArgs = appArgs[1:] + } + return dispatchOne(ctx, cwd, target, flags, "run", func(ctx context.Context, resolver *Resolver, drv *Driver) (ProcessStatus, string, error) { + status, err := resolver.Run(ctx, drv, appArgs, streams) + return status, "", err + }) +} + +// TryBuild resolves and, when matched, builds one driver-backed target. It never +// exits the process; command entry points own status-to-exit translation. +func TryBuild(ctx context.Context, cwd string, target xgoprojs.Proj, flags []string, output string, streams Streams) (DispatchResult, error) { + return dispatchOne(ctx, cwd, target, flags, "build", func(ctx context.Context, resolver *Resolver, drv *Driver) (ProcessStatus, string, error) { + return resolver.Build(ctx, drv, output, streams) + }) +} + +// TryInstall resolves all targets before creating output or starting a driver. +func TryInstall(ctx context.Context, cwd string, targets []xgoprojs.Proj, flags []string, streams Streams) (DispatchResult, error) { + if len(targets) == 0 { + return DispatchResult{}, fmt.Errorf("driver install requires at least one target") + } + for _, target := range targets { + if target == nil { + return DispatchResult{}, fmt.Errorf("driver install requires non-nil targets") + } + } + resolver, ctx, err := newDispatchResolver(ctx, cwd, flags) + if err != nil { + return DispatchResult{}, err + } + drivers := make([]*Driver, 0, len(targets)) + for _, target := range targets { + drv, handled, err := resolveTarget(ctx, resolver, target) + if err != nil { + return DispatchResult{}, err + } + if handled { + drivers = append(drivers, drv) + } + } + if len(drivers) == 0 { + return DispatchResult{}, nil + } + if len(targets) != 1 { + return DispatchResult{}, fmt.Errorf("driver v1 install accepts exactly one target") + } + boundary := beginDriverSignalBoundary(ctx) + status, final, err := resolver.Install(boundary.Context(), drivers[0], streams) + return finishDispatch(boundary, DispatchResult{Output: final}, status, err) +} + +func dispatchOne(ctx context.Context, cwd string, target xgoprojs.Proj, flags []string, action string, run func(context.Context, *Resolver, *Driver) (ProcessStatus, string, error)) (DispatchResult, error) { + if target == nil { + return DispatchResult{}, fmt.Errorf("driver %s requires a non-nil target", action) + } + resolver, ctx, err := newDispatchResolver(ctx, cwd, flags) + if err != nil { + return DispatchResult{}, err + } + drv, handled, err := resolveTarget(ctx, resolver, target) + if err != nil || !handled { + return DispatchResult{Handled: handled}, err + } + boundary := beginDriverSignalBoundary(ctx) + status, output, err := run(boundary.Context(), resolver, drv) + return finishDispatch(boundary, DispatchResult{Output: output}, status, err) +} + +func finishDispatch(boundary *driverSignalBoundary, result DispatchResult, status ProcessStatus, err error) (DispatchResult, error) { + status, err = boundary.Finish(status, err) + if err != nil { + return DispatchResult{}, err + } + result.Handled = true + result.Status = status + return result, nil +} + +func newDispatchResolver(ctx context.Context, cwd string, flags []string) (*Resolver, context.Context, error) { + if ctx == nil { + ctx = context.Background() + } + var err error + if cwd == "" { + cwd, err = os.Getwd() + if err != nil { + return nil, ctx, err + } + } + resolver, err := NewResolver(ctx, cwd, flags) + if err != nil { + return nil, ctx, err + } + return resolver, ctx, nil +} + +func resolveTarget(ctx context.Context, resolver *Resolver, target xgoprojs.Proj) (*Driver, bool, error) { + drv, err := resolver.Resolve(ctx, target) + if errors.Is(err, ErrNotHandled) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + return drv, true, nil +} diff --git a/cmd/internal/projectdriver/dispatch_test.go b/cmd/internal/projectdriver/dispatch_test.go new file mode 100644 index 000000000..262e0b65d --- /dev/null +++ b/cmd/internal/projectdriver/dispatch_test.go @@ -0,0 +1,86 @@ +/* + * 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 projectdriver + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/goplus/xgo/x/xgoprojs" +) + +func TestTryDispatchRequiresTargets(t *testing.T) { + if _, err := TryRun(context.Background(), "", nil, nil, nil, Streams{}); err == nil { + t.Fatal("TryRun accepted a nil target") + } + if _, err := TryBuild(context.Background(), "", nil, nil, "", Streams{}); err == nil { + t.Fatal("TryBuild accepted a nil target") + } + if _, err := TryInstall(context.Background(), "", nil, nil, Streams{}); err == nil { + t.Fatal("TryInstall accepted zero targets") + } + if _, err := TryInstall(context.Background(), "", []xgoprojs.Proj{nil}, nil, Streams{}); err == nil { + t.Fatal("TryInstall accepted a nil target entry") + } +} + +func TestTryBuildKeepWorkAllowsNilStderr(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newDriverFixture(t) + setFixtureRequiredXGo(t, fixture, "1.0") + output := filepath.Join(fixture.root, "game") + var stdout bytes.Buffer + result, err := TryBuild(context.Background(), fixture.app, &xgoprojs.DirProj{Dir: fixture.project}, []string{"-work=true"}, output, Streams{Stdout: &stdout}) + if err != nil { + t.Fatal(err) + } + if !result.Handled || result.Status.Code != 0 || result.Output != output { + t.Fatalf("build = %#v", result) + } + if info, err := os.Stat(output); err != nil || info.Size() == 0 { + t.Fatalf("build output = %#v, %v", info, err) + } +} + +func TestDispatchRejectsMixedTargetsBeforeDriver(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newDriverFixture(t) + setFixtureRequiredXGo(t, fixture, "1.0") + plain := filepath.Join(fixture.app, "plain") + mustMkdirAll(t, plain) + mustWriteFile(t, filepath.Join(plain, "main.go"), "package main\nfunc main() {}\n") + marker := filepath.Join(fixture.root, "driver-started") + t.Setenv("FAKE_DRIVER_MARKER", marker) + result, err := TryInstall(context.Background(), "", []xgoprojs.Proj{&xgoprojs.DirProj{Dir: plain}, &xgoprojs.DirProj{Dir: fixture.project}}, nil, Streams{}) + if err == nil || !strings.Contains(err.Error(), "exactly one target") { + t.Fatalf("dispatch = %#v, %v", result, err) + } + if result.Handled { + t.Fatalf("mixed dispatch was handled: %#v", result) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("driver started before target validation: %v", err) + } +} diff --git a/cmd/internal/projectdriver/driver.go b/cmd/internal/projectdriver/driver.go new file mode 100644 index 000000000..32c8e8277 --- /dev/null +++ b/cmd/internal/projectdriver/driver.go @@ -0,0 +1,261 @@ +/* + * 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 projectdriver + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" +) + +type builtDriver struct { + path string + dir string + keep bool +} + +func (r *Resolver) Run(ctx context.Context, drv *Driver, appArgs []string, streams Streams) (ProcessStatus, error) { + policy, err := r.BuildPolicy() + if err != nil { + return ProcessStatus{}, err + } + return execute(ctx, drv, actionRun, policy, "", "", appArgs, fillStreams(streams)) +} + +func (r *Resolver) Build(ctx context.Context, drv *Driver, requestedOutput string, streams Streams) (ProcessStatus, string, error) { + policy, err := r.BuildPolicy() + if err != nil { + return ProcessStatus{}, "", err + } + return r.buildWithPolicy(ctx, drv, requestedOutput, policy, streams) +} + +func (r *Resolver) buildWithPolicy(ctx context.Context, drv *Driver, requestedOutput string, policy BuildPolicy, streams Streams) (ProcessStatus, string, error) { + streams = fillStreams(streams) + final, err := resolveBuildOutput(r.cwd, requestedOutput, drv.DefaultExecName) + if err != nil { + return ProcessStatus{}, "", err + } + tx, err := beginOutputTransaction(final, policy.KeepWork) + if err != nil { + return ProcessStatus{}, final, err + } + defer tx.abort() + status, err := execute(ctx, drv, actionBuild, policy, tx.staged, final, nil, streams) + if err != nil || status.Signaled || status.Code != 0 { + return status, final, err + } + if err := tx.commitContext(ctx); err != nil { + return ProcessStatus{}, final, err + } + if policy.KeepWork { + fmt.Fprintf(streams.Stderr, "XGO_DRIVER_OUTPUT_WORK=%s\n", tx.dir) + } + return successStatus(), final, nil +} + +// Install builds one driver-backed target transactionally into the effective GOBIN. +func (r *Resolver) Install(ctx context.Context, drv *Driver, streams Streams) (ProcessStatus, string, error) { + policy, err := r.BuildPolicy() + if err != nil { + return ProcessStatus{}, "", err + } + bin, err := installBin(ctx, drv.Graph) + if err != nil { + return ProcessStatus{}, "", err + } + if err := os.MkdirAll(bin, 0755); err != nil { + return ProcessStatus{}, "", fmt.Errorf("create install directory: %w", err) + } + return r.buildWithPolicy(ctx, drv, filepath.Join(bin, drv.DefaultExecName), policy, streams) +} + +func installBin(ctx context.Context, graph GraphPolicy) (string, error) { + cmd := graphCommand(ctx, graph, graph.WorkDir, "env", "-json", "GOBIN", "GOPATH") + stdout, err := cmd.Output() + if err != nil { + return "", commandError("resolve install directory", err, string(cmdStderr(cmd))) + } + values := make(map[string]string) + if err := json.Unmarshal(stdout, &values); err != nil { + return "", fmt.Errorf("decode Go install directory: %w", err) + } + bin := values["GOBIN"] + if bin == "" { + paths := filepath.SplitList(values["GOPATH"]) + if len(paths) == 0 || paths[0] == "" { + return "", fmt.Errorf("go env GOPATH is empty") + } + bin = filepath.Join(paths[0], "bin") + } + if !filepath.IsAbs(bin) { + return "", fmt.Errorf("Go install directory %q is not absolute", bin) + } + return filepath.Clean(bin), nil +} + +func execute(ctx context.Context, drv *Driver, act action, policy BuildPolicy, output, finalOutput string, appArgs []string, streams Streams) (ProcessStatus, error) { + driver, err := buildDriver(ctx, drv, policy, streams) + if err != nil { + return ProcessStatus{}, err + } + defer driver.cleanup() + args, err := driverArgs(drv, act, policy, output, finalOutput, appArgs) + if err != nil { + return ProcessStatus{}, err + } + env := driverEnvironment(os.Environ(), drv) + if err := validateArgv(driver.path, args, env); err != nil { + return ProcessStatus{}, err + } + if policy.Trace { + fmt.Fprintln(streams.Stderr, redactCommand(driver.path, args)) + } + cmd := exec.Command(driver.path, args...) + cmd.Dir = drv.ProjectDir + cmd.Env = env + cmd.Stdin, cmd.Stdout, cmd.Stderr = streams.Stdin, streams.Stdout, streams.Stderr + return runDriverProcess(ctx, cmd) +} + +func buildDriver(ctx context.Context, drv *Driver, policy BuildPolicy, streams Streams) (*builtDriver, error) { + if goos := os.Getenv("GOOS"); goos != "" && goos != runtime.GOOS { + return nil, fmt.Errorf("drivers are host-only: GOOS=%s, host=%s", goos, runtime.GOOS) + } + if goarch := os.Getenv("GOARCH"); goarch != "" && goarch != runtime.GOARCH { + return nil, fmt.Errorf("drivers are host-only: GOARCH=%s, host=%s", goarch, runtime.GOARCH) + } + if err := validateDriver(ctx, drv); err != nil { + return nil, err + } + dir, err := os.MkdirTemp("", "xgo-driver-") + if err != nil { + return nil, err + } + if err := os.Chmod(dir, 0700); err != nil { + _ = os.RemoveAll(dir) + return nil, err + } + driver := &builtDriver{dir: dir, path: filepath.Join(dir, executableName("driver")), keep: policy.KeepWork} + args := drv.Graph.goArgs("build") + args = append(args, policy.goBuildFlags()...) + args = append(args, "-buildmode=exe", "-o", driver.path, drv.DriverPackage) + cmd := exec.CommandContext(ctx, drv.Graph.GoCommand, args...) + cmd.Dir = drv.Graph.WorkDir + cmd.Env = hostBuildEnvironment(os.Environ(), drv.Graph.GoWork) + cmd.Stdin, cmd.Stdout, cmd.Stderr = streams.Stdin, streams.Stdout, streams.Stderr + if policy.Trace { + fmt.Fprintln(streams.Stderr, redactCommand(drv.Graph.GoCommand, args)) + } + if err := cmd.Run(); err != nil { + driver.cleanup() + return nil, fmt.Errorf("build driver %q: %w", drv.DriverPackage, err) + } + info, err := os.Lstat(driver.path) + if err != nil { + driver.cleanup() + return nil, fmt.Errorf("driver build output: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Size() == 0 { + driver.cleanup() + return nil, fmt.Errorf("driver build did not produce a regular executable") + } + if policy.KeepWork { + fmt.Fprintf(streams.Stderr, "XGO_DRIVER_WORK=%s\n", dir) + } + return driver, nil +} + +func (p *builtDriver) cleanup() { + if p != nil && !p.keep { + _ = os.RemoveAll(p.dir) + } +} + +func validateDriver(ctx context.Context, drv *Driver) error { + if !moduleContainsPackage(drv.Origin.Selected.Path, drv.DriverPackage) { + return fmt.Errorf("driver package %q is outside declaring module %q", drv.DriverPackage, drv.Origin.Selected.Path) + } + args := drv.Graph.goArgs("list", "-json", drv.DriverPackage) + cmd := graphCommand(ctx, drv.Graph, drv.Graph.WorkDir, args...) + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + if err := cmd.Run(); err != nil { + return commandError("validate driver", err, stderr.String()) + } + var pkg goListPackage + if err := json.Unmarshal(stdout.Bytes(), &pkg); err != nil { + return fmt.Errorf("decode driver package: %w", err) + } + if pkg.Error != nil && pkg.Error.Err != "" { + return fmt.Errorf("driver package: %s", pkg.Error.Err) + } + if pkg.ImportPath != drv.DriverPackage { + return fmt.Errorf("driver resolved as %q, want %q", pkg.ImportPath, drv.DriverPackage) + } + if pkg.Name != "main" { + return fmt.Errorf("driver package %q is %q, want command package main", drv.DriverPackage, pkg.Name) + } + if pkg.Module == nil { + return fmt.Errorf("driver package %q has no module provenance", drv.DriverPackage) + } + module, err := normalizeListedModule(*pkg.Module) + if err != nil { + return err + } + if !module.Equal(drv.Origin) { + return fmt.Errorf("driver package %q does not match declaring module provenance", drv.DriverPackage) + } + driverDir, err := canonicalExistingDir(pkg.Dir) + if err != nil { + return fmt.Errorf("driver directory: %w", err) + } + if !pathWithin(drv.Origin.Effective().Dir, driverDir) { + return fmt.Errorf("driver directory escapes declaring module") + } + return nil +} + +func hostBuildEnvironment(base []string, goWork string) []string { + env := graphEnvironment(base, goWork) + env = replaceEnv(env, "GOOS", runtime.GOOS) + env = replaceEnv(env, "GOARCH", runtime.GOARCH) + return env +} + +func driverEnvironment(base []string, drv *Driver) []string { + env := hostBuildEnvironment(base, drv.Graph.GoWork) + return replaceEnv(env, driverGuardEnv, driverGuard(drv.ProjectDir, drv.DriverPackage)) +} + +func fillStreams(streams Streams) Streams { + if streams.Stdin == nil { + streams.Stdin = os.Stdin + } + if streams.Stdout == nil { + streams.Stdout = os.Stdout + } + if streams.Stderr == nil { + streams.Stderr = os.Stderr + } + return streams +} diff --git a/cmd/internal/projectdriver/driver_test.go b/cmd/internal/projectdriver/driver_test.go new file mode 100644 index 000000000..6abc8a1b6 --- /dev/null +++ b/cmd/internal/projectdriver/driver_test.go @@ -0,0 +1,214 @@ +/* + * 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 projectdriver + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/goplus/xgo/x/xgoprojs" +) + +func TestDriverRunAndBuild(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newDriverFixture(t) + resolver := fixture.resolver(t, "-trimpath=true", "-buildvcs=false") + drv := resolveDriver(t, resolver, &xgoprojs.DirProj{Dir: fixture.project}) + var stdout, stderr bytes.Buffer + status, err := resolver.Run(context.Background(), drv, []string{"", "a b", "--"}, Streams{Stdout: &stdout, Stderr: &stderr}) + if err != nil || status.Code != 0 || status.Signaled { + t.Fatalf("run = %#v, %v, stderr=%s", status, err, &stderr) + } + if got := strings.TrimSpace(stdout.String()); got != "run-args=|a b|--" { + t.Fatalf("stdout = %q", got) + } + + final := filepath.Join(fixture.root, "bin", "game") + if runtime.GOOS == "windows" { + final += ".exe" + } + if err := os.MkdirAll(filepath.Dir(final), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(final, []byte("old"), 0755); err != nil { + t.Fatal(err) + } + status, gotFinal, err := resolver.Build(context.Background(), drv, final, Streams{Stdout: &stdout, Stderr: &stderr}) + if err != nil || status.Code != 0 || gotFinal != final { + t.Fatalf("build = %#v, %q, %v, stderr=%s", status, gotFinal, err, &stderr) + } + info, err := os.Stat(final) + if err != nil || info.Size() <= int64(len("old")) { + t.Fatalf("artifact = %#v, %v", info, err) + } +} + +func TestDriverBuildFailurePreservesOutput(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newDriverFixture(t) + resolver := fixture.resolver(t) + drv := resolveDriver(t, resolver, &xgoprojs.DirProj{Dir: fixture.project}) + final := filepath.Join(fixture.root, "game") + if runtime.GOOS == "windows" { + final += ".exe" + } + if err := os.WriteFile(final, []byte("old"), 0755); err != nil { + t.Fatal(err) + } + t.Setenv("FAKE_DRIVER_EXIT", "42") + status, _, err := resolver.Build(context.Background(), drv, final, Streams{Stdout: new(bytes.Buffer), Stderr: new(bytes.Buffer)}) + if err != nil || status.Code != 42 { + t.Fatalf("build failure = %#v, %v", status, err) + } + data, readErr := os.ReadFile(final) + if readErr != nil || string(data) != "old" { + t.Fatalf("old output changed: %q, %v", data, readErr) + } + assertNoOutputWorkDirs(t, filepath.Dir(final)) +} + +func TestDriverBuildCancellationPreservesOutput(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newDriverFixture(t) + resolver := fixture.resolver(t) + drv := resolveDriver(t, resolver, &xgoprojs.DirProj{Dir: fixture.project}) + final := filepath.Join(fixture.root, "game") + if runtime.GOOS == "windows" { + final += ".exe" + } + if err := os.WriteFile(final, []byte("old"), 0755); err != nil { + t.Fatal(err) + } + marker := filepath.Join(fixture.root, "driver-started") + t.Setenv("FAKE_DRIVER_MARKER", marker) + t.Setenv("FAKE_DRIVER_BLOCK", "1") + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + _, _, _ = resolver.Build(ctx, drv, final, Streams{Stdout: new(bytes.Buffer), Stderr: new(bytes.Buffer)}) + }() + deadline := time.Now().Add(15 * time.Second) + for { + if _, err := os.Stat(marker); err == nil { + break + } + if time.Now().After(deadline) { + cancel() + <-done + t.Fatal("driver did not start") + } + time.Sleep(10 * time.Millisecond) + } + cancel() + select { + case <-done: + case <-time.After(15 * time.Second): + t.Fatal("canceled driver did not exit") + } + data, readErr := os.ReadFile(final) + if readErr != nil || string(data) != "old" { + t.Fatalf("canceled build changed old output: %q, %v", data, readErr) + } + assertNoOutputWorkDirs(t, filepath.Dir(final)) +} + +func assertNoOutputWorkDirs(t *testing.T, parent string) { + t.Helper() + matches, err := filepath.Glob(filepath.Join(parent, ".xgo-driver-output-*")) + if err != nil { + t.Fatal(err) + } + if len(matches) != 0 { + t.Fatalf("driver output work directories remain: %v", matches) + } +} + +func TestDriverInstallUsesEffectiveGOBIN(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newDriverFixture(t) + resolver := fixture.resolver(t) + drv := resolveDriver(t, resolver, &xgoprojs.DirProj{Dir: fixture.project}) + bin := filepath.Join(fixture.root, "custom-bin") + t.Setenv("GOBIN", bin) + status, final, err := resolver.Install(context.Background(), drv, Streams{Stdout: new(bytes.Buffer), Stderr: new(bytes.Buffer)}) + if err != nil || status.Code != 0 { + t.Fatalf("install = %#v, %q, %v", status, final, err) + } + want := filepath.Join(bin, executableName("game")) + if final != want { + t.Fatalf("install output = %q, want %q", final, want) + } + if info, err := os.Stat(final); err != nil || info.Size() == 0 { + t.Fatalf("installed artifact = %#v, %v", info, err) + } +} + +func TestDriverInstallValidatesPolicyBeforeCreatingGOBIN(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newDriverFixture(t) + resolver := fixture.resolver(t, "-tags=unsupported") + drv := resolveDriver(t, resolver, &xgoprojs.DirProj{Dir: fixture.project}) + bin := filepath.Join(fixture.root, "must-not-exist", "bin") + t.Setenv("GOBIN", bin) + status, final, err := resolver.Install(context.Background(), drv, Streams{Stdout: new(bytes.Buffer), Stderr: new(bytes.Buffer)}) + if err == nil || !strings.Contains(err.Error(), "does not support flag -tags") { + t.Fatalf("Install() = %#v, %q, %v; want unsupported -tags error", status, final, err) + } + if _, statErr := os.Stat(bin); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("GOBIN was created before policy validation: %v", statErr) + } +} + +func TestHostBuildEnvironmentPreservesCGO(t *testing.T) { + env := hostBuildEnvironment([]string{ + "GOOS=target-os", + "GOARCH=target-arch", + "CGO_ENABLED=1", + }, "off") + if got, _ := environmentValue(env, "GOOS"); got != runtime.GOOS { + t.Fatalf("GOOS = %q, want host %q", got, runtime.GOOS) + } + if got, _ := environmentValue(env, "GOARCH"); got != runtime.GOARCH { + t.Fatalf("GOARCH = %q, want host %q", got, runtime.GOARCH) + } + if got, ok := environmentValue(env, "CGO_ENABLED"); !ok || got != "1" { + t.Fatalf("CGO_ENABLED = %q, %t; want inherited value 1", got, ok) + } + + env = hostBuildEnvironment([]string{"PATH=/bin"}, "off") + if got, ok := environmentValue(env, "CGO_ENABLED"); ok { + t.Fatalf("CGO_ENABLED = %q; host default should remain unset", got) + } +} diff --git a/cmd/internal/projectdriver/executable_darwin.go b/cmd/internal/projectdriver/executable_darwin.go new file mode 100644 index 000000000..492d420e1 --- /dev/null +++ b/cmd/internal/projectdriver/executable_darwin.go @@ -0,0 +1,75 @@ +//go:build darwin + +/* + * 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 projectdriver + +import ( + "bytes" + "debug/macho" + "fmt" + "os" + "os/exec" + "strings" + "unsafe" + + "golang.org/x/sys/unix" +) + +func validateHostExecutable(input *os.File, path string) error { + file, err := macho.NewFile(input) + if err != nil { + return fmt.Errorf("driver output is not a Darwin executable: %w", err) + } + if err := file.Close(); err != nil { + return err + } + currentPath, err := openFilePath(input) + if err != nil { + return fmt.Errorf("resolve driver output %q: %w", path, err) + } + identity, err := input.Stat() + if err != nil { + return err + } + if info, err := os.Lstat(currentPath); err != nil || !os.SameFile(identity, info) { + return fmt.Errorf("driver output changed before signature validation") + } + cmd := exec.Command("/usr/bin/codesign", "--verify", "--strict", currentPath) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("driver output has no valid Darwin signature: %w: %s", err, strings.TrimSpace(string(output))) + } + if info, err := os.Lstat(currentPath); err != nil || !os.SameFile(identity, info) { + return fmt.Errorf("driver output changed during signature validation") + } + return nil +} + +func openFilePath(file *os.File) (string, error) { + buffer := make([]byte, unix.PathMax) + _, _, errno := unix.Syscall(unix.SYS_FCNTL, file.Fd(), uintptr(unix.F_GETPATH), uintptr(unsafe.Pointer(&buffer[0]))) + if errno != 0 { + return "", errno + } + if end := bytes.IndexByte(buffer, 0); end >= 0 { + buffer = buffer[:end] + } + if len(buffer) == 0 { + return "", fmt.Errorf("F_GETPATH returned an empty path") + } + return string(buffer), nil +} diff --git a/cmd/internal/projectdriver/executable_linux.go b/cmd/internal/projectdriver/executable_linux.go new file mode 100644 index 000000000..ffaf20c35 --- /dev/null +++ b/cmd/internal/projectdriver/executable_linux.go @@ -0,0 +1,33 @@ +//go:build linux + +/* + * 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 projectdriver + +import ( + "debug/elf" + "fmt" + "os" +) + +func validateHostExecutable(input *os.File, _ string) error { + file, err := elf.NewFile(input) + if err != nil { + return fmt.Errorf("driver output is not a Linux executable: %w", err) + } + return file.Close() +} diff --git a/cmd/internal/projectdriver/executable_other.go b/cmd/internal/projectdriver/executable_other.go new file mode 100644 index 000000000..bdc7305ef --- /dev/null +++ b/cmd/internal/projectdriver/executable_other.go @@ -0,0 +1,23 @@ +//go:build !darwin && !linux && !windows + +/* + * 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 projectdriver + +import "os" + +func validateHostExecutable(*os.File, string) error { return nil } diff --git a/cmd/internal/projectdriver/executable_windows.go b/cmd/internal/projectdriver/executable_windows.go new file mode 100644 index 000000000..2b5d34451 --- /dev/null +++ b/cmd/internal/projectdriver/executable_windows.go @@ -0,0 +1,33 @@ +//go:build windows + +/* + * 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 projectdriver + +import ( + "debug/pe" + "fmt" + "os" +) + +func validateHostExecutable(input *os.File, _ string) error { + file, err := pe.NewFile(input) + if err != nil { + return fmt.Errorf("driver output is not a Windows executable: %w", err) + } + return file.Close() +} diff --git a/cmd/internal/projectdriver/fixture_test.go b/cmd/internal/projectdriver/fixture_test.go new file mode 100644 index 000000000..79e2d86ef --- /dev/null +++ b/cmd/internal/projectdriver/fixture_test.go @@ -0,0 +1,250 @@ +/* + * 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 projectdriver + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/goplus/xgo/x/xgoprojs" +) + +type driverFixture struct { + root string + app string + project string + framework string + mainFile string +} + +func newDriverFixture(t *testing.T) driverFixture { + t.Helper() + t.Setenv("GOWORK", "off") + root := t.TempDir() + app := filepath.Join(root, "app") + project := filepath.Join(app, "game") + framework := filepath.Join(root, "framework") + mustMkdirAll(t, filepath.Join(project, "pack")) + mustMkdirAll(t, filepath.Join(framework, "cmd", "driver")) + mustWriteFile(t, filepath.Join(app, "go.mod"), `module example.test/app + +go 1.25 + +require example.test/framework v1.2.3 //xgo:class + +replace example.test/framework => ../framework +`) + mustWriteFile(t, filepath.Join(framework, "go.mod"), "module example.test/framework\n\ngo 1.25\n") + mustWriteFile(t, filepath.Join(framework, "gox.mod"), `xgo 1.8 + +project main.foo Game example.test/framework +class *.bar Worker +pack pack index.data +driver v1 example.test/framework/cmd/driver +`) + mustWriteFile(t, filepath.Join(framework, "cmd", "driver", "main.go"), fakeDriverSource) + mainFile := filepath.Join(project, "main.foo") + mustWriteFile(t, mainFile, "// fake project source\n") + mustWriteFile(t, filepath.Join(project, "pack", "index.data"), "{}\n") + return driverFixture{root: root, app: app, project: project, framework: framework, mainFile: mainFile} +} + +func (f driverFixture) resolver(t *testing.T, flags ...string) *Resolver { + t.Helper() + resolver, err := NewResolver(context.Background(), f.app, flags) + if err != nil { + t.Fatal(err) + } + resolver.xgoVersion = "v99.0.0" + return resolver +} + +func resolveDriver(t *testing.T, resolver *Resolver, target xgoprojs.Proj) *Driver { + t.Helper() + driver, err := resolver.Resolve(context.Background(), target) + if err != nil { + t.Fatal(err) + } + return driver +} +func setFixtureRequiredXGo(t *testing.T, fixture driverFixture, version string) { + t.Helper() + goxmodPath := filepath.Join(fixture.framework, "gox.mod") + goxmod, err := os.ReadFile(goxmodPath) + if err != nil { + t.Fatal(err) + } + updated := strings.Replace(string(goxmod), "xgo 1.8", "xgo "+version, 1) + if updated == string(goxmod) { + t.Fatalf("fixture gox.mod has no expected xgo directive: %s", goxmod) + } + if err := os.WriteFile(goxmodPath, []byte(updated), 0o644); err != nil { + t.Fatal(err) + } +} + +func mustMkdirAll(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatal(err) + } +} + +func mustWriteFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func mustModuleFile(t *testing.T, path, module string) { + t.Helper() + mustWriteFile(t, path, "module "+module+"\n\ngo 1.25\n") +} + +func mustOverlay(t *testing.T, root string, replacements map[string]string) string { + t.Helper() + path := filepath.Join(root, "overlay.json") + data, err := json.Marshal(overlayJSON{Replace: replacements}) + if err != nil { + t.Fatal(err) + } + mustWriteFile(t, path, string(data)) + return path +} + +func mustPolicies(t *testing.T, cwd string, cli ...string) parsedFlags { + t.Helper() + policy, err := preparePolicies(context.Background(), cwd, cli) + if err != nil { + t.Fatal(err) + } + return policy +} + +func mustGraph(t *testing.T, dir string, policy GraphPolicy) *effectiveGraph { + t.Helper() + graph, err := loadEffectiveGraph(context.Background(), dir, policy) + if err != nil { + t.Fatal(err) + } + return graph +} + +func canonicalDir(t *testing.T, path string) string { + t.Helper() + got, err := canonicalExistingDir(path) + if err != nil { + t.Fatal(err) + } + return got +} + +func canonicalFile(t *testing.T, path string) string { + t.Helper() + got, err := canonicalExistingFile(path) + if err != nil { + t.Fatal(err) + } + return got +} + +func mustRunGo(t *testing.T, dir, goWork string, args ...string) { + t.Helper() + cmd := exec.Command("go", args...) + cmd.Dir = dir + cmd.Env = replaceEnv(replaceEnv(os.Environ(), "GOWORK", goWork), "GOFLAGS", "") + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("go %s: %v\n%s", strings.Join(args, " "), err, output) + } +} + +func environmentValue(env []string, key string) (string, bool) { + for _, entry := range env { + name, value, ok := strings.Cut(entry, "=") + if ok && name == key { + return value, true + } + } + return "", false +} + +const fakeDriverSource = `package main + +import ( + "fmt" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + "time" +) + +func main() { + if marker := os.Getenv("FAKE_DRIVER_MARKER"); marker != "" { + _ = os.WriteFile(marker, []byte("started"), 0600) + } + if value := os.Getenv("FAKE_DRIVER_EXIT"); value != "" { + code, _ := strconv.Atoi(value) + os.Exit(code) + } + if os.Getenv("FAKE_DRIVER_BLOCK") == "1" { + for { + time.Sleep(time.Hour) + } + } + if len(os.Args) < 3 || os.Args[1] != "xgo-driver-v1" { + os.Exit(90) + } + switch os.Args[2] { + case "run": + for i, arg := range os.Args[3:] { + if arg == "--" { + fmt.Printf("run-args=%s\n", strings.Join(os.Args[i+4:], "|")) + return + } + } + os.Exit(91) + case "build": + var output string + for _, arg := range os.Args[3:] { + if strings.HasPrefix(arg, "--output=") { + output = strings.TrimPrefix(arg, "--output=") + } + } + self, err := os.Executable() + if err != nil { panic(err) } + data, err := os.ReadFile(self) + if err != nil { panic(err) } + if err := os.WriteFile(output, data, 0755); err != nil { panic(err) } + if runtime.GOOS == "darwin" { + if data, err := exec.Command("/usr/bin/codesign", "--force", "--sign", "-", output).CombinedOutput(); err != nil { + fmt.Fprintln(os.Stderr, string(data)) + os.Exit(93) + } + } + default: + os.Exit(92) + } +} +` diff --git a/cmd/internal/projectdriver/flags.go b/cmd/internal/projectdriver/flags.go new file mode 100644 index 000000000..750e2936d --- /dev/null +++ b/cmd/internal/projectdriver/flags.go @@ -0,0 +1,239 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package projectdriver + +import ( + "fmt" + "path/filepath" + "strconv" + "strings" +) + +type parsedFlags struct { + graph GraphPolicy + build BuildPolicy + rejected []string +} + +func (p BuildPolicy) goBuildFlags() []string { + return p.formatFlags("") +} + +func (p BuildPolicy) protocolFlags() []string { + return p.formatFlags("=true") +} + +func (p BuildPolicy) formatFlags(enabledSuffix string) []string { + flags := make([]string, 0, 5) + if p.Verbose { + flags = append(flags, "-v"+enabledSuffix) + } + if p.Trace { + flags = append(flags, "-x"+enabledSuffix) + } + if p.KeepWork { + flags = append(flags, "-work"+enabledSuffix) + } + if p.TrimPath { + flags = append(flags, "-trimpath=true") + } + if p.DisableBuildVCS { + flags = append(flags, "-buildvcs=false") + } + return flags +} + +func (p GraphPolicy) goFlags() []string { + flags := make([]string, 0, 3) + if p.ModMode != "" { + flags = append(flags, "-mod="+string(p.ModMode)) + } + if p.ModFile != "" { + flags = append(flags, "-modfile="+p.ModFile) + } + if p.Overlay != "" { + flags = append(flags, "-overlay="+p.Overlay) + } + return flags +} + +func (p GraphPolicy) goArgs(command string, args ...string) []string { + ret := make([]string, 1, 1+len(args)+3) + ret[0] = command + ret = append(ret, p.goFlags()...) + return append(ret, args...) +} + +// parseDriverFlags extracts discovery policy and defers rejected flags. +func parseDriverFlags(projectDir, goCommand, goWork, ambient string, cli []string) (parsedFlags, error) { + ambientArgs, err := splitQuotedFields(ambient) + if err != nil { + return parsedFlags{}, fmt.Errorf("invalid GOFLAGS: %w", err) + } + all := append(ambientArgs, cli...) + var ret parsedFlags + ret.graph.GoCommand = goCommand + ret.graph.GoWork = goWork + for _, arg := range all { + name, value, ok := splitCanonicalFlag(arg) + if !ok { + ret.rejected = append(ret.rejected, arg) + continue + } + switch name { + case "mod": + mode := modMode(value) + switch mode { + case modModeMod, modModeReadonly, modModeVendor: + default: + // Keep malformed driver-only policy deferred until a + // driver-backed project is selected. Legacy Go/XGo commands are + // still allowed to report the flag error themselves. + ret.rejected = append(ret.rejected, arg) + continue + } + ret.graph.ModMode = mode + case "modfile", "overlay": + if value == "" { + ret.rejected = append(ret.rejected, arg) + continue + } + path, err := canonicalFlagPath(projectDir, value) + if err != nil { + ret.rejected = append(ret.rejected, arg) + continue + } + if name == "modfile" { + ret.graph.ModFile = path + } else { + ret.graph.Overlay = path + // Discover through the overlay; reject it for matched drivers. + ret.rejected = append(ret.rejected, arg) + } + case "v": + v, err := strconv.ParseBool(value) + if err != nil { + return parsedFlags{}, fmt.Errorf("invalid -v value %q", value) + } + ret.build.Verbose = v + case "x": + v, err := strconv.ParseBool(value) + if err != nil { + return parsedFlags{}, fmt.Errorf("invalid -x value %q", value) + } + ret.build.Trace = v + case "work": + v, err := strconv.ParseBool(value) + if err != nil { + return parsedFlags{}, fmt.Errorf("invalid -work value %q", value) + } + ret.build.KeepWork = v + case "trimpath": + v, err := strconv.ParseBool(value) + if err != nil || !v { + ret.rejected = append(ret.rejected, "-"+name) + continue + } + ret.build.TrimPath = true + case "buildvcs": + if value != "false" { + ret.rejected = append(ret.rejected, "-"+name) + continue + } + ret.build.DisableBuildVCS = true + default: + ret.rejected = append(ret.rejected, "-"+name) + } + } + return ret, nil +} + +func (p parsedFlags) validateDriver() error { + if len(p.rejected) == 0 { + return nil + } + return fmt.Errorf("driver v1 does not support flag %s", p.rejected[0]) +} + +func splitCanonicalFlag(arg string) (name, value string, ok bool) { + if !strings.HasPrefix(arg, "-") || arg == "-" || arg == "--" { + return "", "", false + } + arg = strings.TrimPrefix(arg, "-") + if strings.HasPrefix(arg, "-") { + arg = strings.TrimPrefix(arg, "-") + } + if at := strings.IndexByte(arg, '='); at >= 0 { + name, value = arg[:at], arg[at+1:] + } else { + name, value = arg, "true" + } + return name, value, name != "" +} + +func canonicalFlagPath(base, path string) (string, error) { + if !filepath.IsAbs(path) { + path = filepath.Join(base, path) + } + abs, err := filepath.Abs(path) + if err != nil { + return "", err + } + abs = filepath.Clean(abs) + if real, err := filepath.EvalSymlinks(abs); err == nil { + abs = real + } + return abs, nil +} + +func isQuotedFieldSpace(c byte) bool { + return c == ' ' || c == '\t' || c == '\n' || c == '\r' +} + +// splitQuotedFields mirrors the Go command's GOFLAGS parser. +func splitQuotedFields(s string) ([]string, error) { + var fields []string + for len(s) > 0 { + for len(s) > 0 && isQuotedFieldSpace(s[0]) { + s = s[1:] + } + if len(s) == 0 { + break + } + if s[0] == '\'' || s[0] == '"' { + quote := s[0] + s = s[1:] + i := 0 + for i < len(s) && s[i] != quote { + i++ + } + if i >= len(s) { + return nil, fmt.Errorf("unterminated %c string", quote) + } + fields = append(fields, s[:i]) + s = s[i+1:] + continue + } + i := 0 + for i < len(s) && !isQuotedFieldSpace(s[i]) { + i++ + } + fields = append(fields, s[:i]) + s = s[i:] + } + return fields, nil +} diff --git a/cmd/internal/projectdriver/flags_test.go b/cmd/internal/projectdriver/flags_test.go new file mode 100644 index 000000000..ebab7b6a4 --- /dev/null +++ b/cmd/internal/projectdriver/flags_test.go @@ -0,0 +1,170 @@ +/* + * 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 projectdriver + +import ( + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestSplitQuotedFields(t *testing.T) { + tests := []struct { + name string + in string + want []string + }{ + { + name: "whole fields may be quoted", + in: `-buildvcs=false '-overlay=a b.json' "-modfile=x.mod" -trimpath`, + want: []string{"-buildvcs=false", "-overlay=a b.json", "-modfile=x.mod", "-trimpath"}, + }, + { + name: "backslashes are literal", + in: `"-modfile=C:\work\alt.mod" -overlay=C:\work\overlay.json -x=foo\`, + want: []string{`-modfile=C:\work\alt.mod`, `-overlay=C:\work\overlay.json`, `-x=foo\`}, + }, + { + name: "quotes inside fields are literal", + in: `-overlay="a b.json" "-modfile=x.mod"`, + want: []string{`-overlay="a`, `b.json"`, "-modfile=x.mod"}, + }, + { + name: "unterminated interior quote is literal", + in: `-x="unterminated`, + want: []string{`-x="unterminated`}, + }, + { + name: "empty quoted field", + in: `'' ""`, + want: []string{"", ""}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := splitQuotedFields(tt.in) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("splitQuotedFields(%q) = %#v, want %#v", tt.in, got, tt.want) + } + }) + } + for _, in := range []string{`"unterminated`, `'unterminated`} { + if _, err := splitQuotedFields(in); err == nil { + t.Fatalf("splitQuotedFields(%q) succeeded", in) + } + } +} + +func TestParseDriverFlags(t *testing.T) { + dir := t.TempDir() + got, err := parseDriverFlags(dir, "/usr/bin/go", "off", + `-buildvcs=false -mod=readonly`, + []string{"-v=true", "-x=false", "-work=true", "-trimpath=true", "-mod=mod", "-modfile=alt.mod"}) + if err != nil { + t.Fatal(err) + } + wantGraph := []string{ + "-mod=mod", + "-modfile=" + filepath.Join(dir, "alt.mod"), + } + if !reflect.DeepEqual(got.graph.goFlags(), wantGraph) { + t.Fatalf("graph flags = %#v, want %#v", got.graph.goFlags(), wantGraph) + } + if got.graph.ModMode != modModeMod || got.graph.ModFile != filepath.Join(dir, "alt.mod") || !got.build.Verbose || got.build.Trace || !got.build.KeepWork { + t.Fatalf("unexpected policies: %#v", got) + } + if !got.build.DisableBuildVCS || !got.build.TrimPath { + t.Fatalf("build flags = %#v", got.build) + } + if err := got.validateDriver(); err != nil { + t.Fatal(err) + } +} + +func TestParseDriverFlagsDefersOverlayRejection(t *testing.T) { + dir := t.TempDir() + got, err := parseDriverFlags(dir, "/usr/bin/go", "off", `'-overlay=old overlay.json'`, nil) + if err != nil { + t.Fatal(err) + } + wantGraph := []string{"-overlay=" + filepath.Join(dir, "old overlay.json")} + if !reflect.DeepEqual(got.graph.goFlags(), wantGraph) { + t.Fatalf("graph flags = %#v, want %#v", got.graph.goFlags(), wantGraph) + } + if err := got.validateDriver(); err == nil || !strings.Contains(err.Error(), "overlay") { + t.Fatalf("validateDriver() = %v, want deferred overlay rejection", err) + } +} + +func TestParseDriverFlagsAcceptsDoubleDashForms(t *testing.T) { + dir := t.TempDir() + got, err := parseDriverFlags(dir, "/usr/bin/go", "off", `--mod=readonly --trimpath`, []string{"--mod=mod", "--buildvcs=false"}) + if err != nil { + t.Fatal(err) + } + if want := []string{"-mod=mod"}; !reflect.DeepEqual(got.graph.goFlags(), want) { + t.Fatalf("graph flags = %#v, want %#v", got.graph.goFlags(), want) + } + if !got.build.TrimPath || !got.build.DisableBuildVCS { + t.Fatalf("build flags = %#v", got.build) + } + if err := got.validateDriver(); err != nil { + t.Fatal(err) + } + for _, flag := range []string{"--", "---trimpath"} { + got, err := parseDriverFlags(dir, "/usr/bin/go", "off", "", []string{flag}) + if err != nil { + t.Fatal(err) + } + if err := got.validateDriver(); err == nil { + t.Fatalf("invalid flag %q was accepted", flag) + } + } +} + +func TestBuildPolicyFlagForms(t *testing.T) { + policy := BuildPolicy{ + TrimPath: true, + Verbose: true, + Trace: true, + KeepWork: true, + } + wantGo := []string{"-v", "-x", "-work", "-trimpath=true"} + wantProtocol := []string{"-v=true", "-x=true", "-work=true", "-trimpath=true"} + if got := policy.goBuildFlags(); !reflect.DeepEqual(got, wantGo) { + t.Fatalf("go build flags = %#v, want %#v", got, wantGo) + } + if got := policy.protocolFlags(); !reflect.DeepEqual(got, wantProtocol) { + t.Fatalf("protocol flags = %#v, want %#v", got, wantProtocol) + } +} + +func TestParseDriverFlagsRejected(t *testing.T) { + for _, flag := range []string{"-n=true", "-tags=foo", "-buildmode=pie", "-buildvcs=true", "-trimpath=false"} { + got, err := parseDriverFlags(t.TempDir(), "/usr/bin/go", "off", "", []string{flag}) + if err != nil { + t.Fatalf("parseDriverFlags(%q): %v", flag, err) + } + if err := got.validateDriver(); err == nil || !strings.Contains(err.Error(), strings.Split(strings.TrimPrefix(flag, "-"), "=")[0]) { + t.Fatalf("validateDriver(%q) = %v", flag, err) + } + } +} diff --git a/cmd/internal/projectdriver/graph.go b/cmd/internal/projectdriver/graph.go new file mode 100644 index 000000000..2ba994353 --- /dev/null +++ b/cmd/internal/projectdriver/graph.go @@ -0,0 +1,280 @@ +/* + * 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 projectdriver + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + + "github.com/goplus/mod/modload" + gomodfile "golang.org/x/mod/modfile" +) + +var errNoGoModule = errors.New("driver discovery requires a Go module") + +type fileIdentity = modload.FileIdentity + +type effectiveGraph struct { + Target ResolvedModule + Modules map[string]ResolvedModule + ClassModules []ResolvedModule + TargetModFile fileIdentity + files *graphFileView +} + +type goListModule struct { + Path string + Version string + Replace *goListModule + Main bool + Dir string + GoMod string + Error *struct { + Err string + } +} + +func loadEffectiveGraph(ctx context.Context, projectDir string, policy GraphPolicy) (*effectiveGraph, error) { + view, err := newGraphFileView(policy, projectDir) + if err != nil { + return nil, err + } + stdout, stderr, err := runGraphCommand(ctx, policy, projectDir, "go list -m", policy.goArgs("list", "-m", "-json", "all")...) + if err != nil { + message := string(stderr) + if strings.Contains(message, "go.mod file not found") || strings.Contains(message, "cannot find main module") { + return nil, fmt.Errorf("%w: %s", errNoGoModule, strings.TrimSpace(message)) + } + return nil, err + } + var raw []goListModule + dec := json.NewDecoder(bytes.NewReader(stdout)) + for { + var module goListModule + if err := dec.Decode(&module); errors.Is(err, io.EOF) { + break + } else if err != nil { + return nil, fmt.Errorf("decode effective module graph: %w", err) + } + raw = append(raw, module) + } + if len(raw) == 0 { + return nil, fmt.Errorf("effective module graph is empty") + } + projectDir, err = canonicalExistingDir(projectDir) + if err != nil { + return nil, err + } + ret := &effectiveGraph{Modules: make(map[string]ResolvedModule, len(raw))} + bestRoot := "" + for _, module := range raw { + resolved, err := normalizeGraphModule(module) + if err != nil { + return nil, err + } + if _, exists := ret.Modules[resolved.Selected.Path]; exists { + return nil, fmt.Errorf("duplicate logical module %q in effective graph", resolved.Selected.Path) + } + ret.Modules[resolved.Selected.Path] = resolved + effective := resolved.Effective() + if effective.Dir != "" && pathWithin(effective.Dir, projectDir) && len(effective.Dir) > len(bestRoot) { + bestRoot = effective.Dir + ret.Target = resolved + } + } + if bestRoot == "" { + return nil, fmt.Errorf("project directory %q is outside the effective module graph", projectDir) + } + modfilePath := ret.Target.Effective().GoMod + if policy.ModFile != "" { + modfilePath = policy.ModFile + } + identity, classPaths, err := readTargetModFileView(modfilePath, view) + if err != nil { + return nil, err + } + classModules := make([]ResolvedModule, 0, len(classPaths)) + for _, path := range classPaths { + module, ok := ret.Modules[path] + if !ok { + return nil, fmt.Errorf("class module %q is absent from the effective graph", path) + } + if module.Effective().Dir == "" || module.Effective().GoMod == "" { + module, err = downloadGraphModule(ctx, projectDir, policy, module) + if err != nil { + return nil, fmt.Errorf("materialize class module %q: %w", path, err) + } + ret.Modules[path] = module + } + classModules = append(classModules, module) + } + ret.ClassModules = classModules + ret.TargetModFile = identity + ret.files = view + return ret, nil +} + +type goDownloadModule struct { + Path string + Version string + Dir string + GoMod string + Error string +} + +func downloadGraphModule(ctx context.Context, dir string, policy GraphPolicy, resolved ResolvedModule) (ResolvedModule, error) { + effective := resolved.Effective() + if effective.Version == "" { + return ResolvedModule{}, fmt.Errorf("local effective source is missing") + } + query := effective.Path + "@" + effective.Version + stdout, _, err := runGraphCommand(ctx, policy, dir, "go mod download "+query, "mod", "download", "-json", query) + if err != nil { + return ResolvedModule{}, err + } + var downloaded goDownloadModule + if err := json.Unmarshal(stdout, &downloaded); err != nil { + return ResolvedModule{}, fmt.Errorf("decode downloaded module: %w", err) + } + if downloaded.Error != "" { + return ResolvedModule{}, errors.New(downloaded.Error) + } + if downloaded.Path != effective.Path || downloaded.Version != effective.Version { + return ResolvedModule{}, fmt.Errorf("downloaded module identity %s@%s does not match %s", downloaded.Path, downloaded.Version, query) + } + sourceDir, goMod, err := canonicalModuleSource(effective.Path, downloaded.Dir, downloaded.GoMod) + if err != nil { + return ResolvedModule{}, err + } + if resolved.Replace == nil { + resolved.Selected.Dir, resolved.Selected.GoMod = sourceDir, goMod + } else { + resolved.Replace.Dir, resolved.Replace.GoMod = sourceDir, goMod + } + if err := resolved.Validate(); err != nil { + return ResolvedModule{}, fmt.Errorf("downloaded module %q: %w", query, err) + } + return resolved, nil +} + +func normalizeListedModule(module goListModule) (ResolvedModule, error) { + return normalizeModule(module, true) +} + +func normalizeGraphModule(module goListModule) (ResolvedModule, error) { + return normalizeModule(module, false) +} + +func normalizeModule(module goListModule, requireSource bool) (ResolvedModule, error) { + if module.Error != nil && module.Error.Err != "" { + return ResolvedModule{}, fmt.Errorf("module %q: %s", module.Path, module.Error.Err) + } + if module.Path == "" { + return ResolvedModule{}, fmt.Errorf("effective graph contains a module with no path") + } + ret := ResolvedModule{ + Selected: ModuleRef{Path: module.Path, Version: module.Version}, + Main: module.Main, + } + if module.Replace == nil { + if !requireSource && (module.Dir == "" || module.GoMod == "") { + return ret, nil + } + dir, goMod, err := canonicalModuleSource(module.Path, module.Dir, module.GoMod) + if err != nil { + return ResolvedModule{}, err + } + ret.Selected.Dir, ret.Selected.GoMod = dir, goMod + if err := ret.Validate(); err != nil { + return ResolvedModule{}, fmt.Errorf("module %q: %w", module.Path, err) + } + return ret, nil + } + if !requireSource && (module.Replace.Dir == "" || module.Replace.GoMod == "") { + replacePath := module.Replace.Path + ret.Replace = &ModuleRef{Path: replacePath, Version: module.Replace.Version} + return ret, nil + } + dir, goMod, err := canonicalModuleSource(module.Replace.Path, module.Replace.Dir, module.Replace.GoMod) + if err != nil { + return ResolvedModule{}, err + } + replacePath := module.Replace.Path + if module.Replace.Version == "" { + // go list preserves the spelling from the replace directive (often + // ../framework). The resolved graph contract carries the canonical + // filesystem identity for a local replacement. + replacePath = dir + } + ret.Replace = &ModuleRef{ + Path: replacePath, + Version: module.Replace.Version, + Dir: dir, + GoMod: goMod, + } + if err := ret.Validate(); err != nil { + return ResolvedModule{}, fmt.Errorf("module %q: %w", module.Path, err) + } + return ret, nil +} + +func canonicalModuleSource(path, dir, goMod string) (string, string, error) { + if dir == "" || goMod == "" { + return "", "", fmt.Errorf("effective source for module %q is incomplete (vendor mode is unsupported for class projects)", path) + } + canonicalDir, err := canonicalExistingDir(dir) + if err != nil { + return "", "", fmt.Errorf("module %q directory: %w", path, err) + } + canonicalGoMod, err := canonicalExistingFile(goMod) + if err != nil { + return "", "", fmt.Errorf("module %q go.mod: %w", path, err) + } + return canonicalDir, canonicalGoMod, nil +} + +func readTargetModFile(path string) (fileIdentity, []string, error) { + return readTargetModFileView(path, nil) +} + +func readTargetModFileView(path string, view *graphFileView) (fileIdentity, []string, error) { + canonical, err := view.canonicalFile(path) + if err != nil { + return fileIdentity{}, nil, fmt.Errorf("effective target modfile: %w", err) + } + data, err := view.readFile(canonical) + if err != nil { + return fileIdentity{}, nil, fmt.Errorf("read effective target modfile: %w", err) + } + parsed, err := gomodfile.Parse(canonical, data, nil) + if err != nil { + return fileIdentity{}, nil, err + } + classMods := make([]string, 0) + for _, require := range parsed.Require { + if require.Syntax == nil || !modload.HasClassMarker(require.Syntax.Suffix) { + continue + } + classMods = append(classMods, require.Mod.Path) + } + return fileIdentity{Path: canonical, SHA256: sha256Bytes(data)}, classMods, nil +} diff --git a/cmd/internal/projectdriver/graph_command.go b/cmd/internal/projectdriver/graph_command.go new file mode 100644 index 000000000..51563be57 --- /dev/null +++ b/cmd/internal/projectdriver/graph_command.go @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package projectdriver + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "strings" +) + +func graphCommand(ctx context.Context, policy GraphPolicy, dir string, args ...string) *exec.Cmd { + cmd := commandContext(ctx, policy.GoCommand, args...) + cmd.Dir = dir + cmd.Env = graphEnvironment(os.Environ(), policy.GoWork) + return cmd +} + +func runGraphCommand(ctx context.Context, policy GraphPolicy, dir, display string, args ...string) (stdout, stderr []byte, err error) { + cmd := graphCommand(ctx, policy, dir, args...) + var out, errOut bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &errOut + err = cmd.Run() + if err != nil { + return out.Bytes(), errOut.Bytes(), commandError(display, err, errOut.String()) + } + return out.Bytes(), nil, nil +} + +func preparePolicies(ctx context.Context, cwd string, cli []string) (parsedFlags, error) { + goCommand, err := hostGoCommand() + if err != nil { + return parsedFlags{}, err + } + ambient, err := goEnvValue(ctx, goCommand, cwd, "GOFLAGS", false) + if err != nil { + return parsedFlags{}, err + } + policy, err := parseDriverFlags(cwd, goCommand, "off", ambient, cli) + if err != nil { + return parsedFlags{}, err + } + policy, err = sanitizeGraphFlags(policy) + if err != nil { + return parsedFlags{}, err + } + // GOWORK does not depend on -mod/-modfile/-overlay. Keep graph flags out of + // GOFLAGS entirely: their canonical paths are passed as distinct argv + // elements to every graph command, which also preserves spaces and Windows + // path separators without a second quoting grammar. + goWork, err := goEnvValue(ctx, goCommand, cwd, "GOWORK", true) + if err != nil { + return parsedFlags{}, err + } + if goWork == "" || goWork == "off" { + policy.graph.GoWork = "off" + } else { + goWork, err = canonicalExistingFile(goWork) + if err != nil { + return parsedFlags{}, fmt.Errorf("effective go.work: %w", err) + } + policy.graph.GoWork = goWork + } + return policy, nil +} + +// sanitizeGraphFlags defers missing graph files as driver-only policy errors. +func sanitizeGraphFlags(policy parsedFlags) (parsedFlags, error) { + if path := policy.graph.ModFile; path != "" { + if _, err := os.Stat(path); os.IsNotExist(err) { + policy.rejected = append(policy.rejected, "-modfile="+path) + policy.graph.ModFile = "" + } else if err != nil { + return parsedFlags{}, fmt.Errorf("inspect -modfile input %q: %w", path, err) + } + } + if path := policy.graph.Overlay; path != "" { + if _, err := os.Stat(path); os.IsNotExist(err) { + policy.rejected = append(policy.rejected, "-overlay="+path) + policy.graph.Overlay = "" + } else if err != nil { + return parsedFlags{}, fmt.Errorf("inspect -overlay input %q: %w", path, err) + } + } + return policy, nil +} + +func hostGoCommand() (string, error) { + path, err := exec.LookPath("go") + if err != nil { + return "", fmt.Errorf("host Go command: %w", err) + } + canonical, err := canonicalExistingFile(path) + if err != nil { + return "", fmt.Errorf("host Go command %q: %w", path, err) + } + return canonical, nil +} + +func goEnvValue(ctx context.Context, goCommand, dir, key string, clearGOFLAGS bool) (string, error) { + cmd := commandContext(ctx, goCommand, "env", key) + cmd.Dir = dir + cmd.Env = os.Environ() + // GOFLAGS itself must be read from the ambient environment; GOWORK is + // queried with GOFLAGS cleared so an ambient graph flag cannot affect it. + if clearGOFLAGS { + cmd.Env = replaceEnv(cmd.Env, "GOFLAGS", "") + } + return runGoEnv(cmd, key) +} + +// goModForDir returns the physical owner; -modfile changes content, not roots. +func goModForDir(ctx context.Context, policy GraphPolicy, dir string) (string, error) { + cmd := graphCommand(ctx, policy, dir, "env", "GOMOD") + return runGoEnv(cmd, "GOMOD") +} + +func runGoEnv(cmd *exec.Cmd, key string) (string, error) { + out, err := cmd.Output() + if err != nil { + return "", commandError("go env "+key, err, string(cmdStderr(cmd))) + } + return strings.TrimSpace(string(out)), nil +} + +func graphEnvironment(base []string, goWork string) []string { + // Graph policy is passed as argv. Clearing inherited GOFLAGS prevents an + // ambient unsupported flag from changing the authoritative graph command. + env := replaceEnv(base, "GOFLAGS", "") + if goWork != "" { + env = replaceEnv(env, "GOWORK", goWork) + } + return env +} + +func replaceEnv(env []string, key, value string) []string { + prefix := key + "=" + ret := make([]string, 0, len(env)+1) + for _, item := range env { + if !strings.HasPrefix(item, prefix) { + ret = append(ret, item) + } + } + return append(ret, prefix+value) +} + +func commandError(name string, err error, stderr string) error { + if message := strings.TrimSpace(stderr); message != "" { + return fmt.Errorf("%s: %w: %s", name, err, message) + } + return fmt.Errorf("%s: %w", name, err) +} diff --git a/cmd/internal/projectdriver/graph_overlay.go b/cmd/internal/projectdriver/graph_overlay.go new file mode 100644 index 000000000..d69e4d336 --- /dev/null +++ b/cmd/internal/projectdriver/graph_overlay.go @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package projectdriver + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// graphFileView models -overlay paths for classification only. +// The Go command remains authoritative for graph resolution. +type graphFileView struct { + workDir string + replacements map[string]string +} + +type overlayJSON struct { + Replace map[string]string +} + +func newGraphFileView(policy GraphPolicy, workDir string) (*graphFileView, error) { + view := &graphFileView{workDir: workDir} + overlay := policy.Overlay + if overlay == "" { + return view, nil + } + data, err := os.ReadFile(overlay) + if err != nil { + return nil, fmt.Errorf("read overlay %q: %w", overlay, err) + } + var parsed overlayJSON + if err := json.Unmarshal(data, &parsed); err != nil { + return nil, fmt.Errorf("parse overlay %q: %w", overlay, err) + } + view.replacements = make(map[string]string, len(parsed.Replace)) + for from, to := range parsed.Replace { + if from == "" { + return nil, fmt.Errorf("overlay %q contains an empty replacement path", overlay) + } + from = overlayPath(workDir, from) + if _, duplicate := view.replacements[from]; duplicate { + return nil, fmt.Errorf("overlay %q contains duplicate normalized path %q", overlay, from) + } + view.replacements[from] = overlayPath(workDir, to) + } + for parent, target := range view.replacements { + if target == "" { + continue + } + for child, childTarget := range view.replacements { + if childTarget != "" && child != parent && pathWithin(parent, child) { + return nil, fmt.Errorf("overlay %q maps both file %q and child %q", overlay, parent, child) + } + } + } + return view, nil +} + +func overlayPath(workDir, path string) string { + if path == "" { + return "" + } + if !filepath.IsAbs(path) { + path = filepath.Join(workDir, path) + } + return filepath.Clean(path) +} + +func (v *graphFileView) hasOverlay() bool { + return v != nil && v.replacements != nil +} + +func (v *graphFileView) logicalPath(path string) string { + if !v.hasOverlay() { + return path + } + return overlayPath(v.workDir, path) +} + +func statVisible(path string, matches func(os.FileMode) bool) (bool, error) { + info, err := os.Stat(path) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, err + } + return matches(info.Mode()), nil +} diff --git a/cmd/internal/projectdriver/graph_overlay_listing.go b/cmd/internal/projectdriver/graph_overlay_listing.go new file mode 100644 index 000000000..c8d2fc3d1 --- /dev/null +++ b/cmd/internal/projectdriver/graph_overlay_listing.go @@ -0,0 +1,140 @@ +/* + * 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 projectdriver + +import ( + "os" + "path/filepath" + "sort" + "strings" +) + +// regularFileNames merges physical and overlay files without inspecting destinations. +func (v *graphFileView) regularFileNames(dir string) ([]string, error) { + logicalDir := v.logicalPath(dir) + entries, err := v.physicalEntries(logicalDir) + if err != nil { + return nil, err + } + files := make(map[string]bool, len(entries)) + for _, entry := range entries { + logical := filepath.Join(logicalDir, entry.Name()) + if v.hasOverlay() { + if actual, replaced := v.replacements[logical]; replaced { + files[entry.Name()] = actual != "" + continue + } + if v.hasReplacementChild(logical) { + continue + } + } + if entry.Type()&os.ModeSymlink != 0 { + continue + } + info, infoErr := entry.Info() + if infoErr != nil { + return nil, infoErr + } + if info.Mode().IsRegular() { + files[entry.Name()] = true + } + } + if v.hasOverlay() { + for logical, actual := range v.replacements { + if filepath.Dir(logical) != logicalDir { + continue + } + files[filepath.Base(logical)] = actual != "" + } + } + names := make([]string, 0, len(files)) + for name, visible := range files { + if visible { + names = append(names, name) + } + } + sort.Strings(names) + return names, nil +} + +// directoryNames returns physical children plus overlay-only directories. +func (v *graphFileView) directoryNames(dir string) ([]string, error) { + logicalDir := overlayPath(v.workDir, dir) + visible, err := v.directoryVisible(logicalDir) + if err != nil { + return nil, err + } + if !visible { + return nil, nil + } + entries, err := v.physicalEntries(logicalDir) + if err != nil { + return nil, err + } + direct := make(map[string]bool, len(entries)) + for _, entry := range entries { + logical := filepath.Join(logicalDir, entry.Name()) + if actual, replaced := v.replacements[logical]; replaced { + direct[entry.Name()] = false + if actual == "" { + continue + } + continue + } + if v.hasReplacementChild(logical) { + direct[entry.Name()] = true + continue + } + if entry.Type()&os.ModeSymlink != 0 { + continue + } + info, infoErr := entry.Info() + if infoErr != nil { + return nil, infoErr + } + if info.IsDir() { + direct[entry.Name()] = true + } + } + for logical, actual := range v.replacements { + if actual == "" || !pathWithin(logicalDir, logical) { + continue + } + rel, relErr := filepath.Rel(logicalDir, logical) + if relErr != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + continue + } + name := rel + if at := strings.IndexByte(rel, filepath.Separator); at >= 0 { + name = rel[:at] + } + child := filepath.Join(logicalDir, name) + if childActual, exact := v.replacements[child]; exact { + direct[name] = childActual == "" + continue + } + direct[name] = true + } + names := make([]string, 0, len(direct)) + for name, visible := range direct { + if visible { + names = append(names, name) + } + } + sort.Strings(names) + return names, nil +} diff --git a/cmd/internal/projectdriver/graph_overlay_view.go b/cmd/internal/projectdriver/graph_overlay_view.go new file mode 100644 index 000000000..44c01a8c8 --- /dev/null +++ b/cmd/internal/projectdriver/graph_overlay_view.go @@ -0,0 +1,161 @@ +/* + * 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 projectdriver + +import ( + "os" + "path/filepath" +) + +func (v *graphFileView) readFile(path string) ([]byte, error) { + if !v.hasOverlay() { + return os.ReadFile(path) + } + logical := v.logicalPath(path) + if actual, ok := v.replacements[logical]; ok { + if actual == "" { + return nil, &os.PathError{Op: "read", Path: logical, Err: os.ErrNotExist} + } + return os.ReadFile(actual) + } + // An exact child replacement wins over an ancestor deletion, as in cmd/go. + if _, hidden := v.replacementAncestor(logical); hidden { + return nil, &os.PathError{Op: "read", Path: logical, Err: os.ErrNotExist} + } + return os.ReadFile(logical) +} + +// replacementAncestor returns the nearest overlay entry above path. +func (v *graphFileView) replacementAncestor(path string) (string, bool) { + if v == nil || !v.hasOverlay() { + return "", false + } + for parent := filepath.Dir(path); parent != path; parent = filepath.Dir(parent) { + if actual, ok := v.replacements[parent]; ok { + return actual, true + } + if parent == filepath.Dir(parent) { + break + } + } + return "", false +} + +// hasReplacementChild reports whether path has a visible overlay child. +// Replacement destinations remain opaque until Go reads them. +func (v *graphFileView) hasReplacementChild(path string) bool { + if !v.hasOverlay() { + return false + } + for child, actual := range v.replacements { + if actual != "" && child != path && pathWithin(path, child) { + return true + } + } + return false +} + +func (v *graphFileView) directoryVisible(dir string) (bool, error) { + if !v.hasOverlay() { + return statVisible(dir, os.FileMode.IsDir) + } + logical := v.logicalPath(dir) + if _, exact := v.replacements[logical]; exact { + return false, nil + } + ancestor, hasAncestor := v.replacementAncestor(logical) + if hasAncestor && ancestor != "" { + return false, nil + } + if v.hasReplacementChild(logical) { + return true, nil + } + return statVisible(logical, os.FileMode.IsDir) +} + +func (v *graphFileView) physicalEntries(dir string) ([]os.DirEntry, error) { + if !v.hasOverlay() { + return os.ReadDir(dir) + } + logical := v.logicalPath(dir) + if _, exact := v.replacements[logical]; exact { + return nil, nil + } + if _, hasAncestor := v.replacementAncestor(logical); hasAncestor { + return nil, nil + } + entries, err := os.ReadDir(logical) + if os.IsNotExist(err) && v.hasReplacementChild(logical) { + return nil, nil + } + return entries, err +} + +func (v *graphFileView) regularFileVisible(path string) (bool, error) { + if !v.hasOverlay() { + return statVisible(path, os.FileMode.IsRegular) + } + logical := v.logicalPath(path) + if actual, exact := v.replacements[logical]; exact { + if actual == "" { + return false, nil + } + return true, nil + } + if _, hidden := v.replacementAncestor(logical); hidden { + return false, nil + } + return statVisible(logical, os.FileMode.IsRegular) +} + +// canonicalFile returns a physical path or an overlay-only logical path. +func (v *graphFileView) canonicalFile(path string) (string, error) { + if !v.hasOverlay() { + return canonicalExistingFile(path) + } + logical := v.logicalPath(path) + if actual, exact := v.replacements[logical]; exact { + if actual == "" { + return "", &os.PathError{Op: "stat", Path: logical, Err: os.ErrNotExist} + } + return logical, nil + } + if _, hidden := v.replacementAncestor(logical); hidden { + return "", &os.PathError{Op: "stat", Path: logical, Err: os.ErrNotExist} + } + return canonicalExistingFile(logical) +} + +// canonicalDir is the directory counterpart to canonicalFile. +// Synthetic directories are safe because overlay-backed drivers never execute. +func (v *graphFileView) canonicalDir(path string) (string, error) { + if !v.hasOverlay() { + return canonicalExistingDir(path) + } + logical := v.logicalPath(path) + visible, err := v.directoryVisible(logical) + if err != nil { + return "", err + } + if !visible { + return "", &os.PathError{Op: "stat", Path: logical, Err: os.ErrNotExist} + } + if v.hasReplacementChild(logical) { + return logical, nil + } + return canonicalExistingDir(logical) +} diff --git a/cmd/internal/projectdriver/graph_package.go b/cmd/internal/projectdriver/graph_package.go new file mode 100644 index 000000000..4d56b8325 --- /dev/null +++ b/cmd/internal/projectdriver/graph_package.go @@ -0,0 +1,210 @@ +/* + * 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 projectdriver + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" +) + +type goListPackage struct { + Dir string + ImportPath string + Name string + Module *goListModule + Error *struct { + Err string + } +} + +func resolvePackageDirectory(ctx context.Context, graph *effectiveGraph, importPath, workDir string, policy GraphPolicy) (string, ResolvedModule, error) { + pkg, err := listPackageTarget(ctx, importPath, workDir, policy) + if err != nil { + return "", ResolvedModule{}, err + } + if pkg.ImportPath != importPath { + return "", ResolvedModule{}, fmt.Errorf("package target %q resolved as %q", importPath, pkg.ImportPath) + } + if pkg.Dir == "" || pkg.Module == nil { + // XGo-only packages may omit physical fields; resolve them from the graph. + return resolveXGoOnlyPackageDirectory(ctx, graph, importPath, policy) + } + listed, err := normalizeListedModule(*pkg.Module) + if err != nil { + return "", ResolvedModule{}, fmt.Errorf("package target %q: %w", importPath, err) + } + module, ok := graph.Modules[listed.Selected.Path] + if !ok { + return "", ResolvedModule{}, fmt.Errorf("package target %q does not match the effective module graph", importPath) + } + dir, err := graph.files.canonicalDir(pkg.Dir) + if err != nil { + return "", ResolvedModule{}, fmt.Errorf("package target %q: %w", importPath, err) + } + if module.Effective().Dir == "" { + // Keep unmarked dependency identity without materializing its source. + return dir, module, nil + } + if !listed.Equal(module) { + return "", ResolvedModule{}, fmt.Errorf("package target %q does not match the effective module graph", importPath) + } + if err := validatePackagePath(module, importPath, dir); err != nil { + return "", ResolvedModule{}, err + } + return dir, module, nil +} + +func validatePackagePath(module ResolvedModule, importPath, dir string) error { + if !pathWithin(module.Effective().Dir, dir) { + return fmt.Errorf("package target %q escapes module %q", importPath, module.Selected.Path) + } + return nil +} + +func validatePackageOwnership(ctx context.Context, policy GraphPolicy, module ResolvedModule, importPath, dir string) error { + ownerGoMod, err := goModForDir(ctx, policy, dir) + if err != nil { + return fmt.Errorf("resolve package target %q module ownership: %w", importPath, err) + } + if ownerGoMod == "" || ownerGoMod == os.DevNull { + return fmt.Errorf("package target %q has no module ownership", importPath) + } + ownerRoot, err := canonicalExistingDir(filepath.Dir(ownerGoMod)) + if err != nil { + return fmt.Errorf("resolve package target %q module ownership: %w", importPath, err) + } + same, err := sameFile(ownerRoot, module.Effective().Dir) + if err != nil { + return fmt.Errorf("resolve package target %q module ownership: %w", importPath, err) + } + if !same { + return fmt.Errorf("package target %q crosses a nested module boundary", importPath) + } + return nil +} + +func moduleOwnsPackage(ctx context.Context, policy GraphPolicy, moduleGoMod, dir string) (bool, error) { + ownerGoMod, err := goModForDir(ctx, policy, dir) + if err != nil { + return false, err + } + if ownerGoMod == "" || ownerGoMod == os.DevNull { + return false, nil + } + return sameFile(moduleGoMod, ownerGoMod) +} + +func listPackageTarget(ctx context.Context, importPath, workDir string, policy GraphPolicy) (goListPackage, error) { + if importPath == "" || strings.Contains(importPath, "@") || strings.Contains(importPath, "...") { + return goListPackage{}, fmt.Errorf("driver does not support package target %q", importPath) + } + stdout, _, err := runGraphCommand(ctx, policy, workDir, "resolve package target "+importPath, policy.goArgs("list", "-e", "-find", "-json", importPath)...) + if err != nil { + return goListPackage{}, err + } + dec := json.NewDecoder(bytes.NewReader(stdout)) + var pkg goListPackage + if err := dec.Decode(&pkg); err != nil { + return goListPackage{}, fmt.Errorf("decode package target %q: %w", importPath, err) + } + var extra json.RawMessage + if err := dec.Decode(&extra); !errors.Is(err, io.EOF) { + if err == nil { + return goListPackage{}, fmt.Errorf("package target %q resolved to multiple packages", importPath) + } + return goListPackage{}, fmt.Errorf("decode package target %q: %w", importPath, err) + } + return pkg, nil +} + +func resolveXGoOnlyPackageDirectory(ctx context.Context, graph *effectiveGraph, importPath string, policy GraphPolicy) (string, ResolvedModule, error) { + paths := make([]string, 0, len(graph.Modules)) + for path := range graph.Modules { + paths = append(paths, path) + } + sort.Slice(paths, func(i, j int) bool { return len(paths[i]) > len(paths[j]) }) + for _, modulePath := range paths { + if !moduleContainsPackage(modulePath, importPath) { + continue + } + module := graph.Modules[modulePath] + root := module.Effective().Dir + if root == "" { + return "", ResolvedModule{}, fmt.Errorf("package target %q has no materialized module source", importPath) + } + suffix := strings.TrimPrefix(importPath, modulePath) + candidate := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(suffix, "/"))) + dir, err := graph.files.canonicalDir(candidate) + if err != nil { + return "", ResolvedModule{}, fmt.Errorf("package target %q: %w", importPath, err) + } + if err := validatePackagePath(module, importPath, dir); err != nil { + return "", ResolvedModule{}, err + } + // Overlay-only packages have no physical cwd; the graph owns their identity. + if graph.files != nil && graph.files.hasReplacementChild(dir) { + return dir, module, nil + } + if err := validatePackageOwnership(ctx, policy, module, importPath, dir); err != nil { + return "", ResolvedModule{}, err + } + return dir, module, nil + } + return "", ResolvedModule{}, fmt.Errorf("package target %q is outside the effective module graph", importPath) +} + +// retargetEffectiveGraph keeps the caller graph and changes only its target module. +func retargetEffectiveGraph(graph *effectiveGraph, target ResolvedModule) (*effectiveGraph, error) { + modfilePath := target.Effective().GoMod + if target.Selected.Path == graph.Target.Selected.Path { + modfilePath = graph.TargetModFile.Path + } + identity, classPaths, err := readTargetModFileView(modfilePath, graph.files) + if err != nil { + return nil, err + } + classModules := make([]ResolvedModule, 0, len(classPaths)) + for _, path := range classPaths { + module, ok := graph.Modules[path] + if !ok { + return nil, fmt.Errorf("class module %q is absent from the effective graph", path) + } + if module.Effective().Dir == "" || module.Effective().GoMod == "" { + return nil, fmt.Errorf("class module %q has no materialized effective source", path) + } + classModules = append(classModules, module) + } + return &effectiveGraph{ + Target: target, + Modules: graph.Modules, + ClassModules: classModules, + TargetModFile: identity, + files: graph.files, + }, nil +} + +func moduleContainsPackage(modulePath, packagePath string) bool { + return packagePath == modulePath || strings.HasPrefix(packagePath, modulePath+"/") +} diff --git a/cmd/internal/projectdriver/graph_paths.go b/cmd/internal/projectdriver/graph_paths.go new file mode 100644 index 000000000..d794d1514 --- /dev/null +++ b/cmd/internal/projectdriver/graph_paths.go @@ -0,0 +1,71 @@ +/* + * 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 projectdriver + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +func canonicalExistingDir(path string) (string, error) { + canonical, err := canonicalExistingPath(path) + if err != nil { + return "", err + } + info, err := os.Stat(canonical) + if err != nil { + return "", err + } + if !info.IsDir() { + return "", fmt.Errorf("%q is not a directory", path) + } + return canonical, nil +} + +func canonicalExistingFile(path string) (string, error) { + canonical, err := canonicalExistingPath(path) + if err != nil { + return "", err + } + info, err := os.Lstat(canonical) + if err != nil { + return "", err + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return "", fmt.Errorf("%q is not a regular non-symlink file", path) + } + return canonical, nil +} + +func canonicalExistingPath(path string) (string, error) { + abs, err := filepath.Abs(path) + if err != nil { + return "", err + } + canonical, err := filepath.EvalSymlinks(filepath.Clean(abs)) + if err != nil { + return "", err + } + return canonical, nil +} + +func pathWithin(root, path string) bool { + rel, err := filepath.Rel(root, path) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel) +} diff --git a/cmd/internal/projectdriver/graph_test.go b/cmd/internal/projectdriver/graph_test.go new file mode 100644 index 000000000..3ed311458 --- /dev/null +++ b/cmd/internal/projectdriver/graph_test.go @@ -0,0 +1,414 @@ +/* + * 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 projectdriver + +import ( + "context" + "os" + "path/filepath" + "reflect" + "runtime" + "sort" + "strings" + "testing" +) + +func TestGraphFileViewReadsOverlayReplacement(t *testing.T) { + root := t.TempDir() + logical := filepath.Join(root, "go.mod") + replacement := filepath.Join(root, "draft.mod") + mustWriteFile(t, logical, "module example.test/plain\n\ngo 1.25\n") + mustWriteFile(t, replacement, "module example.test/overlay\n\ngo 1.25\n") + overlay := mustOverlay(t, root, map[string]string{logical: replacement}) + view, err := newGraphFileView(GraphPolicy{Overlay: overlay}, root) + if err != nil { + t.Fatal(err) + } + got, err := view.readFile(logical) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(got), "module example.test/overlay") { + t.Fatalf("overlay read = %q", got) + } +} + +func TestGraphFileViewSynthesizesOverlayParentDirectories(t *testing.T) { + root := t.TempDir() + actual := filepath.Join(root, "actual.foo") + logical := filepath.Join(root, "virtual", "nested", "main.foo") + mustWriteFile(t, actual, "overlay project\n") + overlay := mustOverlay(t, root, map[string]string{logical: actual}) + view, err := newGraphFileView(GraphPolicy{Overlay: overlay}, root) + if err != nil { + t.Fatal(err) + } + names, err := view.regularFileNames(filepath.Join(root, "virtual", "nested")) + if err != nil { + t.Fatalf("regularFileNames() = %v", err) + } + if !reflect.DeepEqual(names, []string{"main.foo"}) { + t.Fatalf("regularFileNames() = %#v, want [main.foo]", names) + } + dir, err := view.canonicalDir(filepath.Join(root, "virtual", "nested")) + if err != nil || dir != filepath.Join(root, "virtual", "nested") { + t.Fatalf("canonicalDir() = %q, %v", dir, err) + } +} + +func TestGraphFileViewDeletedParentAllowsAddedChild(t *testing.T) { + root := t.TempDir() + parent := filepath.Join(root, "parent") + actual := filepath.Join(root, "replacement.foo") + logical := filepath.Join(parent, "child", "main.foo") + mustMkdirAll(t, parent) + mustWriteFile(t, filepath.Join(parent, "old.foo"), "old\n") + mustWriteFile(t, actual, "new\n") + overlay := mustOverlay(t, root, map[string]string{parent: "", logical: actual}) + view, err := newGraphFileView(GraphPolicy{Overlay: overlay}, root) + if err != nil { + t.Fatal(err) + } + got, err := view.readFile(logical) + if err != nil || string(got) != "new\n" { + t.Fatalf("readFile() = %q, %v", got, err) + } + names, err := view.regularFileNames(filepath.Join(parent, "child")) + if err != nil { + t.Fatalf("regularFileNames() = %v", err) + } + if !reflect.DeepEqual(names, []string{"main.foo"}) { + t.Fatalf("regularFileNames() = %#v, want [main.foo]", names) + } +} + +func TestGraphFileViewDoesNotInspectReplacementDestinations(t *testing.T) { + root := t.TempDir() + project := filepath.Join(root, "project") + mustMkdirAll(t, project) + missing := filepath.Join(root, "missing.foo") + directory := filepath.Join(root, "replacement-dir") + mustMkdirAll(t, directory) + replacements := map[string]string{ + filepath.Join(project, "missing.foo"): missing, + filepath.Join(project, "directory.foo"): directory, + } + if runtime.GOOS != "windows" { + target := filepath.Join(root, "target.foo") + mustWriteFile(t, target, "target\n") + symlink := filepath.Join(root, "symlink.foo") + if err := os.Symlink(target, symlink); err != nil { + t.Fatal(err) + } + replacements[filepath.Join(project, "symlink.foo")] = symlink + } + overlay := mustOverlay(t, root, replacements) + view, err := newGraphFileView(GraphPolicy{Overlay: overlay}, root) + if err != nil { + t.Fatal(err) + } + names, err := view.regularFileNames(project) + if err != nil { + t.Fatalf("regularFileNames() = %v", err) + } + want := []string{"directory.foo", "missing.foo"} + if runtime.GOOS != "windows" { + want = append(want, "symlink.foo") + } + sort.Strings(want) + if !reflect.DeepEqual(names, want) { + t.Fatalf("regularFileNames() = %#v, want %#v", names, want) + } +} + +func TestGraphFileViewAnchorsRelativeDirectoryNames(t *testing.T) { + root := t.TempDir() + mustMkdirAll(t, filepath.Join(root, "project", "game")) + view, err := newGraphFileView(GraphPolicy{}, root) + if err != nil { + t.Fatal(err) + } + names, err := view.directoryNames("project") + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(names, []string{"game"}) { + t.Fatalf("directoryNames() = %#v, want [game]", names) + } +} + +func TestGraphEnvironmentClearsAmbientGOFLAGS(t *testing.T) { + base := []string{"GOFLAGS=-tags=ambient", "GOWORK=/old/work", "PATH=/bin"} + env := graphEnvironment(base, "off") + if got, ok := environmentValue(env, "GOFLAGS"); !ok || got != "" { + t.Fatalf("GOFLAGS = %q, %t; want cleared", got, ok) + } + if got, ok := environmentValue(env, "GOWORK"); !ok || got != "off" { + t.Fatalf("GOWORK = %q, %t; want off", got, ok) + } +} + +func TestReadTargetModFileViewAllowsOverlayOnlyModfile(t *testing.T) { + root := t.TempDir() + logical := filepath.Join(root, "go.mod") + actual := filepath.Join(root, "overlay.mod") + mustWriteFile(t, actual, "module example.test/overlay\n\ngo 1.25\n") + overlay := mustOverlay(t, root, map[string]string{logical: actual}) + view, err := newGraphFileView(GraphPolicy{Overlay: overlay}, root) + if err != nil { + t.Fatal(err) + } + identity, classes, err := readTargetModFileView(logical, view) + if err != nil { + t.Fatal(err) + } + if identity.Path != logical || len(identity.SHA256) != 64 || len(classes) != 0 { + t.Fatalf("overlay-only modfile = %#v, classes %#v", identity, classes) + } +} + +func TestLoadEffectiveGraphLocalReplace(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + t.Setenv("GOWORK", "off") + root := t.TempDir() + app := filepath.Join(root, "app") + framework := filepath.Join(root, "framework") + mustMkdirAll(t, filepath.Join(app, "game")) + mustMkdirAll(t, filepath.Join(framework, "cmd", "driver")) + mustWriteFile(t, filepath.Join(app, "go.mod"), `module example.test/app + +go 1.25 + +require example.test/framework v1.2.3 //xgo:class + +replace example.test/framework => ../framework +`) + mustModuleFile(t, filepath.Join(framework, "go.mod"), "example.test/framework") + mustWriteFile(t, filepath.Join(framework, "cmd", "driver", "main.go"), "package main\nfunc main() {}\n") + + policy := mustPolicies(t, app) + graph := mustGraph(t, filepath.Join(app, "game"), policy.graph) + if got, want := graph.Target.Selected.Path, "example.test/app"; got != want { + t.Fatalf("target = %q, want %q", got, want) + } + origin := graph.Modules["example.test/framework"] + if !reflect.DeepEqual(graph.ClassModules, []ResolvedModule{origin}) { + t.Fatalf("resolved class modules = %#v", graph.ClassModules) + } + if origin.Selected.Version != "v1.2.3" || origin.Selected.Dir != "" || origin.Replace == nil { + t.Fatalf("replacement was flattened: %#v", origin) + } + framework, err := canonicalExistingDir(framework) + if err != nil { + t.Fatal(err) + } + if origin.Replace.Dir != framework || origin.Replace.GoMod != filepath.Join(framework, "go.mod") { + t.Fatalf("replacement source = %#v", origin.Replace) + } + dir, module, err := resolvePackageDirectory(context.Background(), graph, "example.test/framework/cmd/driver", app, policy.graph) + if err != nil { + t.Fatal(err) + } + if dir != filepath.Join(framework, "cmd", "driver") || module.Selected.Path != "example.test/framework" { + t.Fatalf("resolved package = %q, %#v", dir, module) + } +} + +func TestResolvePackageDirectoryHonorsNestedModuleBoundary(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + t.Setenv("GOWORK", "off") + app := t.TempDir() + nested := filepath.Join(app, "nested") + mustMkdirAll(t, nested) + mustModuleFile(t, filepath.Join(app, "go.mod"), "example.test/app") + mustModuleFile(t, filepath.Join(nested, "go.mod"), "example.test/nested") + mustWriteFile(t, filepath.Join(nested, "main.go"), "package main\n") + policy := mustPolicies(t, app) + graph := mustGraph(t, app, policy.graph) + if _, _, err := resolvePackageDirectory(context.Background(), graph, "example.test/app/nested", app, policy.graph); err == nil { + t.Fatal("package path crossing a nested module boundary resolved successfully") + } +} + +func TestSanitizeGraphFlagsPropagatesFilesystemErrors(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("self-referential symlink setup is not portable to Windows") + } + dir := t.TempDir() + path := filepath.Join(dir, "loop.mod") + if err := os.Symlink("loop.mod", path); err != nil { + t.Fatal(err) + } + policy := parsedFlags{graph: GraphPolicy{ModFile: path}} + if _, err := sanitizeGraphFlags(policy); err == nil { + t.Fatal("graph input I/O failure was treated as a missing file") + } +} + +func TestLoadEffectiveGraphModfile(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + t.Setenv("GOWORK", "off") + dir := t.TempDir() + mustModuleFile(t, filepath.Join(dir, "go.mod"), "example.test/app") + mustModuleFile(t, filepath.Join(dir, "driver.mod"), "example.test/app") + policy := mustPolicies(t, dir, "-modfile=driver.mod") + graph := mustGraph(t, dir, policy.graph) + wantModfile, err := canonicalExistingFile(filepath.Join(dir, "driver.mod")) + if err != nil { + t.Fatal(err) + } + if graph.TargetModFile.Path != wantModfile { + t.Fatalf("target modfile = %q", graph.TargetModFile.Path) + } + owner, err := goModForDir(context.Background(), policy.graph, dir) + if err != nil { + t.Fatal(err) + } + wantOwner, err := canonicalExistingFile(filepath.Join(dir, "go.mod")) + if err != nil { + t.Fatal(err) + } + if owner != wantOwner { + t.Fatalf("owning go.mod = %q, want %q", owner, wantOwner) + } +} + +func TestReadTargetModFileClassMarkerBoundaries(t *testing.T) { + dir := t.TempDir() + goMod := filepath.Join(dir, "go.mod") + mustWriteFile(t, goMod, `module example.test/app + +go 1.25 + +require ( + example.test/second v1.0.0 //gop:class payload + example.test/classroom v1.0.0 //xgo:classroom + example.test/first v1.0.0 // xgo:class +) +`) + identity, paths, err := readTargetModFile(goMod) + if err != nil { + t.Fatal(err) + } + want := []string{"example.test/second", "example.test/first"} + if !reflect.DeepEqual(paths, want) { + t.Fatalf("class paths = %#v, want %#v", paths, want) + } + if identity.Path != canonicalFile(t, goMod) || len(identity.SHA256) != 64 { + t.Fatalf("identity = %#v", identity) + } +} + +func TestToXGoGraphPreservesResolvedClassModuleOrder(t *testing.T) { + second := ResolvedModule{Selected: ModuleRef{Path: "example.test/second"}} + first := ResolvedModule{Selected: ModuleRef{Path: "example.test/first"}} + graph := &effectiveGraph{ + Target: ResolvedModule{Selected: ModuleRef{Path: "example.test/app"}}, + Modules: map[string]ResolvedModule{}, + ClassModules: []ResolvedModule{second, first}, + } + got := toXGoGraph(graph) + if len(got.ClassModules) != 2 || got.ClassModules[0].Selected.Path != "example.test/second" || got.ClassModules[1].Selected.Path != "example.test/first" { + t.Fatalf("ClassModules = %#v", got.ClassModules) + } +} + +func TestRetargetEffectiveGraphPreservesClassModuleOrder(t *testing.T) { + root := t.TempDir() + app := filepath.Join(root, "app") + framework := filepath.Join(root, "framework") + firstDir := filepath.Join(root, "first") + secondDir := filepath.Join(root, "second") + for _, dir := range []string{app, framework, firstDir, secondDir} { + mustMkdirAll(t, dir) + } + appGoMod := filepath.Join(app, "go.mod") + frameworkGoMod := filepath.Join(framework, "go.mod") + firstGoMod := filepath.Join(firstDir, "go.mod") + secondGoMod := filepath.Join(secondDir, "go.mod") + mustModuleFile(t, appGoMod, "example.test/app") + mustWriteFile(t, frameworkGoMod, `module example.test/framework + +go 1.25 + +require ( + example.test/second v1.0.0 //xgo:class + example.test/first v1.0.0 //xgo:class +) +`) + mustModuleFile(t, firstGoMod, "example.test/first") + mustModuleFile(t, secondGoMod, "example.test/second") + + appModule := ResolvedModule{Selected: ModuleRef{Path: "example.test/app", Dir: app, GoMod: appGoMod}, Main: true} + frameworkModule := ResolvedModule{Selected: ModuleRef{Path: "example.test/framework", Dir: framework, GoMod: frameworkGoMod}} + firstModule := ResolvedModule{Selected: ModuleRef{Path: "example.test/first", Dir: firstDir, GoMod: firstGoMod}} + secondModule := ResolvedModule{Selected: ModuleRef{Path: "example.test/second", Dir: secondDir, GoMod: secondGoMod}} + graph := &effectiveGraph{ + Target: appModule, + Modules: map[string]ResolvedModule{ + "example.test/app": appModule, + "example.test/framework": frameworkModule, + "example.test/first": firstModule, + "example.test/second": secondModule, + }, + TargetModFile: fileIdentity{Path: canonicalFile(t, appGoMod)}, + } + + retargeted, err := retargetEffectiveGraph(graph, frameworkModule) + if err != nil { + t.Fatal(err) + } + want := []ResolvedModule{secondModule, firstModule} + if !reflect.DeepEqual(retargeted.ClassModules, want) { + t.Fatalf("resolved class modules = %#v, want %#v", retargeted.ClassModules, want) + } + if retargeted.TargetModFile.Path != canonicalFile(t, frameworkGoMod) { + t.Fatalf("target modfile = %q", retargeted.TargetModFile.Path) + } +} + +func TestPreparePoliciesIgnoresXGoGoCmd(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell fixture") + } + t.Setenv("XGO_GOCMD", filepath.Join(t.TempDir(), "does-not-exist")) + t.Setenv("GOWORK", "off") + policy := mustPolicies(t, t.TempDir()) + if filepath.Base(policy.graph.GoCommand) != "go" { + t.Fatalf("Go command = %q", policy.graph.GoCommand) + } +} + +func TestNormalizeListedModuleUsesResolvedValidation(t *testing.T) { + root := t.TempDir() + dir := filepath.Join(root, "source") + mustMkdirAll(t, dir) + goMod := filepath.Join(root, "external.mod") + mustWriteFile(t, goMod, "module example.test/mod\n") + _, err := normalizeListedModule(goListModule{ + Path: "example.test/mod", Version: "v1.2.3", Dir: dir, GoMod: goMod, + }) + if err == nil || !strings.Contains(err.Error(), "matching Go module-cache metadata") { + t.Fatalf("validation error = %v", err) + } +} diff --git a/cmd/internal/projectdriver/output.go b/cmd/internal/projectdriver/output.go new file mode 100644 index 000000000..b9602697c --- /dev/null +++ b/cmd/internal/projectdriver/output.go @@ -0,0 +1,57 @@ +/* + * 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 projectdriver + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" +) + +func resolveBuildOutput(cwd, requested, defaultName string) (string, error) { + if defaultName == "" { + return "", fmt.Errorf("empty driver executable name") + } + defaultName = executableName(defaultName) + path := requested + if path == "" { + path = filepath.Join(cwd, defaultName) + } else { + trailingSeparator := strings.HasSuffix(path, string(filepath.Separator)) || + (runtime.GOOS == "windows" && strings.HasSuffix(path, "/")) + if !filepath.IsAbs(path) { + path = filepath.Join(cwd, path) + } + if info, err := os.Stat(path); err == nil && info.IsDir() || trailingSeparator { + path = filepath.Join(path, defaultName) + } + } + abs, err := filepath.Abs(path) + if err != nil { + return "", err + } + return executableName(filepath.Clean(abs)), nil +} + +func executableName(path string) string { + if runtime.GOOS == "windows" && !strings.EqualFold(filepath.Ext(path), ".exe") { + return path + ".exe" + } + return path +} diff --git a/cmd/internal/projectdriver/output_commit.go b/cmd/internal/projectdriver/output_commit.go new file mode 100644 index 000000000..4692c4fde --- /dev/null +++ b/cmd/internal/projectdriver/output_commit.go @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package projectdriver + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "runtime" +) + +// commitContext validates and publishes staged output. +// Cancellation is checked immediately before the commit rename. +func (tx *outputTransaction) commitContext(ctx context.Context) error { + if tx == nil || tx.closed { + return fmt.Errorf("driver output transaction is closed") + } + if ctx == nil { + ctx = context.Background() + } + if cause := context.Cause(ctx); cause != nil { + return cause + } + if err := tx.checkParentPath(); err != nil { + return err + } + workInfo, err := tx.parent.Lstat(tx.workName) + if err != nil { + return fmt.Errorf("inspect driver output work directory: %w", err) + } + if workInfo.Mode()&os.ModeSymlink != 0 || !workInfo.IsDir() || !os.SameFile(workInfo, tx.workIdentity) { + return fmt.Errorf("driver output work directory changed during driver execution") + } + entries, err := fs.ReadDir(tx.parent.FS(), tx.workName) + if err != nil { + return err + } + if len(entries) != 1 || entries[0].Name() != tx.finalName { + return fmt.Errorf("driver must create exactly one staged output") + } + info, err := tx.parent.Lstat(tx.stagedName) + if err != nil { + return fmt.Errorf("driver output: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return fmt.Errorf("driver output %q is not a regular non-symlink file", tx.staged) + } + if info.Size() == 0 { + return fmt.Errorf("driver output %q is empty", tx.staged) + } + if runtime.GOOS != "windows" && info.Mode().Perm()&0111 == 0 { + return fmt.Errorf("driver output %q is not executable", tx.staged) + } + file, err := tx.parent.Open(tx.stagedName) + if err != nil { + return fmt.Errorf("open driver output: %w", err) + } + openedInfo, err := file.Stat() + if err != nil { + _ = file.Close() + return fmt.Errorf("inspect open driver output: %w", err) + } + if !os.SameFile(info, openedInfo) || !openedInfo.Mode().IsRegular() { + _ = file.Close() + return fmt.Errorf("driver output changed while it was opened") + } + if err := file.Sync(); err != nil { + _ = file.Close() + return fmt.Errorf("sync driver output: %w", err) + } + if err := validateHostExecutable(file, tx.staged); err != nil { + _ = file.Close() + return err + } + if err := file.Close(); err != nil { + return fmt.Errorf("close driver output: %w", err) + } + currentInfo, err := tx.parent.Lstat(tx.stagedName) + if err != nil || !os.SameFile(openedInfo, currentInfo) { + return fmt.Errorf("driver output changed after validation") + } + if err := validateExistingFinal(tx.parent, tx.finalName, tx.final); err != nil { + return err + } + if finalInfo, err := tx.parent.Lstat(tx.finalName); err == nil && os.SameFile(currentInfo, finalInfo) { + return fmt.Errorf("driver output aliases existing final output") + } else if err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("reinspect driver output %q: %w", tx.final, err) + } + // Recheck the user-visible parent after driver execution. + if err := tx.checkParentPath(); err != nil { + return err + } + if cause := context.Cause(ctx); cause != nil { + return cause + } + if err := tx.parent.Rename(tx.stagedName, tx.finalName); err != nil { + state := "absent" + if info, statErr := tx.parent.Lstat(tx.finalName); statErr == nil { + state = info.Mode().String() + } else if !errors.Is(statErr, fs.ErrNotExist) { + state = statErr.Error() + } + return fmt.Errorf("commit driver output (final state %s): %w", state, err) + } + // Rename is the commit point; later checks are diagnostic only. + _ = tx.checkParentPath() + // Cleanup and directory syncing are best effort after publication. + if !tx.keepDir { + _ = tx.parent.Remove(tx.workName) + } + syncOutputParent(tx.parent) + tx.closed = true + _ = tx.parent.Close() + return nil +} + +func (tx *outputTransaction) checkParentPath() error { + pinned, err := tx.parent.Stat(".") + if err != nil { + return fmt.Errorf("inspect pinned driver output parent: %w", err) + } + current, err := os.Stat(tx.parentPath) + if err != nil { + return fmt.Errorf("revalidate driver output parent %q: %w", tx.parentPath, err) + } + if !current.IsDir() || !os.SameFile(pinned, current) { + return fmt.Errorf("driver output parent %q changed during driver execution", tx.parentPath) + } + return nil +} + +func syncOutputParent(root *os.Root) { + dir, err := root.Open(".") + if err != nil { + return + } + _ = dir.Sync() + _ = dir.Close() +} diff --git a/cmd/internal/projectdriver/output_test.go b/cmd/internal/projectdriver/output_test.go new file mode 100644 index 000000000..11c6418d7 --- /dev/null +++ b/cmd/internal/projectdriver/output_test.go @@ -0,0 +1,350 @@ +/* + * 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 projectdriver + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" +) + +func TestOutputTransactionCommit(t *testing.T) { + dir := t.TempDir() + final := filepath.Join(dir, "game") + if runtime.GOOS == "windows" { + final += ".exe" + } + if err := os.WriteFile(final, []byte("old"), 0755); err != nil { + t.Fatal(err) + } + tx, err := beginOutputTransaction(final, false) + if err != nil { + t.Fatal(err) + } + defer tx.abort() + writeTestExecutable(t, tx.staged) + if err := tx.commitContext(context.Background()); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(final) + if err != nil { + t.Fatal(err) + } + if len(got) == 0 { + t.Fatal("final is empty") + } + if _, err := os.Stat(tx.dir); !os.IsNotExist(err) { + t.Fatalf("staging remains: %v", err) + } +} + +func writeTestExecutable(t *testing.T, target string) { + t.Helper() + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(self) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target, data, 0755); err != nil { + t.Fatal(err) + } + if runtime.GOOS == "darwin" { + if output, err := exec.Command("/usr/bin/codesign", "--force", "--sign", "-", target).CombinedOutput(); err != nil { + t.Fatalf("sign test executable: %v: %s", err, output) + } + } +} + +func TestOutputTransactionFailurePreservesFinal(t *testing.T) { + dir := t.TempDir() + final := filepath.Join(dir, "game") + if err := os.WriteFile(final, []byte("old"), 0755); err != nil { + t.Fatal(err) + } + tx, err := beginOutputTransaction(final, false) + if err != nil { + t.Fatal(err) + } + defer tx.abort() + if err := os.WriteFile(tx.staged, nil, 0755); err != nil { + t.Fatal(err) + } + if err := tx.commitContext(context.Background()); err == nil { + t.Fatal("empty staged output committed") + } + tx.abort() + got, err := os.ReadFile(final) + if err != nil || string(got) != "old" { + t.Fatalf("final changed: %q, %v", got, err) + } + if _, err := os.Stat(tx.dir); !os.IsNotExist(err) { + t.Fatalf("failed transaction left staging directory: %v", err) + } +} + +func TestCanceledBuildDoesNotCommitOutput(t *testing.T) { + dir := t.TempDir() + final := filepath.Join(dir, executableName("game")) + if err := os.WriteFile(final, []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + tx, err := beginOutputTransaction(final, false) + if err != nil { + t.Fatal(err) + } + defer tx.abort() + writeTestExecutable(t, tx.staged) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := tx.commitContext(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("commitContext() = %v, want context cancellation", err) + } + got, err := os.ReadFile(final) + if err != nil || string(got) != "old" { + t.Fatalf("canceled commit changed final output: %q, %v", got, err) + } +} + +func TestOutputTransactionRejectsSymlinksAndExtras(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink privileges vary on Windows") + } + dir := t.TempDir() + target := filepath.Join(dir, "target") + if err := os.WriteFile(target, []byte("old"), 0755); err != nil { + t.Fatal(err) + } + link := filepath.Join(dir, "link") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + if _, err := beginOutputTransaction(link, false); err == nil { + t.Fatal("symlink final accepted") + } + + final := filepath.Join(dir, "game") + tx, err := beginOutputTransaction(final, false) + if err != nil { + t.Fatal(err) + } + defer tx.abort() + if err := os.WriteFile(tx.staged, []byte("new"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tx.dir, "extra"), []byte("extra"), 0600); err != nil { + t.Fatal(err) + } + if err := tx.commitContext(context.Background()); err == nil { + t.Fatal("extra staged output accepted") + } + if _, err := os.Stat(final); !os.IsNotExist(err) { + t.Fatalf("final unexpectedly exists: %v", err) + } +} + +func TestOutputTransactionRejectsFinalSymlinkCreatedAfterBegin(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink privileges vary on Windows") + } + dir := t.TempDir() + final := filepath.Join(dir, "game") + tx, err := beginOutputTransaction(final, false) + if err != nil { + t.Fatal(err) + } + defer tx.abort() + writeTestExecutable(t, tx.staged) + target := filepath.Join(dir, "target") + if err := os.WriteFile(target, []byte("target"), 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, final); err != nil { + t.Fatal(err) + } + if err := tx.commitContext(context.Background()); err == nil { + t.Fatal("final symlink created after begin was accepted") + } + if got, err := os.ReadFile(target); err != nil || string(got) != "target" { + t.Fatalf("symlink target changed: %q, %v", got, err) + } +} + +func TestOutputTransactionPinsParentAcrossPathSwap(t *testing.T) { + base := t.TempDir() + parent := filepath.Join(base, "out") + if err := os.Mkdir(parent, 0700); err != nil { + t.Fatal(err) + } + final := filepath.Join(parent, "game") + if runtime.GOOS == "windows" { + final += ".exe" + } + if err := os.WriteFile(final, []byte("old"), 0755); err != nil { + t.Fatal(err) + } + tx, err := beginOutputTransaction(final, false) + if err != nil { + t.Fatal(err) + } + defer tx.abort() + writeTestExecutable(t, tx.staged) + + moved := filepath.Join(base, "moved") + if err := os.Rename(parent, moved); err != nil { + t.Skipf("platform/filesystem cannot rename an open pinned directory: %v", err) + } + if err := os.Mkdir(parent, 0700); err != nil { + t.Fatal(err) + } + replacementFinal := filepath.Join(parent, filepath.Base(final)) + if err := os.WriteFile(replacementFinal, []byte("replacement"), 0600); err != nil { + t.Fatal(err) + } + + commitErr := tx.commitContext(context.Background()) + if commitErr == nil { + t.Fatal("commit accepted a replaced output parent pathname") + } + if got, err := os.ReadFile(replacementFinal); err != nil || string(got) != "replacement" { + t.Fatalf("replacement parent was modified: %q, %v", got, err) + } + movedFinal := filepath.Join(moved, filepath.Base(final)) + got, err := os.ReadFile(movedFinal) + if err != nil { + t.Fatal(err) + } + if string(got) != "old" { + t.Fatalf("failed commit changed pinned output: %q (%v)", got, commitErr) + } +} + +func TestOutputTransactionRejectsWorkDirectorySwap(t *testing.T) { + dir := t.TempDir() + final := filepath.Join(dir, "game") + if runtime.GOOS == "windows" { + final += ".exe" + } + tx, err := beginOutputTransaction(final, false) + if err != nil { + t.Fatal(err) + } + defer tx.abort() + moved := tx.dir + ".moved" + if err := os.Rename(tx.dir, moved); err != nil { + t.Skipf("platform/filesystem cannot rename an open work directory: %v", err) + } + if err := os.Mkdir(tx.dir, 0700); err != nil { + t.Fatal(err) + } + writeTestExecutable(t, tx.staged) + if err := tx.commitContext(context.Background()); err == nil { + t.Fatal("replaced work directory was accepted") + } + if _, err := os.Stat(final); !os.IsNotExist(err) { + t.Fatalf("failed work-directory transaction published output: %v", err) + } +} + +func TestOutputTransactionSupportsStableParentAlias(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink privileges vary on Windows") + } + base := t.TempDir() + parent := filepath.Join(base, "real") + if err := os.Mkdir(parent, 0700); err != nil { + t.Fatal(err) + } + alias := filepath.Join(base, "alias") + if err := os.Symlink(parent, alias); err != nil { + t.Fatal(err) + } + tx, err := beginOutputTransaction(filepath.Join(alias, "game"), false) + if err != nil { + t.Fatal(err) + } + defer tx.abort() + writeTestExecutable(t, tx.staged) + if err := tx.commitContext(context.Background()); err != nil { + t.Fatal(err) + } + if info, err := os.Stat(filepath.Join(parent, "game")); err != nil || info.Size() == 0 { + t.Fatalf("aliased output = %#v, %v", info, err) + } +} + +func TestOutputTransactionWindowsCaseAlias(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows case-insensitive output semantics") + } + dir := t.TempDir() + existing := filepath.Join(dir, "game.exe") + if err := os.WriteFile(existing, []byte("old"), 0755); err != nil { + t.Fatal(err) + } + tx, err := beginOutputTransaction(filepath.Join(dir, "GAME.EXE"), false) + if err != nil { + t.Fatal(err) + } + defer tx.abort() + writeTestExecutable(t, tx.staged) + if err := tx.commitContext(context.Background()); err != nil { + t.Fatal(err) + } + info, err := os.Stat(existing) + if err != nil || info.Size() <= int64(len("old")) { + t.Fatalf("case-aliased output = %#v, %v", info, err) + } +} + +func TestResolveBuildOutput(t *testing.T) { + dir := t.TempDir() + got, err := resolveBuildOutput(dir, "", "game") + if err != nil { + t.Fatal(err) + } + want := filepath.Join(dir, executableName("game")) + if got != want { + t.Fatalf("output = %q, want %q", got, want) + } + got, err = resolveBuildOutput(dir, dir+string(filepath.Separator), "game") + if err != nil || got != want { + t.Fatalf("directory output = %q, %v", got, err) + } + + got, err = resolveBuildOutput(dir, "relative/bin/game", "default") + want = filepath.Join(dir, "relative", "bin", "game") + if err != nil || got != want { + t.Fatalf("relative output = %q, %v; want %q", got, err, want) + } + + relativeDir := filepath.Join(dir, "relative-dir") + if err := os.Mkdir(relativeDir, 0755); err != nil { + t.Fatal(err) + } + got, err = resolveBuildOutput(dir, filepath.Join("relative-dir", ""), "default") + want = filepath.Join(relativeDir, executableName("default")) + if err != nil || got != want { + t.Fatalf("relative directory output = %q, %v; want %q", got, err, want) + } +} diff --git a/cmd/internal/projectdriver/output_transaction.go b/cmd/internal/projectdriver/output_transaction.go new file mode 100644 index 000000000..b160bb887 --- /dev/null +++ b/cmd/internal/projectdriver/output_transaction.go @@ -0,0 +1,151 @@ +/* + * 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 projectdriver + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" +) + +type outputTransaction struct { + final string + parentPath string + dir string + staged string + parent *os.Root + finalName string + workName string + stagedName string + workIdentity os.FileInfo + keepDir bool + closed bool +} + +func beginOutputTransaction(final string, keepWork bool) (*outputTransaction, error) { + parent := filepath.Dir(final) + root, err := openPinnedOutputParent(parent) + if err != nil { + return nil, err + } + finalName := filepath.Base(final) + if err := validateExistingFinal(root, finalName, final); err != nil { + _ = root.Close() + return nil, err + } + workName, err := createOutputWorkDir(root) + if err != nil { + _ = root.Close() + return nil, err + } + workIdentity, err := root.Lstat(workName) + if err != nil { + _ = root.RemoveAll(workName) + _ = root.Close() + return nil, fmt.Errorf("inspect driver output work directory: %w", err) + } + stagedName := filepath.Join(workName, finalName) + return &outputTransaction{ + final: final, + parentPath: parent, + dir: filepath.Join(parent, workName), + staged: filepath.Join(parent, stagedName), + parent: root, + finalName: finalName, + workName: workName, + stagedName: stagedName, + workIdentity: workIdentity, + keepDir: keepWork, + }, nil +} + +func openPinnedOutputParent(parent string) (*os.Root, error) { + before, err := os.Stat(parent) + if err != nil { + return nil, fmt.Errorf("driver output parent: %w", err) + } + if !before.IsDir() { + return nil, fmt.Errorf("driver output parent %q is not a directory", parent) + } + root, err := os.OpenRoot(parent) + if err != nil { + return nil, fmt.Errorf("open driver output parent: %w", err) + } + pinned, err := root.Stat(".") + if err != nil { + _ = root.Close() + return nil, fmt.Errorf("inspect pinned driver output parent: %w", err) + } + after, err := os.Stat(parent) + if err != nil { + _ = root.Close() + return nil, fmt.Errorf("revalidate driver output parent: %w", err) + } + if !os.SameFile(before, pinned) || !os.SameFile(pinned, after) { + _ = root.Close() + return nil, fmt.Errorf("driver output parent %q changed while it was opened", parent) + } + return root, nil +} + +func createOutputWorkDir(root *os.Root) (string, error) { + var random [12]byte + for range 16 { + if _, err := rand.Read(random[:]); err != nil { + return "", fmt.Errorf("generate driver output work directory: %w", err) + } + name := ".xgo-driver-output-" + hex.EncodeToString(random[:]) + if err := root.Mkdir(name, 0700); err == nil { + return name, nil + } else if !errors.Is(err, fs.ErrExist) { + return "", fmt.Errorf("create driver output work directory: %w", err) + } + } + return "", fmt.Errorf("create driver output work directory: too many name collisions") +} + +func validateExistingFinal(root *os.Root, name, displayPath string) error { + info, err := root.Lstat(name) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("inspect driver output %q: %w", displayPath, err) + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("driver output %q is a symlink", displayPath) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("driver output %q is not a regular file", displayPath) + } + return nil +} + +func (tx *outputTransaction) abort() { + if tx == nil || tx.closed { + return + } + if !tx.keepDir { + _ = tx.parent.RemoveAll(tx.workName) + } + tx.closed = true + _ = tx.parent.Close() +} diff --git a/cmd/internal/projectdriver/process_unix.go b/cmd/internal/projectdriver/process_unix.go new file mode 100644 index 000000000..9ec506f9a --- /dev/null +++ b/cmd/internal/projectdriver/process_unix.go @@ -0,0 +1,160 @@ +//go:build !windows + +/* + * 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 projectdriver + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "syscall" + "time" +) + +const ( + driverProcessGracePeriod = 2 * time.Second + driverProcessKillWait = 2 * time.Second +) + +func runDriverProcess(ctx context.Context, cmd *exec.Cmd) (ProcessStatus, error) { + if err := ctx.Err(); err != nil { + return ProcessStatus{}, err + } + configureDriverProcessGroup(cmd) + if err := cmd.Start(); err != nil { + return ProcessStatus{}, err + } + pgid := cmd.Process.Pid + wait := make(chan error, 1) + go func() { + wait <- cmd.Wait() + }() + + select { + case err := <-wait: + if _, cleanupErr := stopDriverProcessGroup(pgid, syscall.SIGTERM, nil); cleanupErr != nil { + return ProcessStatus{}, cleanupErr + } + return driverExitStatus(ctx, err) + case <-ctx.Done(): + } + + initial := driverCancellationSignal(ctx) + err, cleanupErr := stopDriverProcessGroup(pgid, initial, wait) + if cleanupErr != nil { + return ProcessStatus{}, cleanupErr + } + return driverExitStatus(ctx, err) +} + +func configureDriverProcessGroup(cmd *exec.Cmd) { + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } else { + attr := *cmd.SysProcAttr + cmd.SysProcAttr = &attr + } + cmd.SysProcAttr.Setpgid = true + cmd.SysProcAttr.Pgid = 0 +} + +func driverCancellationSignal(ctx context.Context) syscall.Signal { + var cause driverSignalCause + if errors.As(context.Cause(ctx), &cause) && cause.signal != 0 { + return cause.signal + } + return syscall.SIGTERM +} + +// stopDriverProcessGroup allows the initial signal a bounded grace period, +// then kills the group and confirms that both its leader and descendants have +// gone. If wait is nil, the leader has already been reaped. +func stopDriverProcessGroup(pgid int, initial syscall.Signal, wait <-chan error) (error, error) { + state := driverProcessWait{wait: wait, leaderDone: wait == nil} + groupDone := !driverProcessGroupExists(pgid) + if state.leaderDone && groupDone { + return state.leaderErr, nil + } + var signalErr error + if err := signalDriverProcessGroup(pgid, initial); err != nil && !errors.Is(err, syscall.ESRCH) { + signalErr = fmt.Errorf("signal driver process group: %w", err) + } + + groupDone = state.waitForProcessGroup(pgid, driverProcessGracePeriod) + if state.leaderDone && groupDone { + return state.leaderErr, signalErr + } + if err := signalDriverProcessGroup(pgid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) { + signalErr = errors.Join(signalErr, fmt.Errorf("kill driver process group: %w", err)) + } + groupDone = state.waitForProcessGroup(pgid, driverProcessKillWait) + if !state.leaderDone { + return state.leaderErr, errors.Join(signalErr, fmt.Errorf("driver did not exit after SIGKILL")) + } + if !groupDone { + return state.leaderErr, errors.Join(signalErr, fmt.Errorf("driver descendants did not exit after SIGKILL")) + } + return state.leaderErr, signalErr +} + +type driverProcessWait struct { + wait <-chan error + leaderDone bool + leaderErr error +} + +func (s *driverProcessWait) waitForProcessGroup(pgid int, timeout time.Duration) bool { + timer := time.NewTimer(timeout) + defer timer.Stop() + poll := time.NewTicker(10 * time.Millisecond) + defer poll.Stop() + for { + groupDone := !driverProcessGroupExists(pgid) + if s.leaderDone && groupDone { + return true + } + select { + case err := <-s.wait: + s.leaderDone = true + s.leaderErr = err + s.wait = nil + case <-poll.C: + case <-timer.C: + return !driverProcessGroupExists(pgid) + } + } +} + +func signalDriverProcessGroup(pgid int, sig syscall.Signal) error { + return syscall.Kill(-pgid, sig) +} + +func driverProcessGroupExists(pgid int) bool { + err := syscall.Kill(-pgid, 0) + return err == nil || errors.Is(err, syscall.EPERM) +} + +func exitSignal(err *exec.ExitError) (os.Signal, bool) { + status, ok := err.Sys().(syscall.WaitStatus) + if !ok || !status.Signaled() { + return nil, false + } + return status.Signal(), true +} diff --git a/cmd/internal/projectdriver/process_unix_test.go b/cmd/internal/projectdriver/process_unix_test.go new file mode 100644 index 000000000..13b28f82a --- /dev/null +++ b/cmd/internal/projectdriver/process_unix_test.go @@ -0,0 +1,282 @@ +//go:build !windows + +/* + * 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 projectdriver + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strconv" + "syscall" + "testing" + "time" +) + +const driverHelperEnv = "XGO_DRIVER_PROCESS_HELPER" + +type driverProcessResult struct { + status ProcessStatus + err error +} + +func TestRunDriverProcessForwardsDriverSignal(t *testing.T) { + dir := t.TempDir() + ready := filepath.Join(dir, "ready") + received := filepath.Join(dir, "received") + cmd := driverHelperCommand("handle", ready, received, strconv.Itoa(int(syscall.SIGHUP))) + boundary := beginDriverSignalBoundary(context.Background()) + result := make(chan driverProcessResult, 1) + go func() { + status, err := runDriverProcess(boundary.Context(), cmd) + result <- driverProcessResult{status: status, err: err} + }() + pid := waitForHelperPID(t, ready) + t.Cleanup(func() { _ = syscall.Kill(pid, syscall.SIGKILL) }) + + boundary.signals <- syscall.SIGHUP + got := waitForDriverProcessResult(t, result) + var cause driverSignalCause + if !errors.As(got.err, &cause) || cause.signal != syscall.SIGHUP { + t.Fatalf("runDriverProcess() = (%+v, %v), want SIGHUP cancellation cause", got.status, got.err) + } + status, err := boundary.Finish(got.status, got.err) + if err != nil || !status.Signaled || status.Signal != syscall.SIGHUP { + t.Fatalf("Finish(runDriverProcess()) = (%+v, %v), want SIGHUP status", status, err) + } + if signal := waitForHelperSignal(t, received); signal != syscall.SIGHUP { + t.Fatalf("driver received %v, want SIGHUP", signal) + } +} + +func TestRunDriverProcessReturnsCancellationAfterGracefulExit(t *testing.T) { + dir := t.TempDir() + ready := filepath.Join(dir, "ready") + received := filepath.Join(dir, "received") + cmd := driverHelperCommand("handle", ready, received, strconv.Itoa(int(syscall.SIGTERM))) + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan driverProcessResult, 1) + go func() { + status, err := runDriverProcess(ctx, cmd) + result <- driverProcessResult{status: status, err: err} + }() + pid := waitForHelperPID(t, ready) + t.Cleanup(func() { _ = syscall.Kill(pid, syscall.SIGKILL) }) + + cancel() + got := waitForDriverProcessResult(t, result) + if !errors.Is(got.err, context.Canceled) { + t.Fatalf("runDriverProcess() = (%+v, %v), want context cancellation", got.status, got.err) + } + if signal := waitForHelperSignal(t, received); signal != syscall.SIGTERM { + t.Fatalf("driver received %v, want SIGTERM", signal) + } +} + +func TestStatusUnlessCanceledRejectsSuccessfulExitAfterCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + status, err := statusUnlessCanceled(ctx, successStatus()) + if !errors.Is(err, context.Canceled) || status != (ProcessStatus{}) { + t.Fatalf("statusUnlessCanceled() = (%+v, %v), want cancellation", status, err) + } +} + +func TestRunDriverProcessEscalatesIgnoredCancellation(t *testing.T) { + dir := t.TempDir() + ready := filepath.Join(dir, "ready") + received := filepath.Join(dir, "received") + cmd := driverHelperCommand("resist", ready, received, strconv.Itoa(int(syscall.SIGTERM))) + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan driverProcessResult, 1) + go func() { + status, err := runDriverProcess(ctx, cmd) + result <- driverProcessResult{status: status, err: err} + }() + pid := waitForHelperPID(t, ready) + t.Cleanup(func() { _ = syscall.Kill(pid, syscall.SIGKILL) }) + + cancel() + got := waitForDriverProcessResult(t, result) + if got.err != nil || !got.status.Signaled || got.status.Signal != syscall.SIGKILL { + t.Fatalf("runDriverProcess() = (%+v, %v), want SIGKILL after grace period", got.status, got.err) + } + if signal := waitForHelperSignal(t, received); signal != syscall.SIGTERM { + t.Fatalf("driver received %v before escalation, want SIGTERM", signal) + } +} + +func TestRunDriverProcessCleansDescendantsAfterLeaderExit(t *testing.T) { + dir := t.TempDir() + childPID := filepath.Join(dir, "child-pid") + childReady := filepath.Join(dir, "child-ready") + childSignal := filepath.Join(dir, "child-signal") + cmd := driverHelperCommand("spawn-descendant", childPID, childReady, childSignal) + + status, err := runDriverProcess(context.Background(), cmd) + pid := waitForHelperPID(t, childPID) + t.Cleanup(func() { _ = syscall.Kill(pid, syscall.SIGKILL) }) + if err != nil || status.Signaled || status.Code != 0 { + t.Fatalf("runDriverProcess() = (%+v, %v), want successful leader status", status, err) + } + if signal := waitForHelperSignal(t, childSignal); signal != syscall.SIGTERM { + t.Fatalf("descendant received %v before cleanup escalation, want SIGTERM", signal) + } + if err := syscall.Kill(pid, 0); !errors.Is(err, syscall.ESRCH) { + t.Fatalf("same-group descendant %d still exists after driver return: %v", pid, err) + } +} + +func TestDriverProcessHelper(t *testing.T) { + if os.Getenv(driverHelperEnv) != "1" { + return + } + args := driverHelperArgs() + if len(args) == 0 { + t.Fatal("missing helper mode") + } + switch args[0] { + case "handle": + runDriverSignalHelper(t, args[1:], true) + case "resist": + runDriverSignalHelper(t, args[1:], false) + case "spawn-descendant": + if len(args) != 4 { + t.Fatalf("spawn-descendant args = %q", args) + } + child := driverHelperCommand("resist", args[2], args[3], strconv.Itoa(int(syscall.SIGTERM))) + if err := child.Start(); err != nil { + t.Fatal(err) + } + if _, err := waitForHelperFile(args[2], 5*time.Second); err != nil { + _ = child.Process.Kill() + t.Fatal(err) + } + if err := os.WriteFile(args[1], []byte(strconv.Itoa(child.Process.Pid)), 0o600); err != nil { + _ = child.Process.Kill() + t.Fatal(err) + } + default: + t.Fatalf("unknown helper mode %q", args[0]) + } +} + +func runDriverSignalHelper(t *testing.T, args []string, exitAfterSignal bool) { + if len(args) != 3 { + t.Fatalf("signal helper args = %q", args) + } + value, err := strconv.Atoi(args[2]) + if err != nil { + t.Fatal(err) + } + want := syscall.Signal(value) + signals := make(chan os.Signal, 8) + signal.Notify(signals, want) + defer signal.Stop(signals) + if err := os.WriteFile(args[0], []byte(strconv.Itoa(os.Getpid())), 0o600); err != nil { + t.Fatal(err) + } + for { + received := <-signals + unixSignal, ok := received.(syscall.Signal) + if !ok { + continue + } + if err := os.WriteFile(args[1], []byte(strconv.Itoa(int(unixSignal))), 0o600); err != nil { + t.Fatal(err) + } + if exitAfterSignal { + return + } + } +} + +func driverHelperCommand(args ...string) *exec.Cmd { + commandArgs := []string{"-test.run=^TestDriverProcessHelper$", "--"} + commandArgs = append(commandArgs, args...) + cmd := exec.Command(os.Args[0], commandArgs...) + cmd.Env = append(os.Environ(), driverHelperEnv+"=1") + return cmd +} + +func driverHelperArgs() []string { + for i, arg := range os.Args { + if arg == "--" { + return os.Args[i+1:] + } + } + return nil +} + +func waitForDriverProcessResult(t *testing.T, result <-chan driverProcessResult) driverProcessResult { + t.Helper() + select { + case got := <-result: + return got + case <-time.After(5 * time.Second): + t.Fatal("driver process did not return") + return driverProcessResult{} + } +} + +func waitForHelperPID(t *testing.T, path string) int { + t.Helper() + data, err := waitForHelperFile(path, 5*time.Second) + if err != nil { + t.Fatal(err) + } + pid, err := strconv.Atoi(string(data)) + if err != nil { + t.Fatalf("parse helper pid %q: %v", data, err) + } + return pid +} + +func waitForHelperSignal(t *testing.T, path string) syscall.Signal { + t.Helper() + data, err := waitForHelperFile(path, 5*time.Second) + if err != nil { + t.Fatal(err) + } + value, err := strconv.Atoi(string(data)) + if err != nil { + t.Fatalf("parse helper signal %q: %v", data, err) + } + return syscall.Signal(value) +} + +func waitForHelperFile(path string, timeout time.Duration) ([]byte, error) { + deadline := time.Now().Add(timeout) + for { + data, err := os.ReadFile(path) + if err == nil { + return data, nil + } + if !errors.Is(err, os.ErrNotExist) { + return nil, err + } + if time.Now().After(deadline) { + return nil, fmt.Errorf("timed out waiting for %s", filepath.Base(path)) + } + time.Sleep(10 * time.Millisecond) + } +} diff --git a/cmd/internal/projectdriver/process_windows.go b/cmd/internal/projectdriver/process_windows.go new file mode 100644 index 000000000..4f432dd9a --- /dev/null +++ b/cmd/internal/projectdriver/process_windows.go @@ -0,0 +1,140 @@ +//go:build windows + +/* + * 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 projectdriver + +import ( + "context" + "fmt" + "os" + "os/exec" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +var ntResumeProcess = windows.NewLazySystemDLL("ntdll.dll").NewProc("NtResumeProcess") + +func runDriverProcess(ctx context.Context, cmd *exec.Cmd) (ProcessStatus, error) { + if err := ctx.Err(); err != nil { + return ProcessStatus{}, err + } + job, err := windows.CreateJobObject(nil, nil) + if err != nil { + return ProcessStatus{}, err + } + defer windows.CloseHandle(job) + info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{} + info.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + if _, err := windows.SetInformationJobObject( + job, + windows.JobObjectExtendedLimitInformation, + uintptr(unsafe.Pointer(&info)), + uint32(unsafe.Sizeof(info)), + ); err != nil { + return ProcessStatus{}, err + } + configureSuspendedDriver(cmd) + if err := cmd.Start(); err != nil { + return ProcessStatus{}, err + } + // os/exec closes the primary thread handle before Start returns. Opening the + // still-suspended process and resuming it with NtResumeProcess lets us retain + // os/exec's exact argv/environment/stdio behavior without an execution window + // before Job assignment. + process, err := windows.OpenProcess( + windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE|windows.PROCESS_SUSPEND_RESUME, + false, + uint32(cmd.Process.Pid), + ) + if err != nil { + abortSuspendedDriver(cmd, 0) + return ProcessStatus{}, err + } + defer windows.CloseHandle(process) + if err := windows.AssignProcessToJobObject(job, process); err != nil { + abortSuspendedDriver(cmd, process) + return ProcessStatus{}, err + } + if err := ctx.Err(); err != nil { + terminateDriverJob(job, process) + _ = cmd.Wait() + return ProcessStatus{}, err + } + if err := resumeDriverProcess(process); err != nil { + terminateDriverJob(job, process) + _ = cmd.Wait() + return ProcessStatus{}, err + } + done := make(chan struct{}) + watchDone := make(chan struct{}) + go func() { + defer close(watchDone) + select { + case <-ctx.Done(): + terminateDriverJob(job, process) + case <-done: + } + }() + err = cmd.Wait() + close(done) + <-watchDone + // The process and cancellation watcher can become ready at the same time. + // Check the context even when the watcher selected the normal-exit branch. + return driverExitStatus(ctx, err) +} + +func configureSuspendedDriver(cmd *exec.Cmd) { + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } else { + attr := *cmd.SysProcAttr + cmd.SysProcAttr = &attr + } + cmd.SysProcAttr.CreationFlags |= windows.CREATE_SUSPENDED +} + +func resumeDriverProcess(process windows.Handle) error { + if err := ntResumeProcess.Find(); err != nil { + return fmt.Errorf("resolve NtResumeProcess: %w", err) + } + result, _, _ := ntResumeProcess.Call(uintptr(process)) + status := windows.NTStatus(uint32(result)) + if status != windows.STATUS_SUCCESS { + return fmt.Errorf("resume suspended driver: %w", status) + } + return nil +} + +func abortSuspendedDriver(cmd *exec.Cmd, process windows.Handle) { + if process != 0 { + _ = windows.TerminateProcess(process, 1) + } else { + _ = cmd.Process.Kill() + } + _ = cmd.Wait() +} + +func terminateDriverJob(job, process windows.Handle) { + if err := windows.TerminateJobObject(job, 1); err != nil && process != 0 { + _ = windows.TerminateProcess(process, 1) + } +} + +func exitSignal(*exec.ExitError) (os.Signal, bool) { return nil, false } diff --git a/cmd/internal/projectdriver/process_windows_test.go b/cmd/internal/projectdriver/process_windows_test.go new file mode 100644 index 000000000..c4c80a974 --- /dev/null +++ b/cmd/internal/projectdriver/process_windows_test.go @@ -0,0 +1,115 @@ +//go:build windows + +/* + * 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 projectdriver + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "unsafe" + + "golang.org/x/sys/windows" +) + +func TestConfigureSuspendedDriverPreservesFlags(t *testing.T) { + cmd := exec.Command("driver.exe") + cmd.SysProcAttr = &windows.SysProcAttr{CreationFlags: windows.CREATE_NO_WINDOW} + original := cmd.SysProcAttr + configureSuspendedDriver(cmd) + if cmd.SysProcAttr == original { + t.Fatal("configureSuspendedDriver mutated caller-owned SysProcAttr") + } + want := uint32(windows.CREATE_NO_WINDOW | windows.CREATE_SUSPENDED) + if got := cmd.SysProcAttr.CreationFlags; got != want { + t.Fatalf("creation flags = %#x, want %#x", got, want) + } +} + +func TestRunDriverProcessWindows(t *testing.T) { + if os.Getenv("XGO_TEST_WINDOWS_DRIVER_CHILD") == "1" { + inJob, err := currentProcessInJob() + if err != nil || !inJob { + fmt.Fprintf(os.Stderr, "job=%v err=%v\n", inJob, err) + os.Exit(91) + } + input, err := io.ReadAll(os.Stdin) + if err != nil { + os.Exit(92) + } + cwd, err := os.Getwd() + if err != nil { + os.Exit(93) + } + fmt.Printf("stdin=%s|env=%s|cwd=%s|args=%s", input, os.Getenv("XGO_TEST_VALUE"), filepath.Base(cwd), strings.Join(os.Args[len(os.Args)-2:], ",")) + fmt.Fprint(os.Stderr, "driver-stderr") + return + } + + work := t.TempDir() + cmd := exec.Command(os.Args[0], "-test.run=^TestRunDriverProcessWindows$", "--", "a b", `c"d`) + cmd.Dir = work + cmd.Env = append(os.Environ(), "XGO_TEST_WINDOWS_DRIVER_CHILD=1", "XGO_TEST_VALUE=present") + cmd.Stdin = strings.NewReader("driver-stdin") + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + status, err := runDriverProcess(context.Background(), cmd) + if err != nil { + t.Fatal(err) + } + if status.Code != 0 || status.Signaled { + t.Fatalf("status = %#v", status) + } + for _, want := range []string{"stdin=driver-stdin", "env=present", "cwd=" + filepath.Base(work), `args=a b,c"d`} { + if !strings.Contains(stdout.String(), want) { + t.Fatalf("stdout %q does not contain %q", stdout.String(), want) + } + } + if stderr.String() != "driver-stderr" { + t.Fatalf("stderr = %q", stderr.String()) + } +} + +func TestStatusUnlessCanceledRejectsSuccessfulExitAfterCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + status, err := statusUnlessCanceled(ctx, successStatus()) + if !errors.Is(err, context.Canceled) || status != (ProcessStatus{}) { + t.Fatalf("statusUnlessCanceled() = (%+v, %v), want cancellation", status, err) + } +} + +func currentProcessInJob() (bool, error) { + proc := windows.NewLazySystemDLL("kernel32.dll").NewProc("IsProcessInJob") + if err := proc.Find(); err != nil { + return false, err + } + var result int32 + ok, _, callErr := proc.Call(uintptr(windows.CurrentProcess()), 0, uintptr(unsafe.Pointer(&result))) + if ok == 0 { + return false, callErr + } + return result != 0, nil +} diff --git a/cmd/internal/projectdriver/protocol.go b/cmd/internal/projectdriver/protocol.go new file mode 100644 index 000000000..9429d5b12 --- /dev/null +++ b/cmd/internal/projectdriver/protocol.go @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package projectdriver + +import ( + "fmt" + "runtime" + "strings" + "unicode/utf16" + + "github.com/goplus/mod/driverprotocol" + "github.com/goplus/mod/xgomod" +) + +func driverArgs(drv *Driver, act action, policy BuildPolicy, output, finalOutput string, appArgs []string) ([]string, error) { + if drv.Protocol != protocolV1 { + return nil, fmt.Errorf("unsupported driver protocol %q", drv.Protocol) + } + var pack *driverprotocol.Pack + if drv.PackDir != "" || drv.PackIndex != "" { + if drv.PackDir == "" || drv.PackIndex == "" { + return nil, fmt.Errorf("driver pack metadata must contain both directory and index") + } + pack = &driverprotocol.Pack{Directory: drv.PackDir, IndexFile: drv.PackIndex} + } + request := driverprotocol.Request{ + Version: protocolV1, + Action: act, + Project: driverprotocol.Project{ + Dir: drv.ProjectDir, + File: drv.ProjectFile, + ModuleRoot: drv.ModuleRoot, + Extension: drv.ProjectExt, + FullExtension: drv.ProjectFullExt, + Pack: pack, + }, + DriverPackage: drv.DriverPackage, + DriverOrigin: drv.Origin, + Declaration: xgomod.FileIdentity{Path: drv.GoxMod, SHA256: drv.GoxModSHA256}, + Graph: driverprotocol.Graph{ + GoCommand: drv.Graph.GoCommand, + WorkDir: drv.Graph.WorkDir, + GoWork: drv.Graph.GoWork, + Flags: drv.Graph.goFlags(), + }, + BuildFlags: policy.protocolFlags(), + ApplicationArgs: append([]string(nil), appArgs...), + } + switch act { + case actionRun: + if output != "" || finalOutput != "" { + return nil, fmt.Errorf("run protocol cannot contain output paths") + } + case actionBuild: + if output == "" || finalOutput == "" { + return nil, fmt.Errorf("build protocol requires output paths") + } + if len(appArgs) != 0 { + return nil, fmt.Errorf("build protocol cannot contain application arguments") + } + request.Output = &driverprotocol.BuildOutput{Staging: output, Final: finalOutput} + default: + return nil, fmt.Errorf("unsupported driver action %q", act) + } + return driverprotocol.Encode(request) +} + +func validateArgv(executable string, args, env []string) error { + if runtime.GOOS == "windows" { + // CreateProcessW has a 32,767 UTF-16 code-unit command-line limit. Keep + // headroom for quoting performed by os/exec. + n := len(utf16.Encode([]rune(executable))) + 1 + for _, arg := range args { + // os/exec may quote the argument and double every backslash before + // a quote or the end quote. Two code units per input unit plus the + // surrounding syntax is a safe upper bound for CommandLineToArgvW. + n += 2*len(utf16.Encode([]rune(arg))) + 3 + } + if n > 30_000 { + return ErrDriverArgvTooLarge + } + envUnits := 1 + for _, item := range env { + envUnits += len(utf16.Encode([]rune(item))) + 1 + } + if envUnits > 32_767 { + return ErrDriverArgvTooLarge + } + return nil + } + // 128 KiB is below the smallest ARG_MAX supported by XGo's host set and + // accounts for both argv and the inherited environment. + n := len(executable) + 1 + for _, arg := range args { + n += len(arg) + 1 + } + for _, item := range env { + n += len(item) + 1 + } + // execve also consumes one native pointer per argv/env entry. Account for + // 64-bit pointers, the largest supported host representation. + n += 8 * (len(args) + len(env) + 3) + if n > 128<<10 { + return ErrDriverArgvTooLarge + } + return nil +} + +func redactCommand(executable string, args []string) string { + quoted := make([]string, 0, len(args)+1) + quoted = append(quoted, quoteForDisplay(executable)) + for _, arg := range args { + quoted = append(quoted, quoteForDisplay(arg)) + } + return strings.Join(quoted, " ") +} + +func quoteForDisplay(value string) string { + if value != "" && !strings.ContainsAny(value, " \t\r\n\"'") { + return value + } + return fmt.Sprintf("%q", value) +} diff --git a/cmd/internal/projectdriver/protocol_test.go b/cmd/internal/projectdriver/protocol_test.go new file mode 100644 index 000000000..299d5990d --- /dev/null +++ b/cmd/internal/projectdriver/protocol_test.go @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package projectdriver + +import ( + "errors" + "reflect" + "strings" + "testing" +) + +func testDriver() *Driver { + return &Driver{ + ProjectDir: "/project", + ProjectFile: "/project/main.foo", + ModuleRoot: "/project", + DriverPackage: "example.test/framework/cmd/driver", + Origin: ResolvedModule{ + Selected: ModuleRef{Path: "example.test/framework", Version: "v1.2.3"}, + Replace: &ModuleRef{ + Path: "/framework", Dir: "/framework", GoMod: "/framework/go.mod", + }, + }, + Protocol: "v1", + ProjectExt: ".foo", + ProjectFullExt: "*.foo", + PackDir: "payload", + PackIndex: "index.json", + GoxMod: "/framework/gox.mod", + GoxModSHA256: strings.Repeat("a", 64), + Graph: GraphPolicy{ + GoCommand: "/usr/bin/go", + WorkDir: "/project", + GoWork: "off", + ModMode: modModeMod, + ModFile: "/project/alt.mod", + }, + } +} + +func TestDriverArgsRun(t *testing.T) { + drv := testDriver() + got, err := driverArgs(drv, actionRun, BuildPolicy{TrimPath: true, Verbose: true, Trace: true, KeepWork: true}, "", "", []string{"", "a b", "--"}) + if err != nil { + t.Fatal(err) + } + want := []string{ + "xgo-driver-v1", + "run", + "--project-dir=/project", + "--project-file=/project/main.foo", + "--module-root=/project", + "--driver-package=example.test/framework/cmd/driver", + "--selected-path=example.test/framework", + "--selected-version=v1.2.3", + "--origin-main=false", + "--replace-path=/framework", + "--replace-version=", + "--replace-dir=/framework", + "--replace-gomod=/framework/go.mod", + "--project-ext=.foo", + "--project-full-ext=*.foo", + "--pack-dir=payload", + "--pack-index=index.json", + "--declaration-file=/framework/gox.mod", + "--declaration-sha256=" + strings.Repeat("a", 64), + "--go-command=/usr/bin/go", + "--graph-work-dir=/project", + "--go-work=off", + "--graph-flag=-mod=mod", + "--graph-flag=-modfile=/project/alt.mod", + "--build-flag=-v=true", + "--build-flag=-x=true", + "--build-flag=-work=true", + "--build-flag=-trimpath=true", + "--", "", "a b", "--", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("argv = %#v, want %#v", got, want) + } + joined := strings.Join(got, "\n") + if strings.Contains(joined, "selected-dir") || !strings.Contains(joined, "--replace-dir=/framework") { + t.Fatalf("replacement identity not preserved:\n%s", joined) + } +} + +func TestDriverArgsBuildSelected(t *testing.T) { + drv := testDriver() + drv.Origin.Replace = nil + drv.Origin.Selected.Dir = "/framework" + drv.Origin.Selected.GoMod = "/framework/go.mod" + got, err := driverArgs(drv, actionBuild, BuildPolicy{}, "/tmp/stage/game", "/out/game", nil) + if err != nil { + t.Fatal(err) + } + joined := strings.Join(got, "\n") + for _, want := range []string{"--selected-dir=/framework", "--selected-gomod=/framework/go.mod", "--output=/tmp/stage/game", "--final-output=/out/game"} { + if !strings.Contains(joined, want) { + t.Fatalf("missing %q in:\n%s", want, joined) + } + } + if strings.Contains(joined, "--replace-") || strings.Contains(joined, "\n--\n") { + t.Fatalf("invalid build argv:\n%s", joined) + } +} + +func TestDriverArgsInvalid(t *testing.T) { + drv := testDriver() + drv.Protocol = "v2" + if _, err := driverArgs(drv, actionRun, BuildPolicy{}, "", "", nil); err == nil { + t.Fatal("unsupported protocol succeeded") + } + drv.Protocol = "v1" + if _, err := driverArgs(drv, actionBuild, BuildPolicy{}, "", "", nil); err == nil { + t.Fatal("build without output succeeded") + } +} + +func TestValidateArgv(t *testing.T) { + big := strings.Repeat("x", 256<<10) + if err := validateArgv("driver", []string{big}, nil); !errors.Is(err, ErrDriverArgvTooLarge) { + t.Fatalf("validateArgv = %v", err) + } +} diff --git a/cmd/internal/projectdriver/resolve.go b/cmd/internal/projectdriver/resolve.go new file mode 100644 index 000000000..76c9491e8 --- /dev/null +++ b/cmd/internal/projectdriver/resolve.go @@ -0,0 +1,70 @@ +/* + * 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 projectdriver + +import ( + "context" + + "github.com/goplus/xgo/env" + "github.com/goplus/xgo/x/xgoprojs" +) + +// Resolver owns one invocation's graph and host-build policies. +type Resolver struct { + cwd string + policy parsedFlags + xgoVersion string +} + +// NewResolver snapshots ambient GOFLAGS/GOWORK once for an invocation. Policy +// setup must not fall back to a different module graph: doing so could classify +// a workspace driver-backed target as legacy and incorrectly dispatch it to GenGo. +func NewResolver(ctx context.Context, cwd string, flags []string) (*Resolver, error) { + cwd, err := canonicalExistingDir(cwd) + if err != nil { + return nil, err + } + policy, err := preparePolicies(ctx, cwd, flags) + if err != nil { + return nil, err + } + return &Resolver{cwd: cwd, policy: policy, xgoVersion: env.Version()}, nil +} + +// BuildPolicy returns the validated driver build policy. Callers must invoke +// it only after Resolve matched a driver-backed project. +func (r *Resolver) BuildPolicy() (BuildPolicy, error) { + if err := r.policy.validateDriver(); err != nil { + return BuildPolicy{}, err + } + return r.policy.build, nil +} + +// Resolve resolves a parsed XGo target without parsing or generating source. +func (r *Resolver) Resolve(ctx context.Context, target xgoprojs.Proj) (*Driver, error) { + input, err := r.classifyTarget(ctx, target) + if err != nil { + return nil, err + } + policy := r.policy.graph + policy.WorkDir = input.graphWorkDir + graph, err := r.resolveTargetGraph(ctx, input.projectDir, input.graph, policy, input.recursive) + if err != nil { + return nil, err + } + return r.buildResolvedDriver(input, graph, policy) +} diff --git a/cmd/internal/projectdriver/resolve_driver.go b/cmd/internal/projectdriver/resolve_driver.go new file mode 100644 index 000000000..a651d801e --- /dev/null +++ b/cmd/internal/projectdriver/resolve_driver.go @@ -0,0 +1,111 @@ +/* + * 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 projectdriver + +import ( + "fmt" + "os" +) + +func (r *Resolver) buildResolvedDriver(target targetResolution, graph *effectiveGraph, policy GraphPolicy) (*Driver, error) { + module, hasClass, err := loadResolvedClasses(graph) + if err != nil { + return nil, err + } + if !hasClass { + return nil, ErrNotHandled + } + if target.recursive { + hasDriver, err := patternContainsDriverProject(target.projectDir, module) + if err != nil { + return nil, err + } + if !hasDriver { + return nil, ErrNotHandled + } + return nil, fmt.Errorf("driver v1 does not support %s", target.unsupportedForm) + } + + projectFile, info, candidates, err := findProjectFile(target.projectDir, module) + if err != nil { + return nil, err + } + if info == nil || info.Project.Driver == nil { + return nil, ErrNotHandled + } + if target.unsupportedForm != "" { + return nil, fmt.Errorf("driver v1 does not support %s", target.unsupportedForm) + } + if candidates != 1 { + return nil, fmt.Errorf("driver-backed project directory %q contains %d project files; exactly one is required", target.projectDir, candidates) + } + if target.multiFile { + return nil, fmt.Errorf("driver v1 does not support multiple source-file targets") + } + if target.expectedFile != "" { + same, err := sameFile(target.expectedFile, projectFile) + if err != nil || !same { + return nil, fmt.Errorf("driver file target %q is not the unique project file %q", target.original, projectFile) + } + } + if info.Origin == nil { + return nil, fmt.Errorf("driver-backed project %q has no module provenance", projectFile) + } + if err := checkRequiredXGo(info.RequiredXGo, r.xgoVersion); err != nil { + return nil, err + } + project := info.Project + if project.Driver.Protocol != protocolV1 { + return nil, fmt.Errorf("unsupported driver protocol %q", project.Driver.Protocol) + } + packDir, packIndex, err := validatePack(target.projectDir, project.Pack) + if err != nil { + return nil, err + } + origin := *info.Origin + goxmod, err := declaringMetadata(origin, info.Declaration) + if err != nil { + return nil, err + } + if os.Getenv("XGO_DRIVER") == "off" { + return nil, ErrDriverDisabled + } + if os.Getenv(driverGuardEnv) != "" { + return nil, ErrDriverRecursive + } + defaultName := defaultExecutableName(target.kind, target.projectDir, target.targetImportPath) + return &Driver{ + TargetKind: target.kind, + OriginalTarget: target.original, + TargetImportPath: target.targetImportPath, + DefaultExecName: defaultName, + ProjectDir: target.projectDir, + ProjectFile: projectFile, + ModuleRoot: graph.Target.Effective().Dir, + DriverPackage: project.Driver.Package, + Origin: origin, + RequiredXGo: info.RequiredXGo, + Protocol: project.Driver.Protocol, + ProjectExt: project.Ext, + ProjectFullExt: project.FullExt, + PackDir: packDir, + PackIndex: packIndex, + GoxMod: goxmod.Path, + GoxModSHA256: goxmod.SHA256, + Graph: policy, + }, nil +} diff --git a/cmd/internal/projectdriver/resolve_graph.go b/cmd/internal/projectdriver/resolve_graph.go new file mode 100644 index 000000000..3c3b71a40 --- /dev/null +++ b/cmd/internal/projectdriver/resolve_graph.go @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package projectdriver + +import ( + "context" + "errors" +) + +func (r *Resolver) resolveTargetGraph(ctx context.Context, projectDir string, graph *effectiveGraph, policy GraphPolicy, recursive bool) (*effectiveGraph, error) { + if graph != nil { + if graph.files == nil || !graph.files.hasOverlay() { + return graph, nil + } + hasDriver, err := overlayDriverProjectMatch(projectDir, graph, recursive) + if err != nil { + return nil, err + } + if !hasDriver { + return nil, ErrNotHandled + } + return nil, unsupportedOverlayError(policy.Overlay) + } + // An overlay changes the Go command's effective module graph and may + // introduce class markers or driver metadata absent from the physical tree. + if overlay := policy.Overlay; overlay != "" { + overlayGraph, err := loadEffectiveGraph(ctx, projectDir, policy) + if err != nil { + if errors.Is(err, errNoGoModule) { + return nil, ErrNotHandled + } + return nil, err + } + hasDriver, err := overlayDriverProjectMatch(projectDir, overlayGraph, recursive) + if err != nil { + return nil, err + } + if !hasDriver { + return nil, ErrNotHandled + } + return nil, unsupportedOverlayError(overlay) + } + preflightModule, _, hasClass, vendor, err := r.preflightClassMetadata(ctx, projectDir) + if err != nil { + if errors.Is(err, errNoGoModule) { + return nil, ErrNotHandled + } + return nil, err + } + if !hasClass { + return nil, ErrNotHandled + } + if vendor { + hasDriver, err := r.probeVendorProject(projectDir, preflightModule, recursive) + if err != nil { + return nil, err + } + if !hasDriver { + return nil, ErrNotHandled + } + return nil, vendorUnsupportedError(string(r.policy.graph.ModMode)) + } + return loadEffectiveGraph(ctx, projectDir, r.policy.graph) +} diff --git a/cmd/internal/projectdriver/resolve_metadata.go b/cmd/internal/projectdriver/resolve_metadata.go new file mode 100644 index 000000000..42f5b2c10 --- /dev/null +++ b/cmd/internal/projectdriver/resolve_metadata.go @@ -0,0 +1,74 @@ +/* + * 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 projectdriver + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/goplus/mod/xgomod" +) + +func declaringMetadata(origin ResolvedModule, snapshot xgomod.FileIdentity) (fileIdentity, error) { + dir := origin.Effective().Dir + base := filepath.Base(snapshot.Path) + if snapshot.Path == "" || snapshot.SHA256 == "" || filepath.Dir(snapshot.Path) != dir || (base != "gox.mod" && base != "gop.mod") { + return fileIdentity{}, fmt.Errorf("driver origin %q has an invalid declaring metadata snapshot", origin.Selected.Path) + } + if len(snapshot.SHA256) != sha256.Size*2 { + return fileIdentity{}, fmt.Errorf("driver origin %q has an invalid declaring metadata digest", origin.Selected.Path) + } + if _, err := hex.DecodeString(snapshot.SHA256); err != nil || snapshot.SHA256 != strings.ToLower(snapshot.SHA256) { + return fileIdentity{}, fmt.Errorf("driver origin %q has an invalid declaring metadata digest", origin.Selected.Path) + } + before, err := os.Lstat(snapshot.Path) + if err != nil { + return fileIdentity{}, err + } + if before.Mode()&os.ModeSymlink != 0 || !before.Mode().IsRegular() { + return fileIdentity{}, fmt.Errorf("declaring metadata %q is not a regular non-symlink file", snapshot.Path) + } + file, err := os.Open(snapshot.Path) + if err != nil { + return fileIdentity{}, err + } + data, readErr := io.ReadAll(file) + opened, statErr := file.Stat() + closeErr := file.Close() + if readErr != nil { + return fileIdentity{}, readErr + } + if statErr != nil { + return fileIdentity{}, statErr + } + if closeErr != nil { + return fileIdentity{}, closeErr + } + after, err := os.Lstat(snapshot.Path) + if err != nil || !os.SameFile(before, opened) || !os.SameFile(opened, after) || !after.Mode().IsRegular() { + return fileIdentity{}, fmt.Errorf("declaring metadata %q changed after discovery", snapshot.Path) + } + if got := sha256Bytes(data); got != snapshot.SHA256 { + return fileIdentity{}, fmt.Errorf("declaring metadata %q changed after discovery", snapshot.Path) + } + return fileIdentity(snapshot), nil +} diff --git a/cmd/internal/projectdriver/resolve_overlay.go b/cmd/internal/projectdriver/resolve_overlay.go new file mode 100644 index 000000000..010be2572 --- /dev/null +++ b/cmd/internal/projectdriver/resolve_overlay.go @@ -0,0 +1,197 @@ +/* + * 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 projectdriver + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/goplus/mod/modfile" +) + +// resolveOverlayLocalTarget classifies overlay-only local targets. +// xgoprojs may parse both files and directories as DirProj. +func (r *Resolver) resolveOverlayLocalTarget(ctx context.Context, candidate string, recursive bool) (TargetKind, string, string, *effectiveGraph, error) { + graph, err := loadEffectiveGraph(ctx, r.cwd, r.policy.graph) + if err != nil { + if errors.Is(err, errNoGoModule) { + return 0, "", "", nil, ErrNotHandled + } + return 0, "", "", nil, err + } + logical := overlayPath(r.cwd, candidate) + projectDir, dirErr := graph.files.canonicalDir(logical) + kind := TargetDirectory + expectedFile := "" + if dirErr != nil { + if recursive { + if os.IsNotExist(dirErr) { + return 0, "", "", nil, ErrNotHandled + } + return 0, "", "", nil, fmt.Errorf("overlay local target %q: %w", candidate, dirErr) + } + visible, fileErr := graph.files.regularFileVisible(logical) + if fileErr != nil { + return 0, "", "", nil, fmt.Errorf("overlay local target %q: %w", candidate, fileErr) + } + if !visible { + if !os.IsNotExist(dirErr) { + return 0, "", "", nil, fmt.Errorf("overlay local target %q: %w", candidate, dirErr) + } + return 0, "", "", nil, ErrNotHandled + } + kind = TargetFile + expectedFile = logical + projectDir, err = graph.files.canonicalDir(filepath.Dir(logical)) + if err != nil { + return 0, "", "", nil, fmt.Errorf("overlay local file target %q: %w", candidate, err) + } + } + targetModule, err := graphModuleContainingDirectory(graph, projectDir) + if err != nil { + return 0, "", "", nil, err + } + graph, err = retargetEffectiveGraph(graph, targetModule) + if err != nil { + return 0, "", "", nil, err + } + return kind, projectDir, expectedFile, graph, nil +} + +func graphModuleContainingDirectory(graph *effectiveGraph, dir string) (ResolvedModule, error) { + var match ResolvedModule + bestRoot := "" + for _, module := range graph.Modules { + root := module.Effective().Dir + if root != "" && pathWithin(root, dir) && len(root) > len(bestRoot) { + match = module + bestRoot = root + } + } + if bestRoot == "" { + return ResolvedModule{}, fmt.Errorf("overlay target directory %q is outside the effective module graph", dir) + } + return match, nil +} + +func classModuleMarked(classModules []ResolvedModule, modulePath string) bool { + for _, module := range classModules { + if module.Selected.Path == modulePath { + return true + } + } + return false +} + +// overlayDriverProjectMatch classifies against overlay metadata only. +// Driver v1 rejects a positive overlay match before execution. +func overlayDriverProjectMatch(projectDir string, graph *effectiveGraph, recursive bool) (bool, error) { + if graph == nil || graph.files == nil || !graph.files.hasOverlay() { + return false, fmt.Errorf("overlay classification requires an effective graph file view") + } + projects, err := overlayDriverProjects(graph) + if err != nil { + return false, err + } + if len(projects) == 0 { + return false, nil + } + if recursive { + return overlayWalkDriverProjects(projectDir, projects, graph.files) + } + names, err := graph.files.regularFileNames(projectDir) + if err != nil { + return false, err + } + for _, name := range names { + if driverProjectMatches(projects, name) { + return true, nil + } + } + return false, nil +} + +func overlayDriverProjects(graph *effectiveGraph) ([]*modfile.Project, error) { + target := graph.Target.Effective() + loaded, err := loadDriverModuleView(graph.TargetModFile.Path, filepath.Join(target.Dir, "gox.mod"), graph.files) + if err != nil { + return nil, fmt.Errorf("load overlaid target metadata: %w", err) + } + projects := append([]*modfile.Project(nil), loaded.Projects()...) + for _, class := range graph.ClassModules { + effective := class.Effective() + classModule, loadErr := loadDriverModuleView(effective.GoMod, filepath.Join(effective.Dir, "gox.mod"), graph.files) + if loadErr != nil { + return nil, fmt.Errorf("load overlaid class module %q metadata: %w", class.Selected.Path, loadErr) + } + projects = append(projects, classModule.Projects()...) + } + return projects, nil +} + +func overlayWalkDriverProjects(root string, projects []*modfile.Project, view *graphFileView) (bool, error) { + root = overlayPath(view.workDir, root) + visited := make(map[string]struct{}) + var walk func(string) error + walk = func(current string) error { + if _, ok := visited[current]; ok { + return nil + } + visited[current] = struct{}{} + names, err := view.regularFileNames(current) + if err != nil { + return err + } + for _, name := range names { + if driverProjectMatches(projects, name) { + return errDriverProjectInPattern + } + } + dirs, err := view.directoryNames(current) + if err != nil { + return err + } + for _, name := range dirs { + if name == "vendor" || name == "testdata" || strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") { + continue + } + child := filepath.Join(current, name) + if isGoMod, err := view.regularFileVisible(filepath.Join(child, "go.mod")); err != nil { + return err + } else if isGoMod { + continue + } + if err := walk(child); err != nil { + return err + } + } + return nil + } + err := walk(root) + if errors.Is(err, errDriverProjectInPattern) { + return true, nil + } + return false, err +} + +func unsupportedOverlayError(path string) error { + return fmt.Errorf("driver v1 does not support flag -overlay=%s", path) +} diff --git a/cmd/internal/projectdriver/resolve_overlay_test.go b/cmd/internal/projectdriver/resolve_overlay_test.go new file mode 100644 index 000000000..7d8b518b4 --- /dev/null +++ b/cmd/internal/projectdriver/resolve_overlay_test.go @@ -0,0 +1,184 @@ +/* + * 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 projectdriver + +import ( + "context" + "encoding/json" + "errors" + "path/filepath" + "strings" + "testing" + + "github.com/goplus/xgo/x/xgoprojs" +) + +func TestResolveOverlayClassifiesDriverBeforePhysicalPreflight(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + t.Setenv("GOWORK", "off") + root := t.TempDir() + app := filepath.Join(root, "app") + project := filepath.Join(app, "game") + framework := filepath.Join(root, "framework") + mustMkdirAll(t, project) + mustMkdirAll(t, filepath.Join(framework, "cmd", "driver")) + mustWriteFile(t, filepath.Join(app, "go.mod"), "module example.test/app\n\ngo 1.25\n") + mustWriteFile(t, filepath.Join(framework, "go.mod"), "module example.test/framework\n\ngo 1.25\n") + mustWriteFile(t, filepath.Join(framework, "gox.mod"), `xgo 1.8 +project main.foo Game example.test/framework +driver v1 example.test/framework/cmd/driver +`) + mustWriteFile(t, filepath.Join(framework, "cmd", "driver", "main.go"), fakeDriverSource) + mustWriteFile(t, filepath.Join(project, "main.foo"), "// overlaid driver-backed project\n") + overlayMod := filepath.Join(root, "overlay.mod") + mustWriteFile(t, overlayMod, `module example.test/app + +go 1.25 + +require example.test/framework v1.2.3 //xgo:class + +replace example.test/framework => ../framework +`) + overlayFile := filepath.Join(root, "overlay.json") + canonicalApp, err := canonicalExistingDir(app) + if err != nil { + t.Fatal(err) + } + overlayData, err := json.Marshal(struct { + Replace map[string]string `json:"Replace"` + }{Replace: map[string]string{filepath.Join(canonicalApp, "go.mod"): overlayMod}}) + if err != nil { + t.Fatal(err) + } + mustWriteFile(t, overlayFile, string(overlayData)) + resolver, err := NewResolver(context.Background(), app, []string{"-overlay=" + overlayFile}) + if err != nil { + t.Fatal(err) + } + canonicalProject, err := canonicalExistingDir(project) + if err != nil { + t.Fatal(err) + } + graph, graphErr := loadEffectiveGraph(context.Background(), canonicalProject, resolver.policy.graph) + if graphErr == nil { + matched, matchErr := overlayDriverProjectMatch(canonicalProject, graph, false) + if matchErr != nil || !matched { + t.Fatalf("overlay classification = %v, %v; want driver match", matched, matchErr) + } + } else { + t.Fatalf("load overlay graph: %v", graphErr) + } + _, err = resolver.Resolve(context.Background(), &xgoprojs.DirProj{Dir: project}) + if err == nil || errors.Is(err, ErrNotHandled) || !strings.Contains(err.Error(), "-overlay") { + t.Fatalf("overlay driver Resolve() = %v, want explicit overlay rejection", err) + } + _, err = resolver.Resolve(context.Background(), &xgoprojs.PkgPathProj{Path: "example.test/app/game"}) + if err == nil || errors.Is(err, ErrNotHandled) || !strings.Contains(err.Error(), "-overlay") { + t.Fatalf("overlay driver package Resolve() = %v, want explicit overlay rejection", err) + } +} + +func TestResolveOverlayLegacyTargetRemainsUnhandled(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + t.Setenv("GOWORK", "off") + root := t.TempDir() + app := filepath.Join(root, "app") + mustMkdirAll(t, app) + mustWriteFile(t, filepath.Join(app, "go.mod"), "module example.test/app\n\ngo 1.25\n") + mustWriteFile(t, filepath.Join(app, "main.go"), "package main\nfunc main() {}\n") + overlayFile := filepath.Join(root, "overlay.json") + overlayData, err := json.Marshal(struct { + Replace map[string]string `json:"Replace"` + }{Replace: map[string]string{filepath.Join(app, "main.go"): ""}}) + if err != nil { + t.Fatal(err) + } + mustWriteFile(t, overlayFile, string(overlayData)) + resolver, err := NewResolver(context.Background(), app, []string{"-overlay=" + overlayFile}) + if err != nil { + t.Fatal(err) + } + _, err = resolver.Resolve(context.Background(), &xgoprojs.DirProj{Dir: app}) + if !errors.Is(err, ErrNotHandled) { + t.Fatalf("overlay legacy Resolve() = %v, want ErrNotHandled", err) + } +} + +func TestResolveOverlayTargetsWithSyntheticDirectory(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + t.Setenv("GOWORK", "off") + root := t.TempDir() + app := filepath.Join(root, "app") + actual := filepath.Join(root, "overlay-files") + mustMkdirAll(t, app) + mustMkdirAll(t, actual) + mustWriteFile(t, filepath.Join(app, "go.mod"), "module example.test/app\n\ngo 1.25\n") + mustWriteFile(t, filepath.Join(app, "gox.mod"), `xgo 1.8 +project main.foo Game example.test/app +driver v1 example.test/app/cmd/driver +`) + mustWriteFile(t, filepath.Join(actual, "main.go"), "package game\n") + mustWriteFile(t, filepath.Join(actual, "main.foo"), "// overlaid driver-backed project\n") + overlayFile := filepath.Join(root, "overlay.json") + canonicalApp, err := canonicalExistingDir(app) + if err != nil { + t.Fatal(err) + } + canonicalProject := filepath.Join(canonicalApp, "game") + data, err := json.Marshal(struct { + Replace map[string]string `json:"Replace"` + }{Replace: map[string]string{ + filepath.Join(canonicalProject, "main.go"): filepath.Join(actual, "main.go"), + filepath.Join(canonicalProject, "main.foo"): filepath.Join(actual, "main.foo"), + }}) + if err != nil { + t.Fatal(err) + } + mustWriteFile(t, overlayFile, string(data)) + resolver, err := NewResolver(context.Background(), app, []string{"-overlay=" + overlayFile}) + if err != nil { + t.Fatal(err) + } + for _, arg := range []string{ + canonicalProject, + filepath.Join(canonicalProject, "main.foo"), + filepath.Join(canonicalProject, "..."), + } { + target, next, parseErr := xgoprojs.ParseOne(arg) + if parseErr != nil || len(next) != 0 { + t.Fatalf("ParseOne(%q) = (%T, %v, %v)", arg, target, next, parseErr) + } + _, resolveErr := resolver.Resolve(context.Background(), target) + if resolveErr == nil || errors.Is(resolveErr, ErrNotHandled) || !strings.Contains(resolveErr.Error(), "-overlay") { + t.Fatalf("overlay synthetic local target %q Resolve() = %v, want explicit overlay rejection", arg, resolveErr) + } + } + _, err = resolver.Resolve(context.Background(), &xgoprojs.PkgPathProj{Path: "example.test/app/game"}) + if err == nil || errors.Is(err, ErrNotHandled) || !strings.Contains(err.Error(), "-overlay") { + t.Fatalf("overlay synthetic package Resolve() = %v, want explicit overlay rejection", err) + } + _, err = resolver.Resolve(context.Background(), &xgoprojs.PkgPathProj{Path: "example.test/app/..."}) + if err == nil || errors.Is(err, ErrNotHandled) || !strings.Contains(err.Error(), "-overlay") { + t.Fatalf("overlay synthetic package pattern Resolve() = %v, want explicit overlay rejection", err) + } +} diff --git a/cmd/internal/projectdriver/resolve_package.go b/cmd/internal/projectdriver/resolve_package.go new file mode 100644 index 000000000..22f51ecf2 --- /dev/null +++ b/cmd/internal/projectdriver/resolve_package.go @@ -0,0 +1,104 @@ +/* + * 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 projectdriver + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/goplus/mod/modload" + "github.com/goplus/xgo/x/xgoprojs" +) + +func (r *Resolver) classifyPackageTarget(ctx context.Context, target *xgoprojs.PkgPathProj) (targetResolution, error) { + result := targetResolution{ + kind: TargetPackage, + original: target.Path, + targetImportPath: target.Path, + graphWorkDir: r.cwd, + } + lookupPath := target.Path + if strings.Contains(lookupPath, "@") { + hasDriver, err := r.versionedPackageHasDriver(ctx, lookupPath) + if err != nil { + return targetResolution{}, err + } + if !hasDriver { + return targetResolution{}, ErrNotHandled + } + return targetResolution{}, fmt.Errorf("driver v1 does not support package target containing @version") + } + if hasRecursivePattern(lookupPath) { + result.unsupportedForm = "package pattern containing ..." + result.recursive = true + lookupPath = trimRecursivePattern(lookupPath) + } + + hasOverlay := r.policy.graph.Overlay != "" + var ( + preflightModule modload.Module + preflightGoMod string + callerHasClass bool + vendor bool + ) + if !hasOverlay { + var preflightErr error + preflightModule, preflightGoMod, callerHasClass, vendor, preflightErr = r.preflightClassMetadata(ctx, r.cwd) + if preflightErr != nil && !errors.Is(preflightErr, errNoGoModule) { + return targetResolution{}, preflightErr + } + if vendor { + if !callerHasClass && r.policy.graph.GoWork == "off" { + return targetResolution{}, ErrNotHandled + } + hasDriver, probeErr := r.probeVendorPackage(ctx, preflightGoMod, preflightModule, lookupPath, result.recursive) + if probeErr != nil { + return targetResolution{}, probeErr + } + if hasDriver { + return targetResolution{}, vendorUnsupportedError(string(r.policy.graph.ModMode)) + } + return targetResolution{}, ErrNotHandled + } + } + + callerGraph, err := loadEffectiveGraph(ctx, r.cwd, r.policy.graph) + if err != nil { + if errors.Is(err, errNoGoModule) && !callerHasClass && !hasOverlay { + return targetResolution{}, ErrNotHandled + } + return targetResolution{}, err + } + projectDir, targetModule, err := resolvePackageDirectory(ctx, callerGraph, lookupPath, r.cwd, r.policy.graph) + if err != nil { + if callerHasClass || hasOverlay { + return targetResolution{}, err + } + return targetResolution{}, ErrNotHandled + } + if !targetModule.Main && !classModuleMarked(callerGraph.ClassModules, targetModule.Selected.Path) { + return targetResolution{}, ErrNotHandled + } + result.projectDir = projectDir + result.graph, err = retargetEffectiveGraph(callerGraph, targetModule) + if err != nil { + return targetResolution{}, err + } + return result, nil +} diff --git a/cmd/internal/projectdriver/resolve_policy_test.go b/cmd/internal/projectdriver/resolve_policy_test.go new file mode 100644 index 000000000..37fa57ad6 --- /dev/null +++ b/cmd/internal/projectdriver/resolve_policy_test.go @@ -0,0 +1,326 @@ +/* + * 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 projectdriver + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/goplus/mod/xgomod" + "github.com/goplus/xgo/x/xgoprojs" +) + +func TestLoadDriverModulePropagatesOptionalMetadataReadErrors(t *testing.T) { + dir := t.TempDir() + goMod := filepath.Join(dir, "go.mod") + goxMod := filepath.Join(dir, "gox.mod") + mustWriteFile(t, goMod, "module example.test/app\n\ngo 1.25\n") + mustMkdirAll(t, goxMod) + // A valid legacy fallback must not mask a non-NotExist error from gox.mod. + mustWriteFile(t, filepath.Join(dir, "gop.mod"), "gop 1.8\nproject main.foo Game example.test/app\n") + if _, err := loadDriverModule(goMod, goxMod); err == nil || !strings.Contains(err.Error(), "gox.mod") { + t.Fatalf("loadDriverModule() = %v, want explicit gox.mod read error", err) + } +} + +func TestLoadDriverModuleFallsBackToGopModWhenGoxModIsAbsent(t *testing.T) { + dir := t.TempDir() + goMod := filepath.Join(dir, "go.mod") + gopMod := filepath.Join(dir, "gop.mod") + mustWriteFile(t, goMod, "module example.test/app\n\ngo 1.25\n") + mustWriteFile(t, gopMod, "gop 1.1\nproject main.foo Game example.test/app\n") + loaded, err := loadDriverModule(goMod, filepath.Join(dir, "gox.mod")) + if err != nil { + t.Fatal(err) + } + if !loaded.HasProject() || loaded.GoxModIdentity().Path != gopMod { + t.Fatalf("gop.mod fallback identity = %#v", loaded.GoxModIdentity()) + } +} + +func TestResolvePropagatesOptionalMetadataReadErrors(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, "go.mod"), "module example.test/app\n\ngo 1.25\n") + mustMkdirAll(t, filepath.Join(dir, "gox.mod")) + mustWriteFile(t, filepath.Join(dir, "main.foo"), "// project source\n") + t.Setenv("GOWORK", "off") + resolver, err := NewResolver(context.Background(), dir, nil) + if err != nil { + t.Fatal(err) + } + _, err = resolver.Resolve(context.Background(), &xgoprojs.DirProj{Dir: dir}) + if err == nil || errors.Is(err, ErrNotHandled) || !strings.Contains(err.Error(), "gox.mod") { + t.Fatalf("Resolve() = %v, want explicit gox.mod read error", err) + } +} + +func TestResolvePackageTargetRejectsUnmarkedDependencyDriver(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newDriverFixture(t) + dependencyProject := filepath.Join(fixture.framework, "example") + mustMkdirAll(t, filepath.Join(dependencyProject, "pack")) + mustWriteFile(t, filepath.Join(dependencyProject, "main.foo"), "// dependency project\n") + mustWriteFile(t, filepath.Join(dependencyProject, "pack", "index.data"), "{}\n") + + goModPath := filepath.Join(fixture.app, "go.mod") + goMod, err := os.ReadFile(goModPath) + if err != nil { + t.Fatal(err) + } + goMod = bytes.Replace(goMod, []byte(" //xgo:class"), nil, 1) + if err := os.WriteFile(goModPath, goMod, 0o644); err != nil { + t.Fatal(err) + } + + resolver := fixture.resolver(t) + _, err = resolver.Resolve(context.Background(), &xgoprojs.PkgPathProj{Path: "example.test/framework/example"}) + if !errors.Is(err, ErrNotHandled) { + t.Fatalf("unmarked dependency driver = %v, want ErrNotHandled", err) + } +} + +func TestResolveOrdinaryGoSubpackageInsideDriverModuleUsesLegacyPath(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newDriverFixture(t) + ordinary := filepath.Join(fixture.framework, "ordinary") + mustMkdirAll(t, ordinary) + mustWriteFile(t, filepath.Join(ordinary, "main.go"), "package ordinary\n") + resolver := fixture.resolver(t) + _, err := resolver.Resolve(context.Background(), &xgoprojs.PkgPathProj{Path: "example.test/framework/ordinary"}) + if !errors.Is(err, ErrNotHandled) { + t.Fatalf("ordinary Go subpackage = %v", err) + } +} + +func TestUnsupportedPatternOnlyRejectsMatchedDriverTarget(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newDriverFixture(t) + legacy := filepath.Join(fixture.app, "legacy") + mustMkdirAll(t, legacy) + mustWriteFile(t, filepath.Join(legacy, "main.go"), "package main\nfunc main() {}\n") + nested := filepath.Join(legacy, "nested") + mustMkdirAll(t, nested) + mustWriteFile(t, filepath.Join(nested, "go.mod"), "module example.test/nested\n\ngo 1.25\n") + mustWriteFile(t, filepath.Join(nested, "main.foo"), "// nested driver-backed project must be outside the pattern\n") + resolver := fixture.resolver(t) + if _, err := resolver.Resolve(context.Background(), &xgoprojs.DirProj{Dir: filepath.Join(legacy, "...")}); !errors.Is(err, ErrNotHandled) { + t.Fatalf("legacy pattern = %v, want ErrNotHandled", err) + } + if _, err := resolver.Resolve(context.Background(), &xgoprojs.DirProj{Dir: filepath.Join(fixture.project, "...")}); err == nil || !strings.Contains(err.Error(), "directory pattern") { + t.Fatalf("driver pattern error = %v", err) + } + if _, err := resolver.Resolve(context.Background(), &xgoprojs.DirProj{Dir: filepath.Join(fixture.app, "...")}); err == nil || !strings.Contains(err.Error(), "directory pattern") { + t.Fatalf("parent pattern containing driver-backed project error = %v", err) + } + if _, err := resolver.Resolve(context.Background(), &xgoprojs.PkgPathProj{Path: "example.test/app/legacy/..."}); !errors.Is(err, ErrNotHandled) { + t.Fatalf("legacy package pattern = %v, want ErrNotHandled", err) + } + if _, err := resolver.Resolve(context.Background(), &xgoprojs.PkgPathProj{Path: "example.test/app/..."}); err == nil || !strings.Contains(err.Error(), "package pattern") { + t.Fatalf("package pattern containing driver-backed project error = %v", err) + } +} + +func TestResolveDriverWithoutPack(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newDriverFixture(t) + goxmod := filepath.Join(fixture.framework, "gox.mod") + data, err := os.ReadFile(goxmod) + if err != nil { + t.Fatal(err) + } + data = bytes.ReplaceAll(data, []byte("pack pack index.data\n"), nil) + if err := os.WriteFile(goxmod, data, 0644); err != nil { + t.Fatal(err) + } + drv := resolveDriver(t, fixture.resolver(t), &xgoprojs.DirProj{Dir: fixture.project}) + if drv.PackDir != "" || drv.PackIndex != "" { + t.Fatalf("pack = %q, %q", drv.PackDir, drv.PackIndex) + } + args, err := driverArgs(drv, actionRun, BuildPolicy{}, "", "", nil) + if err != nil { + t.Fatal(err) + } + for _, arg := range args { + if strings.HasPrefix(arg, "--pack-") { + t.Fatalf("optional pack leaked into argv: %#v", args) + } + } +} + +func TestDeclaringMetadataRejectsChangeAfterDiscovery(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "gox.mod") + original := []byte("xgo 1.8\n") + if err := os.WriteFile(path, original, 0o600); err != nil { + t.Fatal(err) + } + snapshot := xgomod.FileIdentity{Path: path, SHA256: sha256Bytes(original)} + origin := ResolvedModule{Selected: ModuleRef{Path: "example.test/driver", Dir: dir, GoMod: filepath.Join(dir, "go.mod")}, Main: true} + if _, err := declaringMetadata(origin, snapshot); err != nil { + t.Fatalf("unchanged declaration rejected: %v", err) + } + if err := os.WriteFile(path, []byte("xgo 1.9\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := declaringMetadata(origin, snapshot); err == nil || !strings.Contains(err.Error(), "changed after discovery") { + t.Fatalf("changed declaration error = %v", err) + } +} + +func TestResolveDriverRejectsAmbiguousAndMultiFile(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newDriverFixture(t) + resolver := fixture.resolver(t) + other := filepath.Join(fixture.project, "other.foo") + mustWriteFile(t, other, "// another project\n") + _, err := resolver.Resolve(context.Background(), &xgoprojs.DirProj{Dir: fixture.project}) + if err == nil || !strings.Contains(err.Error(), "2 project files") { + t.Fatalf("ambiguity error = %v", err) + } + if err := os.Remove(other); err != nil { + t.Fatal(err) + } + _, err = resolver.Resolve(context.Background(), &xgoprojs.FilesProj{Files: []string{fixture.mainFile, filepath.Join(fixture.project, "worker.bar")}}) + if err == nil || !strings.Contains(err.Error(), "multiple source-file") { + t.Fatalf("multi-file error = %v", err) + } +} + +func TestResolveNonDriverNotHandled(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, "go.mod"), "module example.test/plain\n\ngo 1.25\n") + mustWriteFile(t, filepath.Join(dir, "main.go"), "package main\nfunc main() {}\n") + resolver, err := NewResolver(context.Background(), dir, []string{"-tags=legacy-only"}) + if err != nil { + t.Fatal(err) + } + _, err = resolver.Resolve(context.Background(), &xgoprojs.DirProj{Dir: dir}) + if !errors.Is(err, ErrNotHandled) { + t.Fatalf("plain project = %v", err) + } +} + +func TestResolverRejectsAmbientPolicyFailureWithoutFallback(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, "go.mod"), "module example.test/plain\n\ngo 1.25\n") + mustWriteFile(t, filepath.Join(dir, "main.go"), "package main\nfunc main() {}\n") + t.Setenv("GOWORK", "off") + t.Setenv("GOFLAGS", "'unterminated") + if resolver, err := NewResolver(context.Background(), dir, nil); err == nil || resolver != nil { + t.Fatalf("NewResolver() = %#v, %v; want policy error without fallback graph", resolver, err) + } +} + +func TestResolverDefersMissingGraphFileForLegacy(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, "go.mod"), "module example.test/plain\n\ngo 1.25\n") + mustWriteFile(t, filepath.Join(dir, "main.go"), "package main\nfunc main() {}\n") + t.Setenv("GOWORK", "off") + resolver, err := NewResolver(context.Background(), dir, []string{"-modfile=missing.mod"}) + if err != nil { + t.Fatal(err) + } + if _, err := resolver.Resolve(context.Background(), &xgoprojs.DirProj{Dir: dir}); !errors.Is(err, ErrNotHandled) { + t.Fatalf("legacy target = %v", err) + } +} + +func TestCheckRequiredXGo(t *testing.T) { + tests := []struct { + required string + current string + ok bool + }{ + {"1.8", "v1.8.0", true}, + {"v1.8.1", "v1.9.0", true}, + {"1.8", "v1.7.5", false}, + {"1.9", "v1.8.9", false}, + {"1.8", "(devel)", true}, + {"1.8.1", "(devel)", false}, + {"1.8", "v1.7.5 devel", true}, + {"1.8.1", "v1.7.5 devel", false}, + {"1.8", "xgo v1.9.0-devel", true}, + {"", "(devel)", true}, + } + for _, test := range tests { + err := checkRequiredXGo(test.required, test.current) + if (err == nil) != test.ok { + t.Fatalf("checkRequiredXGo(%q, %q) = %v", test.required, test.current, err) + } + } +} + +func TestCheckRequiredXGoReportsDevelopmentCapability(t *testing.T) { + err := checkRequiredXGo("1.8.1", "(devel)") + if err == nil { + t.Fatal("development build unexpectedly satisfied a newer capability") + } + for _, want := range []string{"declaring module requires XGo 1.8.1", "(devel)", "driver capability 1.8.0"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("checkRequiredXGo() error = %q, want %q", err, want) + } + } +} + +func TestResolveUsesDeclaringModuleXGoRequirement(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newDriverFixture(t) + resolver := fixture.resolver(t) + resolver.xgoVersion = "v1.8.0" + resolveDriver(t, resolver, &xgoprojs.DirProj{Dir: fixture.project}) + + setFixtureRequiredXGo(t, fixture, "1.9.0") + resolver = fixture.resolver(t) + resolver.xgoVersion = "v1.8.9" + _, err := resolver.Resolve(context.Background(), &xgoprojs.DirProj{Dir: fixture.project}) + if err == nil || !strings.Contains(err.Error(), "declaring module requires XGo 1.9.0") { + t.Fatalf("Resolve() = %v, want declaring-module version error", err) + } + + resolver = fixture.resolver(t) + resolver.xgoVersion = "v1.9.0" + resolveDriver(t, resolver, &xgoprojs.DirProj{Dir: fixture.project}) +} diff --git a/cmd/internal/projectdriver/resolve_project.go b/cmd/internal/projectdriver/resolve_project.go new file mode 100644 index 000000000..69036cb9f --- /dev/null +++ b/cmd/internal/projectdriver/resolve_project.go @@ -0,0 +1,264 @@ +/* + * 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 projectdriver + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/goplus/mod/modfile" + "github.com/goplus/mod/modload" + "github.com/goplus/mod/xgomod" +) + +const driverGuardEnv = "XGO_DRIVER_GUARD" + +func driverGuard(projectDir, driverPackage string) string { + return sha256Bytes([]byte(projectDir + "\x00" + driverPackage)) +} + +// loadDriverModule preserves metadata fallback and reports unreadable optional files. +func loadDriverModule(goMod, goxMod string) (modload.Module, error) { + return loadDriverModuleView(goMod, goxMod, nil) +} + +func loadDriverModuleView(goMod, goxMod string, view *graphFileView) (modload.Module, error) { + optionalPaths := map[string]struct{}{goxMod: {}} + if strings.HasSuffix(goxMod, "gox.mod") { + optionalPaths[strings.TrimSuffix(goxMod, "gox.mod")+"gop.mod"] = struct{}{} + } + var optionalErr error + loaded, err := modload.LoadFromEx(goMod, goxMod, func(path string) ([]byte, error) { + data, readErr := view.readFile(path) + if _, optional := optionalPaths[path]; optional && readErr != nil && !os.IsNotExist(readErr) && optionalErr == nil { + optionalErr = fmt.Errorf("read optional module metadata %q: %w", path, readErr) + } + return data, readErr + }) + if optionalErr != nil { + return modload.Module{}, optionalErr + } + return loaded, err +} + +func externalClassModule(module modload.Module) string { + for _, require := range module.Require { + if require.Syntax != nil && modload.HasClassMarker(require.Syntax.Suffix) { + return require.Mod.Path + } + } + return "" +} + +func driverProjectMatches(projects []*modfile.Project, name string) bool { + ext := modfile.ClassExt(name) + for _, project := range projects { + if project != nil && project.Driver != nil && project.IsProj(ext, name) { + return true + } + } + return false +} + +func loadResolvedClasses(graph *effectiveGraph) (*xgomod.Module, bool, error) { + target := graph.Target.Effective() + loaded, err := loadDriverModuleView(graph.TargetModFile.Path, filepath.Join(target.Dir, "gox.mod"), graph.files) + if err != nil { + return nil, false, err + } + hasClass := len(graph.ClassModules) != 0 || loaded.HasProject() + if !hasClass { + return nil, false, nil + } + module := xgomod.New(loaded) + if err := module.ImportClassesResolved(toXGoGraph(graph)); err != nil { + return nil, false, err + } + return module, true, nil +} + +func toXGoGraph(graph *effectiveGraph) xgomod.ResolvedClassGraph { + // xgomod only consumes the target and explicitly marked class modules. + // Keep the complete build list in effectiveGraph for package resolution, + // while avoiding irrelevant legacy modules whose standard go list GoMod + // identity may live in cache/download rather than the extracted source. + return xgomod.ResolvedClassGraph{ + Target: graph.Target, ClassModules: graph.ClassModules, TargetModFile: graph.TargetModFile, + } +} + +func findProjectFile(dir string, module *xgomod.Module) (string, *xgomod.ProjectInfo, int, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return "", nil, 0, err + } + var ( + projectFile string + projectInfo *xgomod.ProjectInfo + count int + ) + for _, entry := range entries { + if entry.Type()&os.ModeSymlink != 0 { + continue + } + info, err := entry.Info() + if err != nil { + return "", nil, 0, err + } + if !info.Mode().IsRegular() { + continue + } + ext := modfile.ClassExt(entry.Name()) + classInfo, ok := module.LookupClassInfo(ext) + if !ok || !classInfo.Project.IsProj(ext, entry.Name()) { + continue + } + count++ + if classInfo.Project.Driver != nil { + projectFile = filepath.Join(dir, entry.Name()) + projectInfo = classInfo + } + } + return projectFile, projectInfo, count, nil +} + +var errDriverProjectInPattern = errors.New("driver-backed project in pattern") + +func patternContainsDriverProject(root string, module *xgomod.Module) (bool, error) { + return walkDriverPattern(root, func(dir string) (bool, error) { + _, info, _, err := findProjectFile(dir, module) + return info != nil && info.Project != nil && info.Project.Driver != nil, err + }) +} + +func patternContainsDriverProjects(root string, projects []*modfile.Project) (bool, error) { + return walkDriverPattern(root, func(dir string) (bool, error) { + return hasDriverProject(dir, projects) + }) +} + +func walkDriverPattern(root string, matches func(string) (bool, error)) (bool, error) { + err := filepath.WalkDir(root, func(current string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if !entry.IsDir() { + return nil + } + if current != root { + name := entry.Name() + if entry.Type()&os.ModeSymlink != 0 || name == "vendor" || name == "testdata" || strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") { + return filepath.SkipDir + } + goMod := filepath.Join(current, "go.mod") + if info, err := os.Lstat(goMod); err == nil { + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return fmt.Errorf("nested module marker %q is not a regular non-symlink file", goMod) + } + return filepath.SkipDir + } else if !os.IsNotExist(err) { + return err + } + } + matched, err := matches(current) + if err != nil { + return err + } + if matched { + return errDriverProjectInPattern + } + return nil + }) + if errors.Is(err, errDriverProjectInPattern) { + return true, nil + } + return false, err +} + +func validatePack(projectDir string, pack *modfile.Pack) (string, string, error) { + if pack == nil { + return "", "", nil + } + dir := filepath.Clean(filepath.FromSlash(pack.Directory)) + if pack.Directory == "" || filepath.IsAbs(dir) || dir == ".." || strings.HasPrefix(dir, ".."+string(filepath.Separator)) { + return "", "", fmt.Errorf("driver pack directory %q is invalid", pack.Directory) + } + if pack.IndexFile == "" || filepath.Base(pack.IndexFile) != pack.IndexFile || strings.ContainsAny(pack.IndexFile, `/\`) { + return "", "", fmt.Errorf("driver pack index %q is invalid", pack.IndexFile) + } + root := filepath.Join(projectDir, dir) + canonical, err := canonicalExistingDir(root) + if err != nil { + return "", "", fmt.Errorf("driver pack directory: %w", err) + } + if !pathWithin(projectDir, canonical) { + return "", "", fmt.Errorf("driver pack directory escapes the project") + } + return filepath.ToSlash(dir), pack.IndexFile, nil +} + +func sameFile(a, b string) (bool, error) { + aInfo, err := os.Stat(a) + if err != nil { + return false, err + } + bInfo, err := os.Stat(b) + if err != nil { + return false, err + } + return os.SameFile(aInfo, bInfo), nil +} + +func defaultExecutableName(kind TargetKind, projectDir, importPath string) string { + if kind != TargetPackage || importPath == "" { + return filepath.Base(projectDir) + } + parts := strings.Split(strings.TrimSuffix(importPath, "/"), "/") + name := parts[len(parts)-1] + if majorVersionRE.MatchString(name) && len(parts) > 1 { + name = parts[len(parts)-2] + } + return name +} + +var majorVersionRE = regexp.MustCompile(`^v[2-9][0-9]*$`) + +func hasRecursivePattern(target string) bool { + clean := filepath.ToSlash(filepath.Clean(target)) + return clean == "..." || strings.HasSuffix(clean, "/...") +} + +func trimRecursivePattern(target string) string { + clean := filepath.ToSlash(filepath.Clean(target)) + clean = strings.TrimSuffix(clean, "...") + clean = strings.TrimSuffix(clean, "/") + if clean == "" { + return "." + } + return filepath.FromSlash(clean) +} + +func sha256Bytes(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} diff --git a/cmd/internal/projectdriver/resolve_target.go b/cmd/internal/projectdriver/resolve_target.go new file mode 100644 index 000000000..b8cdaf06d --- /dev/null +++ b/cmd/internal/projectdriver/resolve_target.go @@ -0,0 +1,101 @@ +/* + * 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 projectdriver + +import ( + "context" + "errors" + "os" + "path/filepath" + + "github.com/goplus/xgo/x/xgoprojs" +) + +type targetResolution struct { + kind TargetKind + original string + projectDir string + targetImportPath string + expectedFile string + multiFile bool + unsupportedForm string + recursive bool + graph *effectiveGraph + graphWorkDir string +} + +func (r *Resolver) classifyTarget(ctx context.Context, target xgoprojs.Proj) (targetResolution, error) { + switch target := target.(type) { + case *xgoprojs.DirProj: + return r.classifyDirectoryTarget(ctx, target) + case *xgoprojs.FilesProj: + return classifyFilesTarget(target) + case *xgoprojs.PkgPathProj: + return r.classifyPackageTarget(ctx, target) + default: + return targetResolution{}, ErrNotHandled + } +} + +func (r *Resolver) classifyDirectoryTarget(ctx context.Context, target *xgoprojs.DirProj) (targetResolution, error) { + result := targetResolution{kind: TargetDirectory, original: target.Dir, graphWorkDir: r.cwd} + candidate := target.Dir + if hasRecursivePattern(candidate) { + result.unsupportedForm = "directory pattern containing ..." + result.recursive = true + candidate = trimRecursivePattern(candidate) + } + projectDir, err := canonicalExistingDir(candidate) + if err == nil { + result.projectDir = projectDir + result.graphWorkDir = projectDir + return result, nil + } + if isMissingPathError(err) && r.policy.graph.Overlay != "" { + result.kind, result.projectDir, result.expectedFile, result.graph, err = r.resolveOverlayLocalTarget(ctx, candidate, result.recursive) + if err != nil { + return targetResolution{}, err + } + return result, nil + } + if isMissingPathError(err) { + return targetResolution{}, ErrNotHandled + } + return targetResolution{}, err +} + +func classifyFilesTarget(target *xgoprojs.FilesProj) (targetResolution, error) { + if len(target.Files) == 0 { + return targetResolution{}, ErrNotHandled + } + file, err := canonicalExistingFile(target.Files[0]) + if err != nil { + return targetResolution{}, ErrNotHandled + } + return targetResolution{ + kind: TargetFile, + original: target.Files[0], + projectDir: filepath.Dir(file), + expectedFile: file, + multiFile: len(target.Files) != 1, + graphWorkDir: filepath.Dir(file), + }, nil +} + +func isMissingPathError(err error) bool { + return os.IsNotExist(err) || os.IsNotExist(errors.Unwrap(err)) +} diff --git a/cmd/internal/projectdriver/resolve_test.go b/cmd/internal/projectdriver/resolve_test.go new file mode 100644 index 000000000..3a327d5f9 --- /dev/null +++ b/cmd/internal/projectdriver/resolve_test.go @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package projectdriver + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/goplus/xgo/x/xgoprojs" +) + +func TestLoadResolvedClassesWithoutClassMetadata(t *testing.T) { + root := t.TempDir() + goMod := filepath.Join(root, "go.mod") + mustModuleFile(t, goMod, "example.test/plain") + target := ResolvedModule{ + Selected: ModuleRef{Path: "example.test/plain", Dir: root, GoMod: goMod}, + Main: true, + } + module, hasClass, err := loadResolvedClasses(&effectiveGraph{ + Target: target, + TargetModFile: fileIdentity{Path: goMod}, + }) + if err != nil { + t.Fatal(err) + } + if hasClass || module != nil { + t.Fatalf("loadResolvedClasses() = %#v, %t; want nil, false", module, hasClass) + } +} + +func TestResolveDriverTargets(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newDriverFixture(t) + for name, target := range map[string]xgoprojs.Proj{ + "directory": &xgoprojs.DirProj{Dir: fixture.project}, + "file": &xgoprojs.FilesProj{Files: []string{fixture.mainFile}}, + "package": &xgoprojs.PkgPathProj{Path: "example.test/app/game"}, + } { + t.Run(name, func(t *testing.T) { + drv := resolveDriver(t, fixture.resolver(t), target) + if drv.ProjectDir != canonicalDir(t, fixture.project) || drv.ProjectFile != canonicalFile(t, fixture.mainFile) { + t.Fatalf("project identity = %q, %q", drv.ProjectDir, drv.ProjectFile) + } + if drv.DriverPackage != "example.test/framework/cmd/driver" || drv.Protocol != "v1" { + t.Fatalf("driver = %#v", drv) + } + if drv.Origin.Selected.Path != "example.test/framework" || drv.Origin.Selected.Version != "v1.2.3" || drv.Origin.Replace == nil { + t.Fatalf("origin = %#v", drv.Origin) + } + if drv.Origin.Selected.Dir != "" || drv.Origin.Replace.Path != canonicalDir(t, fixture.framework) { + t.Fatalf("replacement identity was flattened: %#v", drv.Origin) + } + if drv.PackDir != "pack" || drv.PackIndex != "index.data" || len(drv.GoxModSHA256) != 64 { + t.Fatalf("metadata = %#v", drv) + } + }) + } +} + +func TestResolveRejectsAnyNestedDriverDispatch(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newDriverFixture(t) + resolver := fixture.resolver(t) + target := &xgoprojs.DirProj{Dir: fixture.project} + guards := map[string]string{ + "same driver": driverGuard(canonicalDir(t, fixture.project), "example.test/framework/cmd/driver"), + "different driver": driverGuard(canonicalDir(t, fixture.app), "example.test/other/cmd/driver"), + } + for name, guard := range guards { + t.Run(name, func(t *testing.T) { + t.Setenv(driverGuardEnv, guard) + if _, err := resolver.Resolve(context.Background(), target); !errors.Is(err, ErrDriverRecursive) { + t.Fatalf("Resolve() = %v, want ErrDriverRecursive", err) + } + }) + } +} + +func TestResolvePackageTargetKeepsCallerGraph(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newDriverFixture(t) + dependencyProject := filepath.Join(fixture.framework, "example") + mustMkdirAll(t, filepath.Join(dependencyProject, "pack")) + mustWriteFile(t, filepath.Join(dependencyProject, "main.foo"), "// dependency project\n") + mustWriteFile(t, filepath.Join(dependencyProject, "pack", "index.data"), "{}\n") + + resolver := fixture.resolver(t) + drv := resolveDriver(t, resolver, &xgoprojs.PkgPathProj{Path: "example.test/framework/example"}) + if drv.ModuleRoot != canonicalDir(t, fixture.framework) || drv.Graph.WorkDir != canonicalDir(t, fixture.app) { + t.Fatalf("graph roots = module %q, work %q", drv.ModuleRoot, drv.Graph.WorkDir) + } + if drv.Origin.Main || drv.Origin.Replace == nil || drv.Origin.Selected.Version != "v1.2.3" { + t.Fatalf("caller replacement graph was lost: %#v", drv.Origin) + } + if err := validateDriver(context.Background(), drv); err != nil { + t.Fatalf("driver validation switched graphs: %v", err) + } +} + +func TestResolveXGoOnlyPackageTargetWithModfile(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newDriverFixture(t) + data, err := os.ReadFile(filepath.Join(fixture.app, "go.mod")) + if err != nil { + t.Fatal(err) + } + mustWriteFile(t, filepath.Join(fixture.app, "driver.mod"), string(data)) + resolver := fixture.resolver(t, "-modfile=driver.mod") + if _, err := resolver.Resolve(context.Background(), &xgoprojs.PkgPathProj{Path: "example.test/app/game"}); err != nil { + t.Fatal(err) + } +} diff --git a/cmd/internal/projectdriver/resolve_vendor.go b/cmd/internal/projectdriver/resolve_vendor.go new file mode 100644 index 000000000..3fdb2e047 --- /dev/null +++ b/cmd/internal/projectdriver/resolve_vendor.go @@ -0,0 +1,127 @@ +/* + * 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 projectdriver + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/goplus/mod/modload" +) + +// preflightClassMetadata reads target metadata without loading the graph. +// This keeps legacy vendor targets on the existing path. +func (r *Resolver) preflightClassMetadata(ctx context.Context, dir string) (loaded modload.Module, moduleGoMod string, hasClass, vendor bool, err error) { + goMod, err := goModForDir(ctx, r.policy.graph, dir) + if err != nil { + return loaded, "", false, false, err + } + if goMod == "" || goMod == os.DevNull { + return loaded, "", false, false, errNoGoModule + } + moduleGoMod, err = canonicalExistingFile(goMod) + if err != nil { + return loaded, "", false, false, err + } + effectiveMod := moduleGoMod + if r.policy.graph.ModFile != "" { + effectiveMod = r.policy.graph.ModFile + } + identity, classMods, err := readTargetModFile(effectiveMod) + if err != nil { + return loaded, moduleGoMod, false, false, err + } + loaded, err = loadDriverModule(identity.Path, filepath.Join(filepath.Dir(moduleGoMod), "gox.mod")) + if err != nil { + return loaded, moduleGoMod, false, false, err + } + hasClass = len(classMods) != 0 || loaded.HasProject() + vendor, err = effectiveVendorMode(r.policy.graph, moduleGoMod, loaded.File) + return loaded, moduleGoMod, hasClass, vendor, err +} + +// probeVendorProject classifies a target without go list -m all. +// External class metadata is unavailable in standard vendor snapshots. +func (r *Resolver) probeVendorProject(projectDir string, target modload.Module, recursive bool) (bool, error) { + return r.matchVendorModule(projectDir, target, recursive) +} + +// probeVendorPackage uses package-specific go list, which works in vendor mode. +// Only main/workspace metadata is authoritative. +func (r *Resolver) probeVendorPackage(ctx context.Context, moduleGoMod string, target modload.Module, importPath string, recursive bool) (bool, error) { + if classPath := externalClassModule(target); classPath != "" { + return false, r.vendorClassMetadataError(classPath) + } + pkg, err := listPackageTarget(ctx, importPath, r.cwd, r.policy.graph) + if err != nil { + return false, err + } + if pkg.ImportPath != importPath { + return false, fmt.Errorf("package target %q resolved as %q", importPath, pkg.ImportPath) + } + if pkg.Dir == "" || pkg.Module == nil { + if moduleContainsPackage(target.Path(), importPath) { + return r.probeVendorPackageInModule(ctx, moduleGoMod, target, importPath, recursive) + } + if r.policy.graph.GoWork != "off" { + return r.probeVendorWorkspacePackage(ctx, importPath, recursive) + } + return false, nil + } + if !pkg.Module.Main { + // An unmarked dependency cannot expand the driver trust boundary. + return false, nil + } + module, err := normalizeListedModule(*pkg.Module) + if err != nil { + return false, fmt.Errorf("package target %q: %w", importPath, err) + } + root := module.Effective().Dir + projectDir, err := canonicalExistingDir(pkg.Dir) + if err != nil { + return false, fmt.Errorf("package target %q: %w", importPath, err) + } + if !pathWithin(root, projectDir) { + return false, fmt.Errorf("package target %q escapes module %q", importPath, module.Selected.Path) + } + loaded, err := loadDriverModule(module.Effective().GoMod, filepath.Join(root, "gox.mod")) + if err != nil { + return false, err + } + return r.matchVendorModule(projectDir, loaded, recursive) +} + +func (r *Resolver) probeVendorPackageInModule(ctx context.Context, moduleGoMod string, target modload.Module, importPath string, recursive bool) (bool, error) { + root := filepath.Dir(moduleGoMod) + suffix := strings.TrimPrefix(importPath, target.Path()) + dir := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(suffix, "/"))) + projectDir, err := canonicalExistingDir(dir) + if err != nil || !pathWithin(root, projectDir) { + return false, nil + } + same, err := moduleOwnsPackage(ctx, r.policy.graph, moduleGoMod, projectDir) + if err != nil { + return false, err + } + if !same { + return false, nil + } + return matchVendorProjects(projectDir, target.Projects(), recursive) +} diff --git a/cmd/internal/projectdriver/resolve_vendor_classify.go b/cmd/internal/projectdriver/resolve_vendor_classify.go new file mode 100644 index 000000000..1835f0eea --- /dev/null +++ b/cmd/internal/projectdriver/resolve_vendor_classify.go @@ -0,0 +1,69 @@ +/* + * 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 projectdriver + +import ( + "fmt" + "os" + + "github.com/goplus/mod/modfile" + "github.com/goplus/mod/modload" +) + +func (r *Resolver) vendorClassMetadataError(modulePath string) error { + return fmt.Errorf("%w: class module %q metadata is not represented by standard Go vendor data", vendorUnsupportedError(string(r.policy.graph.ModMode)), modulePath) +} + +func (r *Resolver) matchVendorModule(projectDir string, module modload.Module, recursive bool) (bool, error) { + if classPath := externalClassModule(module); classPath != "" { + return false, r.vendorClassMetadataError(classPath) + } + return matchVendorProjects(projectDir, module.Projects(), recursive) +} + +func matchVendorProjects(dir string, projects []*modfile.Project, recursive bool) (bool, error) { + if recursive { + return patternContainsDriverProjects(dir, projects) + } + return hasDriverProject(dir, projects) +} + +func hasDriverProject(dir string, projects []*modfile.Project) (bool, error) { + if len(projects) == 0 { + return false, nil + } + entries, err := os.ReadDir(dir) + if err != nil { + return false, err + } + for _, entry := range entries { + if entry.Type()&os.ModeSymlink != 0 { + continue + } + info, err := entry.Info() + if err != nil { + return false, err + } + if !info.Mode().IsRegular() { + continue + } + if driverProjectMatches(projects, entry.Name()) { + return true, nil + } + } + return false, nil +} diff --git a/cmd/internal/projectdriver/resolve_vendor_mode.go b/cmd/internal/projectdriver/resolve_vendor_mode.go new file mode 100644 index 000000000..b1fc6a11e --- /dev/null +++ b/cmd/internal/projectdriver/resolve_vendor_mode.go @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package projectdriver + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + gomodfile "golang.org/x/mod/modfile" +) + +func effectiveVendorMode(policy GraphPolicy, moduleGoMod string, parsed *gomodfile.File) (bool, error) { + if policy.ModMode != "" { + return policy.ModMode == modModeVendor, nil + } + workspace := policy.GoWork != "" && policy.GoWork != "off" + var ( + goVersion string + vendorDir string + ) + if workspace { + data, err := os.ReadFile(policy.GoWork) + if err != nil { + return false, err + } + work, err := gomodfile.ParseWork(policy.GoWork, data, nil) + if err != nil { + return false, err + } + if work.Go != nil { + goVersion = work.Go.Version + } + vendorDir = filepath.Join(filepath.Dir(policy.GoWork), "vendor") + } else { + if parsed.Go != nil { + goVersion = parsed.Go.Version + } + vendorDir = filepath.Join(filepath.Dir(moduleGoMod), "vendor") + } + if goVersion == "" || !versionAtLeast(goVersion, 1, 14) { + return false, nil + } + info, err := os.Stat(vendorDir) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, err + } + if !info.IsDir() { + return false, nil + } + vendoredWorkspace, err := vendorManifestIsForWorkspace(vendorDir) + if err != nil { + return false, err + } + return vendoredWorkspace == workspace, nil +} + +// vendorManifestIsForWorkspace mirrors cmd/go's workspace marker. +// A missing modules.txt is treated as module vendor mode. +func vendorManifestIsForWorkspace(vendorDir string) (bool, error) { + file, err := os.Open(filepath.Join(vendorDir, "modules.txt")) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, err + } + defer file.Close() + var buf [512]byte + n, err := file.Read(buf[:]) + if err != nil && !errors.Is(err, io.EOF) { + return false, err + } + line, _, _ := strings.Cut(string(buf[:n]), "\n") + annotations, ok := strings.CutPrefix(line, "## ") + if !ok { + return false, nil + } + for entry := range strings.SplitSeq(annotations, ";") { + if strings.TrimSpace(entry) == "workspace" { + return true, nil + } + } + return false, nil +} + +func versionAtLeast(version string, major, minor int) bool { + var gotMajor, gotMinor int + if _, err := fmt.Sscanf(version, "%d.%d", &gotMajor, &gotMinor); err != nil { + return false + } + return gotMajor > major || gotMajor == major && gotMinor >= minor +} + +func vendorUnsupportedError(mode string) error { + if mode == "" { + mode = "automatic vendor mode" + } else { + mode = "-mod=" + mode + } + return fmt.Errorf("%w (%s); select -mod=readonly or -mod=mod explicitly", ErrDriverVendorUnsupported, mode) +} diff --git a/cmd/internal/projectdriver/resolve_vendor_mode_test.go b/cmd/internal/projectdriver/resolve_vendor_mode_test.go new file mode 100644 index 000000000..c762a31b1 --- /dev/null +++ b/cmd/internal/projectdriver/resolve_vendor_mode_test.go @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package projectdriver + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "github.com/goplus/mod/modload" + "github.com/goplus/xgo/x/xgoprojs" +) + +func TestEffectiveVendorModeMatchesGoCommandDefaults(t *testing.T) { + root := t.TempDir() + app := filepath.Join(root, "app") + vendorDir := filepath.Join(app, "vendor") + mustMkdirAll(t, vendorDir) + goMod := filepath.Join(app, "go.mod") + mustWriteFile(t, goMod, "module example.test/app\n\ngo 1.21\n") + loaded, err := modload.LoadFrom(goMod, filepath.Join(app, "gox.mod")) + if err != nil { + t.Fatal(err) + } + + modulePolicy := GraphPolicy{GoWork: "off"} + if vendor, err := effectiveVendorMode(modulePolicy, goMod, loaded.File); err != nil || !vendor { + t.Fatalf("module vendor without modules.txt = %v, %v; want true", vendor, err) + } + mustWriteFile(t, filepath.Join(vendorDir, "modules.txt"), "## workspace\n") + if vendor, err := effectiveVendorMode(modulePolicy, goMod, loaded.File); err != nil || vendor { + t.Fatalf("workspace manifest outside workspace = %v, %v; want false", vendor, err) + } + mustWriteFile(t, filepath.Join(vendorDir, "modules.txt"), "# module manifest\n") + if vendor, err := effectiveVendorMode(modulePolicy, goMod, loaded.File); err != nil || !vendor { + t.Fatalf("module manifest in module mode = %v, %v; want true", vendor, err) + } + + goWork := filepath.Join(root, "go.work") + mustWriteFile(t, goWork, "go 1.21\n\nuse ./app\n") + workspaceVendor := filepath.Join(root, "vendor") + mustMkdirAll(t, workspaceVendor) + mustWriteFile(t, filepath.Join(workspaceVendor, "modules.txt"), "# module manifest\n") + workspacePolicy := GraphPolicy{GoWork: goWork} + if vendor, err := effectiveVendorMode(workspacePolicy, goMod, loaded.File); err != nil || vendor { + t.Fatalf("module manifest in workspace mode = %v, %v; want false", vendor, err) + } + mustWriteFile(t, filepath.Join(workspaceVendor, "modules.txt"), "## workspace; future annotation\n") + if vendor, err := effectiveVendorMode(workspacePolicy, goMod, loaded.File); err != nil || !vendor { + t.Fatalf("Go 1.21 workspace manifest = %v, %v; want true", vendor, err) + } +} + +func TestResolveVendorIgnoresUnmarkedDependencyDriver(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + app := t.TempDir() + project := filepath.Join(app, "game") + framework := filepath.Join(app, "framework") + mustMkdirAll(t, project) + mustMkdirAll(t, framework) + mustMkdirAll(t, filepath.Join(app, "vendor")) + mustWriteFile(t, filepath.Join(app, "vendor", "modules.txt"), "# vendored\n") + mustWriteFile(t, filepath.Join(app, "go.mod"), `module example.test/app + +go 1.25 + +require example.test/framework v1.2.3 + +replace example.test/framework => ./framework +`) + // The target module has class metadata, so vendor preflight must make a + // positive trust decision rather than returning early before probing. + mustWriteFile(t, filepath.Join(app, "gox.mod"), "xgo 1.8\nproject main.legacy Game example.test/app\n") + mustWriteFile(t, filepath.Join(framework, "go.mod"), "module example.test/framework\n\ngo 1.25\n") + mustWriteFile(t, filepath.Join(framework, "gox.mod"), "xgo 1.8\nproject main.foo Game example.test/framework\ndriver v1 example.test/framework/cmd/driver\n") + mustWriteFile(t, filepath.Join(project, "main.foo"), "// unmarked dependency driver-backed project\n") + t.Setenv("GOWORK", "off") + resolver, err := NewResolver(context.Background(), app, []string{"-mod=vendor"}) + if err != nil { + t.Fatal(err) + } + _, err = resolver.Resolve(context.Background(), &xgoprojs.DirProj{Dir: project}) + if !errors.Is(err, ErrNotHandled) { + t.Fatalf("unmarked vendor dependency driver = %v, want ErrNotHandled", err) + } +} diff --git a/cmd/internal/projectdriver/resolve_vendor_test.go b/cmd/internal/projectdriver/resolve_vendor_test.go new file mode 100644 index 000000000..4c671d7c7 --- /dev/null +++ b/cmd/internal/projectdriver/resolve_vendor_test.go @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package projectdriver + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/goplus/xgo/x/xgoprojs" +) + +func TestResolveDriverVendorFailsClosed(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + fixture := newDriverFixture(t) + mustMkdirAll(t, filepath.Join(fixture.app, "vendor")) + mustWriteFile(t, filepath.Join(fixture.app, "vendor", "modules.txt"), "# fixture\n") + _, err := fixture.resolver(t).Resolve(context.Background(), &xgoprojs.DirProj{Dir: fixture.project}) + if !errors.Is(err, ErrDriverVendorUnsupported) { + t.Fatalf("vendor error = %v", err) + } +} + +func TestResolveVendorClassifiesLegacyTargetsConservatively(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + tests := []struct { + name string + class bool + auto bool + flags []string + wantUnsupported bool + }{ + {name: "main-explicit", flags: []string{"-mod=vendor"}}, + {name: "main-automatic", auto: true}, + {name: "class-explicit", class: true, flags: []string{"-mod=vendor"}, wantUnsupported: true}, + {name: "class-automatic", class: true, auto: true, wantUnsupported: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + app := t.TempDir() + project := filepath.Join(app, "game") + mustMkdirAll(t, project) + mustMkdirAll(t, filepath.Join(app, "vendor")) + mustWriteFile(t, filepath.Join(app, "vendor", "modules.txt"), "# vendored\n") + if test.class { + framework := filepath.Join(app, "framework") + mustMkdirAll(t, framework) + mustWriteFile(t, filepath.Join(framework, "go.mod"), "module example.test/framework\n\ngo 1.25\n") + mustWriteFile(t, filepath.Join(framework, "gox.mod"), "xgo 1.8\nproject main.foo Game example.test/framework\n") + mustWriteFile(t, filepath.Join(app, "go.mod"), "module example.test/app\n\ngo 1.25\n\nrequire example.test/framework v1.2.3 //xgo:class\n\nreplace example.test/framework => ./framework\n") + } else { + mustWriteFile(t, filepath.Join(app, "go.mod"), "module example.test/app\n\ngo 1.25\n") + mustWriteFile(t, filepath.Join(app, "gox.mod"), "xgo 1.8\nproject main.foo Game example.test/app\n") + } + mustWriteFile(t, filepath.Join(project, "main.foo"), "// legacy project\n") + t.Setenv("GOWORK", "off") + resolver, err := NewResolver(context.Background(), app, test.flags) + if err != nil { + t.Fatal(err) + } + if test.auto { + resolver.policy.graph.ModMode = "" + } + _, err = resolver.Resolve(context.Background(), &xgoprojs.DirProj{Dir: project}) + if test.wantUnsupported { + if !errors.Is(err, ErrDriverVendorUnsupported) { + t.Fatalf("external class vendor target = %v, want ErrDriverVendorUnsupported", err) + } + } else if !errors.Is(err, ErrNotHandled) { + t.Fatalf("main-module legacy vendor target = %v, want ErrNotHandled", err) + } + }) + } +} + +func TestResolveDriverTargetStillRejectsVendor(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + app := t.TempDir() + project := filepath.Join(app, "game") + framework := filepath.Join(app, "framework") + mustMkdirAll(t, project) + mustMkdirAll(t, framework) + mustMkdirAll(t, filepath.Join(app, "vendor")) + mustWriteFile(t, filepath.Join(app, "vendor", "modules.txt"), "# vendored\n") + mustWriteFile(t, filepath.Join(app, "go.mod"), "module example.test/app\n\ngo 1.25\n\nrequire example.test/framework v1.2.3 //xgo:class\n\nreplace example.test/framework => ./framework\n") + mustWriteFile(t, filepath.Join(framework, "go.mod"), "module example.test/framework\n\ngo 1.25\n") + mustWriteFile(t, filepath.Join(framework, "gox.mod"), "xgo 1.8\nproject main.foo Game example.test/framework\ndriver v1 example.test/framework/cmd/driver\n") + mustWriteFile(t, filepath.Join(project, "main.foo"), "// driver-backed project\n") + t.Setenv("GOWORK", "off") + resolver, err := NewResolver(context.Background(), app, []string{"-mod=vendor"}) + if err != nil { + t.Fatal(err) + } + _, err = resolver.Resolve(context.Background(), &xgoprojs.DirProj{Dir: project}) + if !errors.Is(err, ErrDriverVendorUnsupported) { + t.Fatalf("driver vendor target = %v", err) + } +} + +func TestResolveRealModuleVendorExternalClassFailsClosed(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + root := t.TempDir() + app := filepath.Join(root, "app") + project := filepath.Join(app, "game") + framework := filepath.Join(root, "framework") + mustMkdirAll(t, project) + mustMkdirAll(t, filepath.Join(framework, "cmd", "driver")) + mustWriteFile(t, filepath.Join(app, "go.mod"), `module example.test/app + +go 1.25 + +require example.test/framework v1.2.3 //xgo:class + +replace example.test/framework => ../framework +`) + mustWriteFile(t, filepath.Join(app, "main.go"), "package app\n\nimport _ \"example.test/framework/cmd/driver\"\n") + mustWriteFile(t, filepath.Join(framework, "go.mod"), "module example.test/framework\n\ngo 1.25\n") + mustWriteFile(t, filepath.Join(framework, "gox.mod"), `xgo 1.8 +project main.foo Game example.test/framework +driver v1 example.test/framework/cmd/driver +`) + mustWriteFile(t, filepath.Join(framework, "cmd", "driver", "driver.go"), "package driver\n") + mustWriteFile(t, filepath.Join(project, "main.foo"), "// driver-backed project\n") + mustRunGo(t, app, "off", "mod", "vendor") + vendoredFramework := filepath.Join(app, "vendor", "example.test", "framework") + if _, err := os.Stat(filepath.Join(vendoredFramework, "cmd", "driver", "driver.go")); err != nil { + t.Fatalf("real vendor snapshot omitted imported driver package: %v", err) + } + if _, err := os.Stat(filepath.Join(vendoredFramework, "gox.mod")); !os.IsNotExist(err) { + t.Fatalf("real vendor snapshot unexpectedly contains module-root gox.mod: %v", err) + } + t.Setenv("GOWORK", "off") + resolver, err := NewResolver(context.Background(), app, []string{"-mod=vendor"}) + if err != nil { + t.Fatal(err) + } + for name, target := range map[string]xgoprojs.Proj{ + "directory": &xgoprojs.DirProj{Dir: project}, + "package": &xgoprojs.PkgPathProj{Path: "example.test/app/game"}, + } { + t.Run(name, func(t *testing.T) { + if _, err := resolver.Resolve(context.Background(), target); !errors.Is(err, ErrDriverVendorUnsupported) { + t.Fatalf("real vendored class target = %v, want ErrDriverVendorUnsupported", err) + } + }) + } +} + +func TestResolveRealModuleVendorPlainPackageUsesLegacyPath(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + root := t.TempDir() + app := filepath.Join(root, "app") + dependency := filepath.Join(root, "dependency") + mustMkdirAll(t, app) + mustMkdirAll(t, dependency) + mustWriteFile(t, filepath.Join(app, "go.mod"), "module example.test/app\n\ngo 1.25\n\nrequire example.test/dependency v1.0.0\n\nreplace example.test/dependency => ../dependency\n") + mustWriteFile(t, filepath.Join(app, "main.go"), "package app\n\nimport _ \"example.test/dependency\"\n") + mustWriteFile(t, filepath.Join(dependency, "go.mod"), "module example.test/dependency\n\ngo 1.25\n") + mustWriteFile(t, filepath.Join(dependency, "dependency.go"), "package dependency\n") + mustRunGo(t, app, "off", "mod", "vendor") + t.Setenv("GOWORK", "off") + resolver, err := NewResolver(context.Background(), app, []string{"-mod=vendor"}) + if err != nil { + t.Fatal(err) + } + if _, err := resolver.Resolve(context.Background(), &xgoprojs.PkgPathProj{Path: "example.test/app"}); !errors.Is(err, ErrNotHandled) { + t.Fatalf("plain vendored package = %v, want ErrNotHandled", err) + } +} + +func TestResolveRealWorkspaceVendorClassFailsClosed(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + root := t.TempDir() + app := filepath.Join(root, "app") + project := filepath.Join(app, "game") + framework := filepath.Join(root, "framework") + mustMkdirAll(t, project) + mustMkdirAll(t, filepath.Join(framework, "cmd", "driver")) + mustWriteFile(t, filepath.Join(root, "go.work"), "go 1.25\n\nuse ./app\n\nreplace example.test/framework => ./framework\n") + mustWriteFile(t, filepath.Join(app, "go.mod"), "module example.test/app\n\ngo 1.25\n\nrequire example.test/framework v1.2.3 //xgo:class\n") + mustWriteFile(t, filepath.Join(app, "main.go"), "package app\n\nimport _ \"example.test/framework/cmd/driver\"\n") + mustWriteFile(t, filepath.Join(framework, "go.mod"), "module example.test/framework\n\ngo 1.25\n") + mustWriteFile(t, filepath.Join(framework, "gox.mod"), "xgo 1.8\nproject main.foo Game example.test/framework\ndriver v1 example.test/framework/cmd/driver\n") + mustWriteFile(t, filepath.Join(framework, "cmd", "driver", "driver.go"), "package driver\n") + mustWriteFile(t, filepath.Join(project, "main.foo"), "// driver-backed project\n") + goWork := filepath.Join(root, "go.work") + mustRunGo(t, root, goWork, "work", "vendor") + if _, err := os.Stat(filepath.Join(root, "vendor", "modules.txt")); err != nil { + t.Fatalf("workspace vendor snapshot missing modules.txt: %v", err) + } + t.Setenv("GOWORK", goWork) + resolver, err := NewResolver(context.Background(), app, nil) + if err != nil { + t.Fatal(err) + } + if _, err := resolver.Resolve(context.Background(), &xgoprojs.DirProj{Dir: project}); !errors.Is(err, ErrDriverVendorUnsupported) { + t.Fatalf("workspace vendored class target = %v, want ErrDriverVendorUnsupported", err) + } +} diff --git a/cmd/internal/projectdriver/resolve_vendor_workspace.go b/cmd/internal/projectdriver/resolve_vendor_workspace.go new file mode 100644 index 000000000..fd8222714 --- /dev/null +++ b/cmd/internal/projectdriver/resolve_vendor_workspace.go @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package projectdriver + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + gomodfile "golang.org/x/mod/modfile" +) + +type workspaceVendorMember struct { + modulePath string + root string + goMod string +} + +// probeVendorWorkspacePackage handles XGo-only packages in vendor mode. +// Only go.work use members are eligible. +func (r *Resolver) probeVendorWorkspacePackage(ctx context.Context, importPath string, recursive bool) (bool, error) { + members, err := loadWorkspaceVendorMembers(r.policy.graph.GoWork) + if err != nil { + return false, err + } + var selected *workspaceVendorMember + for i := range members { + member := &members[i] + if !moduleContainsPackage(member.modulePath, importPath) { + continue + } + if selected == nil || len(member.modulePath) > len(selected.modulePath) { + selected = member + } + } + if selected == nil { + return false, fmt.Errorf("%w: package target %q is not owned by a workspace member", vendorUnsupportedError(string(r.policy.graph.ModMode)), importPath) + } + + loaded, err := loadDriverModule(selected.goMod, filepath.Join(selected.root, "gox.mod")) + if err != nil { + return false, fmt.Errorf("load workspace member %q metadata: %w", selected.modulePath, err) + } + if loaded.Path() != selected.modulePath { + return false, fmt.Errorf("workspace member %q module path changed to %q during driver discovery", selected.modulePath, loaded.Path()) + } + if classPath := externalClassModule(loaded); classPath != "" { + return false, r.vendorClassMetadataError(classPath) + } + suffix := strings.TrimPrefix(importPath, selected.modulePath) + candidate := filepath.Join(selected.root, filepath.FromSlash(strings.TrimPrefix(suffix, "/"))) + projectDir, err := canonicalExistingDir(candidate) + if err != nil { + return false, fmt.Errorf("%w: package target %q has no classifiable workspace directory: %v", vendorUnsupportedError(string(r.policy.graph.ModMode)), importPath, err) + } + if !pathWithin(selected.root, projectDir) { + return false, fmt.Errorf("package target %q escapes workspace module %q", importPath, selected.modulePath) + } + ownerGoMod, err := goModForDir(ctx, r.policy.graph, projectDir) + if err != nil { + return false, fmt.Errorf("resolve package target %q module ownership: %w", importPath, err) + } + if ownerGoMod == "" || ownerGoMod == os.DevNull { + return false, fmt.Errorf("package target %q has no module ownership", importPath) + } + same, err := sameFile(selected.goMod, ownerGoMod) + if err != nil { + return false, fmt.Errorf("resolve package target %q module ownership: %w", importPath, err) + } + if !same { + return false, fmt.Errorf("package target %q crosses a nested module boundary", importPath) + } + return matchVendorProjects(projectDir, loaded.Projects(), recursive) +} + +func loadWorkspaceVendorMembers(goWork string) ([]workspaceVendorMember, error) { + data, err := os.ReadFile(goWork) + if err != nil { + return nil, fmt.Errorf("read workspace file %q: %w", goWork, err) + } + work, err := gomodfile.ParseWork(goWork, data, nil) + if err != nil { + return nil, err + } + workRoot := filepath.Dir(goWork) + members := make([]workspaceVendorMember, 0, len(work.Use)) + seenRoots := make(map[string]struct{}, len(work.Use)) + seenModules := make(map[string]string, len(work.Use)) + for _, use := range work.Use { + if use == nil || use.Path == "" { + return nil, fmt.Errorf("workspace %q contains an empty use path", goWork) + } + root := filepath.FromSlash(use.Path) + if !filepath.IsAbs(root) { + root = filepath.Join(workRoot, root) + } + root, err = canonicalExistingDir(root) + if err != nil { + return nil, fmt.Errorf("resolve workspace member %q: %w", use.Path, err) + } + if _, duplicate := seenRoots[root]; duplicate { + return nil, fmt.Errorf("workspace %q contains duplicate member directory %q", goWork, root) + } + seenRoots[root] = struct{}{} + + goModPath := filepath.Join(root, "go.mod") + info, err := os.Lstat(goModPath) + if err != nil { + return nil, fmt.Errorf("inspect workspace member go.mod %q: %w", goModPath, err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return nil, fmt.Errorf("workspace member go.mod %q is not a regular non-symlink file", goModPath) + } + originalGoModPath := goModPath + goModPath, err = canonicalExistingFile(originalGoModPath) + if err != nil { + return nil, fmt.Errorf("resolve workspace member go.mod %q: %w", originalGoModPath, err) + } + if filepath.Dir(goModPath) != root { + return nil, fmt.Errorf("workspace member go.mod %q escapes member root %q", goModPath, root) + } + goModData, err := os.ReadFile(goModPath) + if err != nil { + return nil, fmt.Errorf("read workspace member go.mod %q: %w", goModPath, err) + } + parsed, err := gomodfile.Parse(goModPath, goModData, nil) + if err != nil { + return nil, err + } + if parsed.Module == nil || parsed.Module.Mod.Path == "" { + return nil, fmt.Errorf("workspace member %q has no module path", root) + } + modulePath := parsed.Module.Mod.Path + if previous, duplicate := seenModules[modulePath]; duplicate { + return nil, fmt.Errorf("workspace module %q is declared by both %q and %q", modulePath, previous, root) + } + seenModules[modulePath] = root + members = append(members, workspaceVendorMember{modulePath: modulePath, root: root, goMod: goModPath}) + } + return members, nil +} diff --git a/cmd/internal/projectdriver/resolve_vendor_workspace_test.go b/cmd/internal/projectdriver/resolve_vendor_workspace_test.go new file mode 100644 index 000000000..7195efef5 --- /dev/null +++ b/cmd/internal/projectdriver/resolve_vendor_workspace_test.go @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package projectdriver + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/goplus/mod/modfile" + "github.com/goplus/xgo/x/xgoprojs" +) + +func TestResolveWorkspaceVendorPackageUsesTargetModuleMetadata(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + root := t.TempDir() + caller := filepath.Join(root, "caller") + driverModule := filepath.Join(root, "driver") + driverProject := filepath.Join(driverModule, "game") + legacyModule := filepath.Join(root, "legacy") + legacyProject := filepath.Join(legacyModule, "game") + mustMkdirAll(t, caller) + mustMkdirAll(t, driverProject) + mustMkdirAll(t, legacyProject) + mustWriteFile(t, filepath.Join(root, "go.work"), `go 1.25 + +use ( + ./caller + ./driver + ./legacy +) +`) + mustWriteFile(t, filepath.Join(caller, "go.mod"), "module example.test/caller\n\ngo 1.25\n") + mustWriteFile(t, filepath.Join(caller, "caller.go"), "package caller\n") + mustWriteFile(t, filepath.Join(driverModule, "go.mod"), "module example.test/driver\n\ngo 1.25\n") + mustWriteFile(t, filepath.Join(driverModule, "gox.mod"), "xgo 1.8\nproject main.foo Game example.test/driver\ndriver v1 example.test/driver/cmd/driver\n") + mustWriteFile(t, filepath.Join(driverProject, "main.foo"), "// driver-backed project\n") + mustWriteFile(t, filepath.Join(legacyModule, "go.mod"), "module example.test/legacy\n\ngo 1.25\n") + mustWriteFile(t, filepath.Join(legacyModule, "gox.mod"), "xgo 1.8\nproject main.legacy Game example.test/legacy\n") + mustWriteFile(t, filepath.Join(legacyProject, "main.legacy"), "// legacy project\n") + goWork := filepath.Join(root, "go.work") + t.Setenv("GOWORK", goWork) + resolver, err := NewResolver(context.Background(), caller, []string{"-mod=vendor"}) + if err != nil { + t.Fatal(err) + } + if _, err := resolver.Resolve(context.Background(), &xgoprojs.PkgPathProj{Path: "example.test/driver/game"}); !errors.Is(err, ErrDriverVendorUnsupported) { + t.Fatalf("workspace driver package in vendor mode = %v, want ErrDriverVendorUnsupported", err) + } + if _, err := resolver.Resolve(context.Background(), &xgoprojs.PkgPathProj{Path: "example.test/legacy/game"}); !errors.Is(err, ErrNotHandled) { + t.Fatalf("workspace legacy package in vendor mode = %v, want ErrNotHandled", err) + } + + if driver, err := resolver.probeVendorWorkspacePackage(context.Background(), "example.test/driver/game", false); err != nil || !driver { + t.Fatalf("workspace fallback driver classification = %v, %v; want true", driver, err) + } + if driver, err := resolver.probeVendorWorkspacePackage(context.Background(), "example.test/legacy/game", false); err != nil || driver { + t.Fatalf("workspace fallback legacy classification = %v, %v; want false", driver, err) + } +} + +func TestProbeVendorWorkspacePackageRejectsExternalClassWithoutReadingReplacement(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + root := t.TempDir() + caller := filepath.Join(root, "caller") + member := filepath.Join(root, "member") + project := filepath.Join(member, "game") + for _, dir := range []string{caller, project} { + mustMkdirAll(t, dir) + } + mustWriteFile(t, filepath.Join(root, "go.work"), `go 1.25 + +use ( + ./caller + ./member +) + +replace example.test/framework => ./missing-live-replacement +`) + mustWriteFile(t, filepath.Join(caller, "go.mod"), "module example.test/caller\n\ngo 1.25\n") + mustWriteFile(t, filepath.Join(member, "go.mod"), "module example.test/member\n\ngo 1.25\n\nrequire example.test/framework v1.0.0 //xgo:class\n") + mustWriteFile(t, filepath.Join(member, "gox.mod"), "xgo 1.8\nproject main.legacy Game example.test/member\n") + mustWriteFile(t, filepath.Join(project, "main.legacy"), "// indeterminate external class project\n") + + goWork := filepath.Join(root, "go.work") + t.Setenv("GOWORK", goWork) + resolver, err := NewResolver(context.Background(), caller, []string{"-mod=vendor"}) + if err != nil { + t.Fatal(err) + } + if _, err := resolver.probeVendorWorkspacePackage(context.Background(), "example.test/member/game", false); !errors.Is(err, ErrDriverVendorUnsupported) { + t.Fatalf("workspace external class classification = %v, want ErrDriverVendorUnsupported", err) + } +} + +func TestProbeVendorWorkspacePackageUsesLongestMemberAndRejectsNestedModule(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + root := t.TempDir() + caller := filepath.Join(root, "caller") + parent := filepath.Join(root, "parent") + child := filepath.Join(root, "child") + childProject := filepath.Join(child, "game") + nested := filepath.Join(parent, "nested") + nestedProject := filepath.Join(nested, "game") + for _, dir := range []string{caller, parent, childProject, nestedProject} { + mustMkdirAll(t, dir) + } + mustWriteFile(t, filepath.Join(root, "go.work"), "go 1.25\n\nuse (\n\t./caller\n\t./parent\n\t./child\n)\n") + mustWriteFile(t, filepath.Join(caller, "go.mod"), "module example.test/caller\n\ngo 1.25\n") + mustWriteFile(t, filepath.Join(parent, "go.mod"), "module example.test/shared\n\ngo 1.25\n") + mustWriteFile(t, filepath.Join(parent, "gox.mod"), "xgo 1.8\nproject main.legacy Game example.test/shared\n") + mustWriteFile(t, filepath.Join(child, "go.mod"), "module example.test/shared/sub\n\ngo 1.25\n") + mustWriteFile(t, filepath.Join(child, "gox.mod"), "xgo 1.8\nproject main.foo Game example.test/shared/sub\ndriver v1 example.test/shared/sub/cmd/driver\n") + mustWriteFile(t, filepath.Join(childProject, "main.foo"), "// driver-backed project in longest module match\n") + mustWriteFile(t, filepath.Join(nested, "go.mod"), "module example.test/nested\n\ngo 1.25\n") + mustWriteFile(t, filepath.Join(nestedProject, "main.legacy"), "// nested module project\n") + + goWork := filepath.Join(root, "go.work") + t.Setenv("GOWORK", goWork) + resolver, err := NewResolver(context.Background(), caller, []string{"-mod=vendor"}) + if err != nil { + t.Fatal(err) + } + if driver, err := resolver.probeVendorWorkspacePackage(context.Background(), "example.test/shared/sub/game", false); err != nil || !driver { + t.Fatalf("longest workspace member classification = %v, %v; want true", driver, err) + } + if _, err := resolver.probeVendorWorkspacePackage(context.Background(), "example.test/shared/nested/game", false); err == nil { + t.Fatal("workspace package crossing a nested module boundary was classified") + } +} + +func TestResolveVendorRecursivePatternFailsClosed(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + app := t.TempDir() + project := filepath.Join(app, "game") + mustMkdirAll(t, project) + mustWriteFile(t, filepath.Join(app, "go.mod"), "module example.test/app\n\ngo 1.25\n") + mustWriteFile(t, filepath.Join(app, "gox.mod"), "xgo 1.8\nproject main.foo Game example.test/app\ndriver v1 example.test/app/cmd/driver\n") + mustWriteFile(t, filepath.Join(app, "app.go"), "package app\n") + mustWriteFile(t, filepath.Join(project, "main.foo"), "// driver-backed project\n") + t.Setenv("GOWORK", "off") + resolver, err := NewResolver(context.Background(), app, []string{"-mod=vendor"}) + if err != nil { + t.Fatal(err) + } + for name, target := range map[string]xgoprojs.Proj{ + "directory": &xgoprojs.DirProj{Dir: filepath.Join(app, "...")}, + "package": &xgoprojs.PkgPathProj{Path: "example.test/app/..."}, + } { + t.Run(name, func(t *testing.T) { + if _, err := resolver.Resolve(context.Background(), target); !errors.Is(err, ErrDriverVendorUnsupported) { + t.Fatalf("recursive vendor target = %v, want ErrDriverVendorUnsupported", err) + } + }) + } +} + +func TestVendorDriverProbePropagatesFilesystemErrors(t *testing.T) { + projects := []*modfile.Project{{Driver: &modfile.Driver{Protocol: "v1", Package: "example.test/driver"}}} + if _, err := hasDriverProject(filepath.Join(t.TempDir(), "missing"), projects); err == nil { + t.Fatal("missing project directory was classified as no driver") + } + + if runtime.GOOS == "windows" { + t.Skip("self-referential symlink setup is not portable to Windows") + } + app := t.TempDir() + mustWriteFile(t, filepath.Join(app, "go.mod"), "module example.test/app\n\ngo 1.25\n") + mustWriteFile(t, filepath.Join(app, "gox.mod"), "xgo 1.8\nproject main.foo Game example.test/app\n") + mustWriteFile(t, filepath.Join(app, "main.foo"), "// legacy project\n") + if err := os.Symlink("vendor", filepath.Join(app, "vendor")); err != nil { + t.Fatal(err) + } + t.Setenv("GOWORK", "off") + resolver, err := NewResolver(context.Background(), app, nil) + if err != nil { + t.Fatal(err) + } + if _, err := resolver.Resolve(context.Background(), &xgoprojs.DirProj{Dir: app}); err == nil || errors.Is(err, ErrNotHandled) { + t.Fatalf("vendor manifest I/O failure = %v, want explicit error", err) + } +} diff --git a/cmd/internal/projectdriver/resolve_versioned.go b/cmd/internal/projectdriver/resolve_versioned.go new file mode 100644 index 000000000..7d2b469e7 --- /dev/null +++ b/cmd/internal/projectdriver/resolve_versioned.go @@ -0,0 +1,289 @@ +/* + * 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 projectdriver + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "golang.org/x/mod/module" +) + +// versionedPackageHasDriver classifies the requested version in an isolated graph. +// Caller graph metadata is deliberately not reused. +func (r *Resolver) versionedPackageHasDriver(ctx context.Context, target string) (bool, error) { + importPath, query, ok := splitVersionedPackageTarget(target) + if !ok { + return false, nil + } + + probeDir, err := os.MkdirTemp("", "xgo-driver-version-probe-") + if err != nil { + return false, fmt.Errorf("create versioned package graph probe: %w", err) + } + defer os.RemoveAll(probeDir) + probeMod := "module xgo.dev/projectdriver/versionprobe\n\ngo 1.25\n" + if err := os.WriteFile(filepath.Join(probeDir, "go.mod"), []byte(probeMod), 0o600); err != nil { + return false, fmt.Errorf("write versioned package graph probe: %w", err) + } + probeEnv := replaceEnv(os.Environ(), "GOWORK", "off") + probeEnv = replaceEnv(probeEnv, "GOFLAGS", "-mod=mod") + get := commandContext(ctx, r.policy.graph.GoCommand, "get", importPath+"@"+query) + get.Dir = probeDir + get.Env = probeEnv + get.Stdout = io.Discard + getErr := get.Run() + if getErr != nil { + // Continue with the structured probe; XGo-only packages may not be Go packages. + if cause := context.Cause(ctx); cause != nil { + return false, cause + } + } + + probePolicy := GraphPolicy{ + GoCommand: r.policy.graph.GoCommand, + GoWork: "off", + ModMode: modModeMod, + WorkDir: probeDir, + } + var pkg goListPackage + if getErr == nil { + pkg, err = listPackageTarget(ctx, importPath, probeDir, probePolicy) + } + var probeResult versionedProbeResult + if err == nil && pkg.ImportPath == importPath && pkg.Module != nil && pkg.Dir != "" { + listed, normalizeErr := normalizeListedModule(*pkg.Module) + err = normalizeErr + if err == nil { + projectDir, dirErr := canonicalExistingDir(pkg.Dir) + err = dirErr + if err == nil && pathWithin(listed.Effective().Dir, projectDir) { + probeResult = versionedProbeResult{ + state: versionedProbeMatch, + module: listed, + projectDir: projectDir, + } + } + } + } + if probeResult.state != versionedProbeMatch { + if cause := context.Cause(ctx); cause != nil { + return false, cause + } + probeResult, err = r.resolveVersionedModuleSource(ctx, probeDir, importPath, query, probeEnv) + if err != nil { + if cause := context.Cause(ctx); cause != nil { + return false, cause + } + return false, fmt.Errorf("resolve versioned package %q module: %w", target, err) + } + if probeResult.state != versionedProbeMatch { + return false, nil + } + } + listed := probeResult.module + projectDir := probeResult.projectDir + if err := listed.Validate(); err != nil { + return false, fmt.Errorf("validate versioned package %q module: %w", target, err) + } + probeMod = fmt.Sprintf("module xgo.dev/projectdriver/versionprobe\n\ngo 1.25\n\nrequire %s %s //xgo:class\n", listed.Selected.Path, listed.Selected.Version) + if err := os.WriteFile(filepath.Join(probeDir, "go.mod"), []byte(probeMod), 0o600); err != nil { + return false, fmt.Errorf("write versioned package graph probe: %w", err) + } + + graph, err := loadEffectiveGraph(ctx, probeDir, probePolicy) + if err != nil { + if cause := context.Cause(ctx); cause != nil { + return false, cause + } + return false, fmt.Errorf("load versioned package %q graph: %w", target, err) + } + requested, ok := graph.Modules[listed.Selected.Path] + if !ok || !sameModuleSelection(requested, listed) { + return false, fmt.Errorf("versioned package %q graph selected unexpected module %#v", target, requested) + } + if requested.Effective().Dir == "" || requested.Effective().GoMod == "" { + // `go list -find` already supplied the authoritative source fields. + requested = listed + graph.Modules[listed.Selected.Path] = requested + } + _, classPaths, err := readTargetModFile(requested.Effective().GoMod) + if err != nil { + return false, fmt.Errorf("read versioned package %q module metadata: %w", target, err) + } + for _, classPath := range classPaths { + classModule, ok := graph.Modules[classPath] + if !ok { + return false, fmt.Errorf("versioned package %q class module %q is absent from its isolated graph", target, classPath) + } + if classModule.Effective().Dir == "" || classModule.Effective().GoMod == "" { + classModule, err = downloadGraphModule(ctx, probeDir, probePolicy, classModule) + if err != nil { + if cause := context.Cause(ctx); cause != nil { + return false, cause + } + return false, fmt.Errorf("materialize versioned package %q class module %q: %w", target, classPath, err) + } + graph.Modules[classPath] = classModule + } + } + graph, err = retargetEffectiveGraph(graph, requested) + if err != nil { + return false, fmt.Errorf("retarget versioned package %q graph: %w", target, err) + } + module, hasClass, err := loadResolvedClasses(graph) + if err != nil { + return false, fmt.Errorf("load versioned package %q driver metadata: %w", target, err) + } + if !hasClass { + return false, nil + } + _, info, _, err := findProjectFile(projectDir, module) + if err != nil { + return false, fmt.Errorf("classify versioned package %q: %w", target, err) + } + return info != nil && info.Project != nil && info.Project.Driver != nil, nil +} + +type versionedProbeState uint8 + +const ( + versionedProbeMiss versionedProbeState = iota + versionedProbeMatch + versionedProbeNestedBoundary +) + +type versionedProbeResult struct { + state versionedProbeState + module ResolvedModule + projectDir string +} + +func (r *Resolver) resolveVersionedModuleSource(ctx context.Context, probeDir, importPath, query string, env []string) (versionedProbeResult, error) { + parts := strings.Split(importPath, "/") + for length := len(parts); length > 0; length-- { + candidate := strings.Join(parts[:length], "/") + if err := module.CheckPath(candidate); err != nil { + continue + } + result, err := r.downloadVersionedModule(ctx, probeDir, candidate, importPath, query, env) + if err != nil { + return versionedProbeResult{}, err + } + if result.state == versionedProbeNestedBoundary || result.state == versionedProbeMatch { + return result, nil + } + } + return versionedProbeResult{state: versionedProbeMiss}, nil +} + +func (r *Resolver) downloadVersionedModule(ctx context.Context, probeDir, candidate, importPath, query string, env []string) (versionedProbeResult, error) { + cmd := commandContext(ctx, r.policy.graph.GoCommand, "mod", "download", "-json", candidate+"@"+query) + cmd.Dir = probeDir + cmd.Env = env + var stdout bytes.Buffer + cmd.Stdout = &stdout + if err := cmd.Run(); err != nil { + if cause := context.Cause(ctx); cause != nil { + return versionedProbeResult{}, cause + } + return versionedProbeResult{state: versionedProbeMiss}, nil + } + var downloaded goDownloadModule + if err := json.Unmarshal(stdout.Bytes(), &downloaded); err != nil { + return versionedProbeResult{}, fmt.Errorf("decode downloaded module %q: %w", candidate, err) + } + if downloaded.Error != "" || downloaded.Path != candidate || downloaded.Version == "" || downloaded.Dir == "" || downloaded.GoMod == "" { + return versionedProbeResult{state: versionedProbeMiss}, nil + } + sourceDir, goMod, err := canonicalModuleSource(downloaded.Path, downloaded.Dir, downloaded.GoMod) + if err != nil { + return versionedProbeResult{state: versionedProbeMiss}, nil + } + state, projectDir, err := versionedModulePackageDir(sourceDir, downloaded.Path, importPath) + if err != nil { + return versionedProbeResult{}, err + } + if state != versionedProbeMatch { + return versionedProbeResult{state: state}, nil + } + return versionedProbeResult{ + state: versionedProbeMatch, + projectDir: projectDir, + module: ResolvedModule{ + Selected: ModuleRef{ + Path: downloaded.Path, + Version: downloaded.Version, + Dir: sourceDir, + GoMod: goMod, + }, + }, + }, nil +} + +func versionedModulePackageDir(moduleRoot, modulePath, importPath string) (versionedProbeState, string, error) { + if !moduleContainsPackage(modulePath, importPath) { + return versionedProbeMiss, "", nil + } + rel := strings.TrimPrefix(importPath, modulePath) + rel = strings.TrimPrefix(rel, "/") + projectDir, err := canonicalExistingDir(filepath.Join(moduleRoot, filepath.FromSlash(rel))) + if err != nil { + if os.IsNotExist(err) { + return versionedProbeMiss, "", nil + } + return versionedProbeMiss, "", err + } + if !pathWithin(moduleRoot, projectDir) { + return versionedProbeMiss, "", fmt.Errorf("package %q escapes module %q", importPath, modulePath) + } + for current := projectDir; current != moduleRoot; current = filepath.Dir(current) { + if _, err := os.Stat(filepath.Join(current, "go.mod")); err == nil { + return versionedProbeNestedBoundary, "", nil + } else if !os.IsNotExist(err) { + return versionedProbeMiss, "", err + } + } + return versionedProbeMatch, projectDir, nil +} + +func splitVersionedPackageTarget(target string) (importPath, query string, ok bool) { + index := strings.LastIndexByte(target, '@') + if index <= 0 || index == len(target)-1 { + return "", "", false + } + return target[:index], target[index+1:], true +} + +func sameModuleSelection(a, b ResolvedModule) bool { + if a.Main != b.Main || a.Selected.Path != b.Selected.Path || a.Selected.Version != b.Selected.Version { + return false + } + if (a.Replace == nil) != (b.Replace == nil) { + return false + } + if a.Replace == nil { + return true + } + return a.Replace.Path == b.Replace.Path && a.Replace.Version == b.Replace.Version +} diff --git a/cmd/internal/projectdriver/resolve_versioned_test.go b/cmd/internal/projectdriver/resolve_versioned_test.go new file mode 100644 index 000000000..7c859d32b --- /dev/null +++ b/cmd/internal/projectdriver/resolve_versioned_test.go @@ -0,0 +1,226 @@ +/* + * 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 projectdriver + +import ( + "context" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/goplus/xgo/x/xgoprojs" + "golang.org/x/mod/module" + modzip "golang.org/x/mod/zip" +) + +func TestResolveVersionedPackageUsesRequestedVersionMetadata(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + proxy := t.TempDir() + writeModuleProxy(t, proxy, "example.test/framework", map[string]map[string]string{ + "v1.0.0": { + "go.mod": "module example.test/framework\n\ngo 1.25\n", + "gox.mod": "xgo 1.8\nproject main.foo Game example.test/framework\ndriver v1 example.test/framework/cmd/driver\n", + "cmd/driver/driver.go": "package driver\n", + }, + }) + writeModuleProxy(t, proxy, "example.test/app/game", map[string]map[string]string{ + "v1.3.0": { + "go.mod": "module example.test/app/game\n\ngo 1.25\n", + "legacy.foo": "// nested legacy project\n", + }, + }) + writeModuleProxy(t, proxy, "example.test/app", map[string]map[string]string{ + "v1.0.0": { + "go.mod": "module example.test/app\n\ngo 1.25\n", + "main.go": "package main\nfunc main() {}\n", + }, + "v1.1.0": { + "go.mod": "module example.test/app\n\ngo 1.25\n\nrequire example.test/framework v1.0.0 //xgo:class\n", + "main.go": "package main\nfunc main() {}\n", + "main.foo": "// driver-backed project\n", + "ordinary/main.go": "package ordinary\n", + }, + "v1.2.0": { + "go.mod": "module example.test/app\n\ngo 1.25\n\nrequire example.test/framework v1.0.0 //xgo:class\n", + "main.foo": "// XGo-only driver-backed project\n", + "game/main.foo": "// XGo-only subdirectory driver-backed project\n", + }, + "v1.3.0": { + "go.mod": "module example.test/app\n\ngo 1.25\n\nrequire example.test/framework v1.0.0 //xgo:class\n", + "main.foo": "// latest driver-backed project\n", + "game/main.foo": "// parent project hidden by nested module\n", + }, + }) + cache := t.TempDir() + t.Cleanup(func() { + _ = filepath.Walk(cache, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if info.IsDir() { + return os.Chmod(path, 0700) + } + return os.Chmod(path, 0600) + }) + }) + proxyURL := (&url.URL{Scheme: "file", Path: filepath.ToSlash(proxy)}).String() + t.Setenv("GOMODCACHE", cache) + t.Setenv("GOPROXY", proxyURL) + t.Setenv("GOSUMDB", "off") + bin := t.TempDir() + t.Setenv("GOBIN", bin) + t.Setenv("GOWORK", "off") + + for _, test := range []struct { + name string + callerVersion string + callerClass bool + target string + wantDriver bool + }{ + {name: "legacy requested version ignores driver caller graph", callerVersion: "v1.1.0", callerClass: true, target: "example.test/app@v1.0.0"}, + {name: "driver requested version ignores legacy caller graph", callerVersion: "v1.0.0", target: "example.test/app@v1.1.0", wantDriver: true}, + {name: "latest query uses selected version metadata", callerVersion: "v1.0.0", target: "example.test/app@latest", wantDriver: true}, + {name: "XGo-only driver version is classified", callerVersion: "v1.0.0", target: "example.test/app@v1.2.0", wantDriver: true}, + {name: "XGo-only subdirectory uses parent module", callerVersion: "v1.0.0", target: "example.test/app/game@v1.2.0", wantDriver: true}, + {name: "nested module wins over parent prefix", callerVersion: "v1.0.0", target: "example.test/app/game@v1.3.0"}, + {name: "ordinary package in driver version remains legacy", callerVersion: "v1.0.0", target: "example.test/app/ordinary@v1.1.0"}, + } { + t.Run(test.name, func(t *testing.T) { + caller := t.TempDir() + marker := "" + if test.callerClass { + marker = " //xgo:class" + } + mustWriteFile(t, filepath.Join(caller, "go.mod"), fmt.Sprintf("module example.test/caller\n\ngo 1.25\n\nrequire example.test/app %s%s\n", test.callerVersion, marker)) + resolver, err := NewResolver(context.Background(), caller, nil) + if err != nil { + t.Fatal(err) + } + _, err = resolver.Resolve(context.Background(), &xgoprojs.PkgPathProj{Path: test.target}) + if test.wantDriver { + if err == nil || errors.Is(err, ErrNotHandled) || !strings.Contains(err.Error(), "@version") { + t.Fatalf("Resolve(%q) = %v, want explicit @version rejection", test.target, err) + } + return + } + if !errors.Is(err, ErrNotHandled) { + t.Fatalf("Resolve(%q) = %v, want ErrNotHandled", test.target, err) + } + }) + } + entries, err := os.ReadDir(bin) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("versioned package probe installed files into GOBIN: %v", entries) + } +} + +func TestResolveVersionedPackageCanceledContext(t *testing.T) { + if testing.Short() { + t.Skip("invokes the host Go command") + } + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, "go.mod"), "module example.test/caller\n\ngo 1.25\n") + t.Setenv("GOWORK", "off") + bin := t.TempDir() + t.Setenv("GOBIN", bin) + resolver, err := NewResolver(context.Background(), dir, nil) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err = resolver.Resolve(ctx, &xgoprojs.PkgPathProj{Path: "example.test/app@latest"}) + if !errors.Is(err, context.Canceled) || errors.Is(err, ErrNotHandled) { + t.Fatalf("canceled versioned Resolve() = %v, want context.Canceled", err) + } + entries, readErr := os.ReadDir(bin) + if readErr != nil { + t.Fatal(readErr) + } + if len(entries) != 0 { + t.Fatalf("canceled versioned probe installed files into GOBIN: %v", entries) + } +} + +func writeModuleProxy(t *testing.T, proxy, modulePath string, versions map[string]map[string]string) { + t.Helper() + escaped, err := module.EscapePath(modulePath) + if err != nil { + t.Fatal(err) + } + moduleProxyDir := filepath.Join(proxy, filepath.FromSlash(escaped), "@v") + if err := os.MkdirAll(moduleProxyDir, 0755); err != nil { + t.Fatal(err) + } + versionNames := make([]string, 0, len(versions)) + for version := range versions { + versionNames = append(versionNames, version) + } + sort.Strings(versionNames) + if err := os.WriteFile(filepath.Join(moduleProxyDir, "list"), []byte(strings.Join(versionNames, "\n")+"\n"), 0644); err != nil { + t.Fatal(err) + } + for _, version := range versionNames { + files := versions[version] + source := t.TempDir() + for name, content := range files { + path := filepath.Join(source, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + } + mod := module.Version{Path: modulePath, Version: version} + zipPath := filepath.Join(moduleProxyDir, version+".zip") + zipFile, err := os.Create(zipPath) + if err != nil { + t.Fatal(err) + } + zipErr := modzip.CreateFromDir(zipFile, mod, source) + closeErr := zipFile.Close() + if zipErr != nil { + t.Fatal(zipErr) + } + if closeErr != nil { + t.Fatal(closeErr) + } + info := fmt.Sprintf("{\"Version\":%q,\"Time\":\"2026-01-01T00:00:00Z\"}\n", version) + if err := os.WriteFile(filepath.Join(moduleProxyDir, version+".info"), []byte(info), 0644); err != nil { + t.Fatal(err) + } + goMod, ok := files["go.mod"] + if !ok { + t.Fatalf("module %s@%s has no go.mod", modulePath, version) + } + if err := os.WriteFile(filepath.Join(moduleProxyDir, version+".mod"), []byte(goMod), 0644); err != nil { + t.Fatal(err) + } + } +} diff --git a/cmd/internal/projectdriver/signal_boundary_unix.go b/cmd/internal/projectdriver/signal_boundary_unix.go new file mode 100644 index 000000000..7163dddc1 --- /dev/null +++ b/cmd/internal/projectdriver/signal_boundary_unix.go @@ -0,0 +1,95 @@ +//go:build !windows + +/* + * 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 projectdriver + +import ( + "context" + "fmt" + "os" + "os/signal" + "sync" + "syscall" +) + +type driverSignalCause struct { + signal syscall.Signal +} + +func (c driverSignalCause) Error() string { + return fmt.Sprintf("driver interrupted by %s", c.signal) +} + +type driverSignalBoundary struct { + ctx context.Context + cancel context.CancelCauseFunc + signals chan os.Signal + done chan struct{} + wait sync.WaitGroup + mu sync.Mutex + signal syscall.Signal +} + +func beginDriverSignalBoundary(parent context.Context) *driverSignalBoundary { + if parent == nil { + parent = context.Background() + } + ctx, cancel := context.WithCancelCause(parent) + b := &driverSignalBoundary{ + ctx: ctx, cancel: cancel, signals: make(chan os.Signal, 8), done: make(chan struct{}), + } + signal.Notify(b.signals, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGQUIT) + b.wait.Add(1) + go func() { + defer b.wait.Done() + for { + select { + case received := <-b.signals: + unixSignal, ok := received.(syscall.Signal) + if !ok { + continue + } + b.mu.Lock() + if b.signal == 0 { + b.signal = unixSignal + b.cancel(driverSignalCause{signal: unixSignal}) + } + b.mu.Unlock() + case <-b.done: + return + } + } + }() + return b +} + +func (b *driverSignalBoundary) Context() context.Context { return b.ctx } + +func (b *driverSignalBoundary) Finish(status ProcessStatus, err error) (ProcessStatus, error) { + signal.Stop(b.signals) + close(b.done) + b.wait.Wait() + b.cancel(nil) + b.mu.Lock() + received := b.signal + b.mu.Unlock() + if received != 0 { + return ProcessStatus{Signal: received, Signaled: true}, nil + } + return status, err +} diff --git a/cmd/internal/projectdriver/signal_boundary_unix_test.go b/cmd/internal/projectdriver/signal_boundary_unix_test.go new file mode 100644 index 000000000..384c6c0c7 --- /dev/null +++ b/cmd/internal/projectdriver/signal_boundary_unix_test.go @@ -0,0 +1,47 @@ +//go:build !windows + +/* + * 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 projectdriver + +import ( + "context" + "os" + "syscall" + "testing" + "time" +) + +func TestDriverSignalBoundaryRecordsSignalAndCancelsWork(t *testing.T) { + boundary := beginDriverSignalBoundary(context.Background()) + if err := syscall.Kill(os.Getpid(), syscall.SIGTERM); err != nil { + t.Fatal(err) + } + select { + case <-boundary.Context().Done(): + case <-time.After(5 * time.Second): + t.Fatal("driver signal boundary did not cancel work") + } + cause, ok := context.Cause(boundary.Context()).(driverSignalCause) + if !ok || cause.signal != syscall.SIGTERM { + t.Fatalf("context cause = %#v, want SIGTERM driver signal", context.Cause(boundary.Context())) + } + status, err := boundary.Finish(ProcessStatus{}, context.Canceled) + if err != nil || !status.Signaled || status.Signal != syscall.SIGTERM { + t.Fatalf("Finish() = (%+v, %v), want SIGTERM status", status, err) + } +} diff --git a/cmd/internal/projectdriver/signal_boundary_windows.go b/cmd/internal/projectdriver/signal_boundary_windows.go new file mode 100644 index 000000000..42c0a14d1 --- /dev/null +++ b/cmd/internal/projectdriver/signal_boundary_windows.go @@ -0,0 +1,74 @@ +//go:build windows + +/* + * 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 projectdriver + +import ( + "context" + "os" + "os/signal" + "sync" +) + +type driverSignalBoundary struct { + ctx context.Context + cancel context.CancelFunc + signals chan os.Signal + done chan struct{} + wait sync.WaitGroup + mu sync.Mutex + interrupt bool +} + +func beginDriverSignalBoundary(parent context.Context) *driverSignalBoundary { + if parent == nil { + parent = context.Background() + } + ctx, cancel := context.WithCancel(parent) + b := &driverSignalBoundary{ctx: ctx, cancel: cancel, signals: make(chan os.Signal, 4), done: make(chan struct{})} + signal.Notify(b.signals, os.Interrupt) + b.wait.Add(1) + go func() { + defer b.wait.Done() + select { + case <-b.signals: + b.mu.Lock() + b.interrupt = true + b.mu.Unlock() + b.cancel() + case <-b.done: + } + }() + return b +} + +func (b *driverSignalBoundary) Context() context.Context { return b.ctx } + +func (b *driverSignalBoundary) Finish(status ProcessStatus, err error) (ProcessStatus, error) { + signal.Stop(b.signals) + close(b.done) + b.wait.Wait() + b.cancel() + b.mu.Lock() + interrupted := b.interrupt + b.mu.Unlock() + if interrupted { + return ProcessStatus{Code: 130}, nil + } + return status, err +} diff --git a/cmd/internal/projectdriver/status.go b/cmd/internal/projectdriver/status.go new file mode 100644 index 000000000..a24f6d506 --- /dev/null +++ b/cmd/internal/projectdriver/status.go @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package projectdriver + +import ( + "context" + "errors" + "os" + "os/exec" +) + +// Exit terminates a command handler with the exact driver status. Callers +// must invoke it only after all driver/output cleanup has completed. +func Exit(status ProcessStatus) { + if status.Signaled { + exitWithSignal(status.Signal) + } + os.Exit(status.Code) +} + +func processStatus(err error) (ProcessStatus, error) { + if err == nil { + return successStatus(), nil + } + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + return ProcessStatus{}, err + } + status := ProcessStatus{Code: exitErr.ExitCode()} + if signal, ok := exitSignal(exitErr); ok { + status.Signal, status.Signaled = signal, true + } + return status, nil +} + +func statusUnlessCanceled(ctx context.Context, status ProcessStatus) (ProcessStatus, error) { + if !status.Signaled && status.Code == 0 { + if cause := context.Cause(ctx); cause != nil { + return ProcessStatus{}, cause + } + } + return status, nil +} + +func driverExitStatus(ctx context.Context, waitErr error) (ProcessStatus, error) { + status, err := processStatus(waitErr) + if err != nil { + return ProcessStatus{}, err + } + return statusUnlessCanceled(ctx, status) +} diff --git a/cmd/internal/projectdriver/status_unix.go b/cmd/internal/projectdriver/status_unix.go new file mode 100644 index 000000000..f069fdfa5 --- /dev/null +++ b/cmd/internal/projectdriver/status_unix.go @@ -0,0 +1,39 @@ +//go:build !windows + +/* + * 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 projectdriver + +import ( + "os" + "os/signal" + "syscall" + "time" +) + +func exitWithSignal(sig os.Signal) { + unixSignal, ok := sig.(syscall.Signal) + if !ok { + os.Exit(1) + } + signal.Reset(unixSignal) + if err := syscall.Kill(os.Getpid(), unixSignal); err != nil { + os.Exit(128 + int(unixSignal)) + } + time.Sleep(time.Second) + os.Exit(128 + int(unixSignal)) +} diff --git a/cmd/internal/projectdriver/status_windows.go b/cmd/internal/projectdriver/status_windows.go new file mode 100644 index 000000000..3b776491c --- /dev/null +++ b/cmd/internal/projectdriver/status_windows.go @@ -0,0 +1,23 @@ +//go:build windows + +/* + * 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 projectdriver + +import "os" + +func exitWithSignal(os.Signal) { os.Exit(1) } diff --git a/cmd/internal/projectdriver/types.go b/cmd/internal/projectdriver/types.go new file mode 100644 index 000000000..f2417cc2f --- /dev/null +++ b/cmd/internal/projectdriver/types.go @@ -0,0 +1,128 @@ +/* + * 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 projectdriver implements XGo's private project-driver dispatcher. +package projectdriver + +import ( + "errors" + "io" + "os" + + "github.com/goplus/mod/driverprotocol" + "github.com/goplus/mod/xgomod" +) + +const protocolV1 = driverprotocol.Version1 + +var ( + // ErrNotHandled means that the target is not backed by a driver. + // It is the only result for which callers may use the legacy GenGo path. + ErrNotHandled = errors.New("driver not configured") + + ErrDriverVendorUnsupported = errors.New("drivers do not support vendor mode") + ErrDriverDisabled = errors.New("driver execution is disabled by XGO_DRIVER=off") + ErrDriverRecursive = errors.New("recursive driver invocation") + ErrDriverArgvTooLarge = errors.New("driver argv and environment are too large") +) + +type action = driverprotocol.Action + +const ( + actionRun = driverprotocol.ActionRun + actionBuild = driverprotocol.ActionBuild +) + +// TargetKind records the user-facing form of the resolved target. +type TargetKind int + +const ( + TargetDirectory TargetKind = iota + TargetFile + TargetPackage +) + +// ModuleRef and ResolvedModule share xgomod's canonical resolved identity. +type ModuleRef = xgomod.ModuleRef +type ResolvedModule = xgomod.ResolvedModule + +type modMode string + +const ( + modModeMod modMode = "mod" + modModeReadonly modMode = "readonly" + modModeVendor modMode = "vendor" +) + +// GraphPolicy is the exact module/workspace policy shared by discovery, +// validation, and driver construction. +type GraphPolicy struct { + GoCommand string + GoWork string + ModMode modMode + ModFile string + Overlay string + // WorkDir anchors all Go graph operations on both sides of the wire; + // driver execution itself still runs in ProjectDir. + WorkDir string +} + +// BuildPolicy is the driver-safe subset of XGo/Go build flags. +type BuildPolicy struct { + Verbose bool + Trace bool + KeepWork bool + TrimPath bool + DisableBuildVCS bool +} + +// Driver is the immutable discovery result passed to execution. +type Driver struct { + TargetKind TargetKind + OriginalTarget string + TargetImportPath string + DefaultExecName string + ProjectDir string + ProjectFile string + ModuleRoot string + DriverPackage string + Origin ResolvedModule + RequiredXGo string + Protocol string + ProjectExt string + ProjectFullExt string + PackDir string + PackIndex string + GoxMod string + GoxModSHA256 string + Graph GraphPolicy +} + +// Streams are inherited by the driver without using protocol files or stdin. +type Streams struct { + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer +} + +// ProcessStatus preserves a normal exit code or an operating-system signal. +type ProcessStatus struct { + Code int + Signal os.Signal + Signaled bool +} + +func successStatus() ProcessStatus { return ProcessStatus{Code: 0} } diff --git a/cmd/internal/projectdriver/version.go b/cmd/internal/projectdriver/version.go new file mode 100644 index 000000000..9888122ec --- /dev/null +++ b/cmd/internal/projectdriver/version.go @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package projectdriver + +import ( + "fmt" + "regexp" + "strings" + + "golang.org/x/mod/semver" +) + +var baseVersionRE = regexp.MustCompile(`(?:^|[^0-9])v?([0-9]+\.[0-9]+(?:\.[0-9]+)?(?:-[0-9A-Za-z.-]+)?)`) + +// driverCapability is the XGo capability level claimed by unversioned +// development builds. Released binaries continue to use env.Version(). +const driverCapability = "1.8.0" + +func checkRequiredXGo(required, current string) error { + if required == "" { + return nil + } + requiredSemver, ok := normalizeXGoVersion(required) + if !ok { + return fmt.Errorf("invalid required XGo version %q (xgo build %s)", required, describeDriverVersion(current)) + } + currentSemver, ok := comparableDriverVersion(current) + if !ok || semver.Compare(currentSemver, requiredSemver) < 0 { + return fmt.Errorf("declaring module requires XGo %s, but xgo build is %s", required, describeDriverVersion(current)) + } + return nil +} + +func comparableDriverVersion(version string) (string, bool) { + if isDevelopmentVersion(version) { + return normalizeXGoVersion(driverCapability) + } + return comparableCurrentVersion(version) +} + +func describeDriverVersion(version string) string { + if isDevelopmentVersion(version) { + return fmt.Sprintf("%s (driver capability %s)", version, driverCapability) + } + return version +} + +func isDevelopmentVersion(version string) bool { + version = strings.TrimSpace(version) + return version == "(devel)" || strings.HasSuffix(version, " devel") +} + +func normalizeXGoVersion(version string) (string, bool) { + version = strings.TrimSpace(strings.TrimPrefix(version, "v")) + if strings.Count(strings.SplitN(version, "-", 2)[0], ".") == 1 { + parts := strings.SplitN(version, "-", 2) + version = parts[0] + ".0" + if len(parts) == 2 { + version += "-" + parts[1] + } + } + version = "v" + version + return version, semver.IsValid(version) +} + +func comparableCurrentVersion(version string) (string, bool) { + if normalized, ok := normalizeXGoVersion(version); ok { + return normalized, true + } + // Display-form versions may include a comparable semantic base. Unversioned + // development builds are handled by comparableDriverVersion. + match := baseVersionRE.FindStringSubmatch(version) + if len(match) != 2 { + return "", false + } + return normalizeXGoVersion(match[1]) +} diff --git a/cmd/internal/run/run.go b/cmd/internal/run/run.go index 2d7ba52d8..0ac5ff803 100644 --- a/cmd/internal/run/run.go +++ b/cmd/internal/run/run.go @@ -18,6 +18,7 @@ package run import ( + "context" "fmt" "os" "reflect" @@ -25,6 +26,7 @@ import ( "github.com/goplus/gogen" "github.com/goplus/xgo/cl" "github.com/goplus/xgo/cmd/internal/base" + "github.com/goplus/xgo/cmd/internal/projectdriver" "github.com/goplus/xgo/tool" "github.com/goplus/xgo/x/gocmd" "github.com/goplus/xgo/x/xgoprojs" @@ -75,11 +77,32 @@ func runCmd(cmd *base.Command, args []string) { gogen.SetDebug(gogen.DbgFlagInstruction) } + noChdir := *flagNoChdir + driverFlags := append([]string(nil), pass.Args...) + if *flagAsm { + driverFlags = append(driverFlags, "-asm=true") + } + if *flagNoChdir { + driverFlags = append(driverFlags, "-nc=true") + } + if *flagProf { + driverFlags = append(driverFlags, "-prof=true") + } + driverResult, driverErr := tryDriver(proj, args, driverFlags) + if driverErr != nil { + fmt.Fprintln(os.Stderr, driverErr) + os.Exit(1) + } + if driverResult.Handled { + if driverResult.Status.Signaled || driverResult.Status.Code != 0 { + projectdriver.Exit(driverResult.Status) + } + return + } if *flagProf { panic("TODO: profile not impl") } - noChdir := *flagNoChdir conf, err := tool.NewDefaultConf(".", tool.ConfFlagNoTestFiles, pass.Tags()) if err != nil { log.Panicln("tool.NewDefaultConf:", err) @@ -94,6 +117,10 @@ func runCmd(cmd *base.Command, args []string) { run(proj, args, !noChdir, conf, confCmd) } +func tryDriver(proj xgoprojs.Proj, args, flags []string) (projectdriver.DispatchResult, error) { + return projectdriver.TryRun(context.Background(), "", proj, flags, args, projectdriver.Streams{}) +} + func run(proj xgoprojs.Proj, args []string, chDir bool, conf *tool.Config, run *gocmd.RunConfig) { const flags = 0 var obj string diff --git a/doc/gox.mod.md b/doc/gox.mod.md index 838888904..3982c15e3 100644 --- a/doc/gox.mod.md +++ b/doc/gox.mod.md @@ -221,7 +221,31 @@ mygame/ ``` --- - + +## Project drivers + +A framework may delegate project execution to a verified driver executable: + +```text +driver v1 example.com/framework/cmd/driver +``` + +The driver must be a `main` package inside the declaring module. XGo resolves +it from the effective `go.mod`/`go.work` graph, checks the class metadata +snapshot, then builds and runs it on the host platform. `xgo run`, `xgo build`, +and `xgo install` use the same driver protocol. + +The protocol version and the declaring module's `xgo` requirement are independent. +`driver v1` identifies the driver contract, first supported by XGo 1.8.0; +`xgo 1.9.0` would mean only that the declaring module needs XGo 1.9.0 or +later. The effective minimum is the higher of those two requirements. + +Project drivers are intentionally fail-closed: vendor and overlay-backed +driver-backed projects are rejected, and `XGO_DRIVER=off` disables dispatch. Keep +the driver package and its `gox.mod`/`gop.mod` in the framework module. + +--- + ## Summary `gox.mod` is the heart of XGo's classfile system, but it lives in **framework packages**, not in user projects. Ordinary XGo projects use a plain `go.mod` with `//xgo:class` annotations on their framework dependencies — that's the signal `xgo run` and other toolchain commands use to discover the relevant `gox.mod` files and learn the class structure (file patterns, class types, auto-imports) before parsing and compiling the project's source files. diff --git a/go.mod b/go.mod index 9502b1f4f..c5a43cc7e 100644 --- a/go.mod +++ b/go.mod @@ -10,12 +10,9 @@ require ( github.com/goplus/lib v0.3.1 github.com/goplus/mod v0.21.2 github.com/qiniu/x v1.18.3 + golang.org/x/mod v0.20.0 golang.org/x/net v0.57.0 -) - -require ( - golang.org/x/mod v0.20.0 // indirect - golang.org/x/sys v0.47.0 // indirect + golang.org/x/sys v0.47.0 ) retract v1.1.12